Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .moon/workspace.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ projects:
# The toolchain version-parity gate: asserts CI's PATH holds the dev shell's
# toolchain, and carries the unit tests for its own comparison logic.
toolchain-parity: 'tools/toolchain'
# The generator-stamp gate: asserts the checked-in gen trees' `@generated by`
# headers agree with each other and with the nixpkgs protoc-gen-es on PATH
# (SEA-1405). Separate from compass-proto because its subject is the plugin
# rather than the schema.
stamp-gate: 'tools/stamp-gate'
# Vendored upstream fork subtrees (forks/<name>, SEA-1512). Each carries its
# own nix-driven build as functional CI — so a fork-only change is gated by
# the fork's own suite — while its tree stays exempt from the style gates
Expand Down
9 changes: 9 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

53 changes: 53 additions & 0 deletions tools/stamp-gate/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* Entry point for the generator-stamp gate. Owns all the I/O — walking the gen
* trees and spawning the plugin — so `stamp-gate.ts` stays a pure decision the
* tests can drive without a filesystem.
*/
import { readFile } from "node:fs/promises";
import { resolve, sep } from "node:path";
import { Glob } from "bun";
import { assertGeneratorStamp, type CommandRunner, report } from "./stamp-gate";

// This file is tools/stamp-gate/index.ts, so `../..` is the workspace root.
const WORKSPACE_ROOT = resolve(import.meta.dir, "../..");

// Both TS gen trees: the public client and the agent's internal one. Both are
// produced by protoc-gen-es and both carry its stamp, so both must agree — a
// glob covering only one would miss exactly the partial-regeneration case this
// gate exists to name.
//
// INVARIANT: every protoc-gen-es out-dir lives under packages/*/src/gen. This
// glob must stay in sync with the `out:` paths in buf.gen*.yaml — a future gen
// tree under another workspace root (apps/*, tools/*) would be silently invisible
// to the gate (a non-empty-but-incomplete source set the fail-closed empty check
// cannot catch), so widen this glob whenever a buf template targets a new root.
const GEN_GLOB = "packages/*/src/gen/**/*_pb.ts";

const runner: CommandRunner = async (argv) => {
const proc = Bun.spawn(argv, { stdout: "pipe", stderr: "pipe" });
const [stdout, stderr, code] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
return { code, stdout, stderr };
};

async function main(): Promise<number> {
const sources: Array<{ path: string; text: string }> = [];
for await (const match of new Glob(GEN_GLOB).scan({ cwd: WORKSPACE_ROOT })) {
const path = match.split(sep).join("/");
sources.push({
path,
text: await readFile(resolve(WORKSPACE_ROOT, match), "utf8"),
});
}
// Sorted so the two paths named in a disagreement are stable across runs:
// a gate that names a different pair each time reads as flaky.
sources.sort((a, b) => a.path.localeCompare(b.path));

const result = await assertGeneratorStamp(runner, sources);
return report(result);
}

process.exit(await main());
49 changes: 49 additions & 0 deletions tools/stamp-gate/moon.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# yaml-language-server: $schema=https://moonrepo.dev/schemas/project.json
#
# The generator-stamp gate (SEA-1405). See stamp-gate.ts for why it exists;
# in short, protoc-gen-es is a nixpkgs store path with no lockfile pin, so the
# `@generated by` header in each gen tree is the only record of which plugin
# produced the checked-in code.
#
# TypeScript rather than a shell script (AGENTS.md): the decision has real
# logic — three distinct failure modes, each needing its own message — and the
# core is a pure function over injected inputs, so `bun test` covers every
# branch including the two fail-closed ones a shell version would drop.
layer: 'tool'
language: 'typescript'
tags: ['bun', 'oss']

tasks:
typecheck:
command: 'bunx tsc --noEmit'
deps: ['install']
inputs: ['*.ts', 'tsconfig.json', 'package.json', '/bun.lock']
test:
command: 'bun test'
deps: ['install']
inputs: ['*.ts', 'tsconfig.json', 'package.json', '/bun.lock']
check:
# The gate itself, against the real gen trees and the real plugin. Distinct
# from `test`, which drives the pure core over fixtures: this one is the
# only task that observes the actual plugin on PATH.
command: 'bun run tools/stamp-gate/index.ts'
deps: ['install']
options:
runFromWorkspaceRoot: true
# Never cache. The gate's subject is the plugin on PATH, which is NOT an
# input moon can hash — a devenv.lock bump moves the binary underneath an
# unchanged tree, and that is precisely the skew this gate exists to
# catch. A cached green would survive exactly the change it must red on.
cache: false
# With caching off these inputs do not form a cache key — the task always
# runs. They are kept as documentation of the gate's conceptual dependency
# set (the gen trees, the pin, the gate's own source), not as a hash input.
inputs:
- '/packages/*/src/gen/**/*_pb.ts'
- '/devenv.lock'
- 'index.ts'
- 'stamp-gate.ts'
ci:
deps: ['typecheck', 'test', 'check']
options:
cache: false
10 changes: 10 additions & 0 deletions tools/stamp-gate/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"name": "@compass/stamp-gate",
"private": true,
"type": "module",
"description": "The generator-stamp gate (SEA-1405). protoc-gen-es comes from the pinned nixpkgs with no lockfile pin, so the `@generated by` header on each checked-in file is the only record of which plugin produced it. This asserts those stamps agree with each other and with the plugin on PATH, so a nixpkgs bump reads as a named skew and a partial regeneration reads as a named intra-tree disagreement rather than as a mystery comment-only drift.",
"devDependencies": {
"@types/bun": "catalog:",
"typescript": "catalog:"
}
}
185 changes: 185 additions & 0 deletions tools/stamp-gate/stamp-gate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import { describe, expect, test } from "bun:test";
import {
assertGeneratorStamp,
type CommandRunner,
report,
type StampSource,
} from "./stamp-gate";

/** A runner that reports a healthy plugin at `version`. */
const healthy = (version: string): CommandRunner => {
return async () => ({
code: 0,
stdout: `protoc-gen-es v${version}\n`,
stderr: "",
});
};

const stamped = (path: string, version: string): StampSource => ({
path,
text: `// @generated by protoc-gen-es v${version} with parameter "target=ts"\n\nexport const X = 1;\n`,
});

describe("assertGeneratorStamp", () => {
test("passes when every stamp agrees with the plugin on PATH", async () => {
const r = await assertGeneratorStamp(healthy("2.12.0"), [
stamped(
"packages/compass-agent/src/gen/compass/v1/agent_pb.ts",
"2.12.0",
),
stamped(
"packages/compass-client/src/gen/compass/v1/compass_pb.ts",
"2.12.0",
),
]);
expect(r.pass).toBe(true);
expect(r.detail).toContain("2.12.0");
// The count is part of the pass line: a green over one file and a green
// over forty are different claims, and the gate should not render them
// identically.
expect(r.detail).toContain("2 files");
});

// FAIL-CLOSED 1. A glob that matches nothing must not grade as agreement.
// This is the whole reason the gate cannot be a `grep | uniq` one-liner:
// an empty pipeline is silent, and silence reads as consensus.
test("fails closed on an empty source set", async () => {
const r = await assertGeneratorStamp(healthy("2.12.0"), []);
expect(r.pass).toBe(false);
expect(r.detail).toBe("no generated sources to check");
});

// FAIL-CLOSED 2. protoc-gen-es exits 0 and prints its banner when healthy,
// so a wrapper that prints a plausible banner and then dies would otherwise
// be parsed as a working plugin. The exit code is checked BEFORE the banner.
test("fails closed on a non-zero plugin exit even when the banner parses", async () => {
const bannerThenDie: CommandRunner = async () => ({
code: 127,
stdout: "protoc-gen-es v2.12.0\n",
stderr: "error while loading shared libraries",
});
const r = await assertGeneratorStamp(bannerThenDie, [
stamped(
"packages/compass-client/src/gen/compass/v1/compass_pb.ts",
"2.12.0",
),
]);
expect(r.pass).toBe(false);
expect(r.detail).toContain("exited 127");
});

test("names both sides of an intra-tree stamp disagreement", async () => {
const r = await assertGeneratorStamp(healthy("2.12.0"), [
stamped(
"packages/compass-agent/src/gen/compass/v1/agent_pb.ts",
"2.12.0",
),
stamped(
"packages/compass-client/src/gen/compass/v1/compass_pb.ts",
"2.12.1",
),
]);
expect(r.pass).toBe(false);
// Both versions AND both paths: the reader has to know which tree lagged,
// not merely that something disagreed.
expect(r.detail).toContain("2.12.0");
expect(r.detail).toContain("2.12.1");
expect(r.detail).toContain("agent_pb.ts");
expect(r.detail).toContain("compass_pb.ts");
});

test("names the skew when the plugin moves under an unchanged tree", async () => {
const r = await assertGeneratorStamp(healthy("2.13.0"), [
stamped(
"packages/compass-client/src/gen/compass/v1/compass_pb.ts",
"2.12.0",
),
]);
expect(r.pass).toBe(false);
expect(r.detail).toBe("baked 2.13.0 != gen-tree stamp 2.12.0");
expect(r.output).toContain("compass-proto:gen");
});

test("fails when a generated file carries no stamp at all", async () => {
const r = await assertGeneratorStamp(healthy("2.12.0"), [
{
path: "packages/compass-client/src/gen/compass/v1/compass_pb.ts",
text: "export const X = 1;\n",
},
]);
expect(r.pass).toBe(false);
expect(r.detail).toBe("gen-tree stamp unreadable");
expect(r.output).toContain("compass_pb.ts");
});

test("fails when the plugin exits 0 but prints no recognisable version", async () => {
const silent: CommandRunner = async () => ({
code: 0,
stdout: "\n",
stderr: "",
});
const r = await assertGeneratorStamp(silent, [
stamped(
"packages/compass-client/src/gen/compass/v1/compass_pb.ts",
"2.12.0",
),
]);
expect(r.pass).toBe(false);
expect(r.detail).toContain("no version in");
});

// The banner regex is anchored on the plugin's own name so an unrelated
// semver printed alongside it cannot shadow the real one. Without the
// anchor a wrapper's own version wins and the gate compares the wrong pair
// — passing or failing for a reason that has nothing to do with the plugin.
test("reads the plugin's own version, not a bystander semver on the same output", async () => {
const noisy: CommandRunner = async () => ({
code: 0,
stdout: "wrapper v9.9.9\nprotoc-gen-es v2.12.0\n",
stderr: "",
});
const r = await assertGeneratorStamp(noisy, [
stamped(
"packages/compass-client/src/gen/compass/v1/compass_pb.ts",
"2.12.0",
),
]);
expect(r.pass).toBe(true);
});

// Prerelease and build metadata are legal semver and nixpkgs does ship them.
// A regex accepting only `x.y.z` would report "no version in output" for a
// perfectly healthy plugin — a false red, which costs more than a false
// green because a closed door does not get re-tested.
test("accepts a prerelease version on both sides", async () => {
const r = await assertGeneratorStamp(healthy("2.13.0-rc.1"), [
stamped(
"packages/compass-client/src/gen/compass/v1/compass_pb.ts",
"2.13.0-rc.1",
),
]);
expect(r.pass).toBe(true);
});
});

describe("report", () => {
test("a failing result maps to exit code 1", () => {
expect(
report({
pass: false,
detail: "baked 2.13.0 != gen-tree stamp 2.12.0",
output: "",
}),
).toBe(1);
});

test("a passing result maps to exit code 0", () => {
expect(
report({
pass: true,
detail: "2.12.0 == gen-tree stamp (6 files)",
output: "",
}),
).toBe(0);
});
});
Loading
Loading