diff --git a/.moon/workspace.yml b/.moon/workspace.yml index 22fef916..0bbd80d5 100644 --- a/.moon/workspace.yml +++ b/.moon/workspace.yml @@ -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/, 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 diff --git a/bun.lock b/bun.lock index afa31939..6e7e8ac1 100644 --- a/bun.lock +++ b/bun.lock @@ -69,6 +69,13 @@ "typescript": "catalog:", }, }, + "tools/stamp-gate": { + "name": "@compass/stamp-gate", + "devDependencies": { + "@types/bun": "catalog:", + "typescript": "catalog:", + }, + }, "tools/toolchain": { "name": "@compass/toolchain-parity", "version": "0.1.0", @@ -179,6 +186,8 @@ "@compass/client": ["@compass/client@workspace:packages/compass-client"], + "@compass/stamp-gate": ["@compass/stamp-gate@workspace:tools/stamp-gate"], + "@compass/toolchain-parity": ["@compass/toolchain-parity@workspace:tools/toolchain"], "@compass/ui": ["@compass/ui@workspace:apps/ui"], diff --git a/tools/stamp-gate/index.ts b/tools/stamp-gate/index.ts new file mode 100644 index 00000000..e81c8a36 --- /dev/null +++ b/tools/stamp-gate/index.ts @@ -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 { + 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()); diff --git a/tools/stamp-gate/moon.yml b/tools/stamp-gate/moon.yml new file mode 100644 index 00000000..f3b0284b --- /dev/null +++ b/tools/stamp-gate/moon.yml @@ -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 diff --git a/tools/stamp-gate/package.json b/tools/stamp-gate/package.json new file mode 100644 index 00000000..7d5a5130 --- /dev/null +++ b/tools/stamp-gate/package.json @@ -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:" + } +} diff --git a/tools/stamp-gate/stamp-gate.test.ts b/tools/stamp-gate/stamp-gate.test.ts new file mode 100644 index 00000000..feeb85ab --- /dev/null +++ b/tools/stamp-gate/stamp-gate.test.ts @@ -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); + }); +}); diff --git a/tools/stamp-gate/stamp-gate.ts b/tools/stamp-gate/stamp-gate.ts new file mode 100644 index 00000000..57703d9a --- /dev/null +++ b/tools/stamp-gate/stamp-gate.ts @@ -0,0 +1,150 @@ +/** + * The generator-stamp gate (SEA-1405). + * + * `protoc-gen-es` comes from the pinned nixpkgs (devenv.nix), so its version is + * the nixpkgs's to choose and there is no lockfile pin to compare against. What + * DOES record which generator produced the checked-in code is the + * `// @generated by protoc-gen-es v` header every file in the gen + * trees carries. + * + * This gate asserts those stamps agree with each other AND with the plugin on + * PATH. A `devenv.lock` bump that moves the plugin surfaces as a named skew + * ("baked X != gen-tree stamp Y"), and a partial regeneration — the two TS gen + * trees are produced by separate `buf generate` invocations, so one can restamp + * while the other lags — surfaces as a named intra-tree disagreement. + * + * `compass-proto:drift` catches the OUTCOME of both, but reports it as a + * comment-only diff on a PR that touched neither the schema nor the gen trees. + * This gate names the cause, so the fix reads as `moon run compass-proto:gen` + * rather than as a mystery red. + * + * Pure: the command runner and the generated source texts are injected, so the + * whole decision is unit-testable without a filesystem or a plugin. + */ + +/** Spawns a command and returns its captured output. Injected for testability. */ +export type CommandRunner = ( + argv: string[], +) => Promise<{ code: number; stdout: string; stderr: string }>; + +/** A checked-in generated file: its repo-relative path and its full text. */ +export interface StampSource { + path: string; + text: string; +} + +export interface GateResult { + pass: boolean; + /** One line, suitable as a summary. */ + detail: string; + /** Multi-line remediation context. Empty on pass. */ + output: string; +} + +/** + * The plugin's own banner line. Anchored on `protoc-gen-es` so a wrapper line + * carrying an unrelated semver cannot shadow the real version. + */ +const BANNER = /protoc-gen-es v(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.+-]+)?)/; + +/** The header protoc-gen-es writes at the top of every file it generates. */ +const STAMP = + /@generated by protoc-gen-es v(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.+-]+)?)/; + +const VERSION_ARGV = ["protoc-gen-es", "--version"]; + +const REGEN = + "Fix: run `moon run compass-proto:gen` and commit the restamped generated trees."; + +export async function assertGeneratorStamp( + run: CommandRunner, + sources: StampSource[], +): Promise { + // An empty source set fails closed. A glob that matches nothing would + // otherwise make the gate vacuously green — passing by never looking, which + // is indistinguishable from passing by agreeing. + if (sources.length === 0) { + return { + pass: false, + detail: "no generated sources to check", + output: + "no generated `_pb.ts` sources were found, so there is no `@generated by protoc-gen-es v` stamp to compare against the plugin on PATH. Either the glob is wrong or the gen trees are missing.", + }; + } + + const stamps: Array<{ path: string; version: string }> = []; + for (const source of sources) { + const version = source.text.match(STAMP)?.[1]; + if (version === undefined) { + return { + pass: false, + detail: "gen-tree stamp unreadable", + output: `no \`// @generated by protoc-gen-es v\` header in ${source.path}`, + }; + } + stamps.push({ path: source.path, version }); + } + + const first = stamps[0] as { path: string; version: string }; + const other = stamps.find((s) => s.version !== first.version); + if (other !== undefined) { + return { + pass: false, + detail: `gen-tree stamps disagree: ${first.version} (${first.path}) vs ${other.version} (${other.path})`, + output: `the checked-in generated trees are stamped by more than one protoc-gen-es version, the likely cause of which is a partial regeneration — the trees come from three separate \`buf generate\` invocations. ${REGEN}`, + }; + } + const stamped = first.version; + + const r = await run(VERSION_ARGV); + // protoc-gen-es exits 0 and prints its banner when healthy, so the exit code + // is load-bearing: fail closed BEFORE parsing rather than trust the banner of + // a dying plugin. A banner-then-die wrapper (wrong arch, missing interpreter) + // must not grade as a working plugin. + if (r.code !== 0) { + return { + pass: false, + detail: `\`${VERSION_ARGV.join(" ")}\` exited ${r.code}`, + output: `${r.stdout}\n${r.stderr}`.trim(), + }; + } + + const actual = r.stdout.match(BANNER)?.[1]; + if (actual === undefined) { + return { + pass: false, + detail: `no version in \`${VERSION_ARGV.join(" ")}\` output`, + output: `${r.stdout}\n${r.stderr}`.trim(), + }; + } + + if (actual !== stamped) { + return { + pass: false, + detail: `baked ${actual} != gen-tree stamp ${stamped}`, + output: `the plugin on PATH (${actual}) and the checked-in generated code (stamped ${stamped}) disagree: regenerating would rewrite every generated file's header. ${REGEN}`, + }; + } + + return { + pass: true, + detail: `${actual} == gen-tree stamp (${sources.length} files)`, + output: "", + }; +} + +/** + * Maps a gate result to a process exit code, emitting the summary and any + * remediation context. The single line that makes the gate fail-closed at the + * process boundary; extracted from the I/O shell so an accidental inversion of + * the exit mapping reds a unit test rather than only the integration check. + */ +export function report(result: GateResult): number { + if (!result.pass) { + console.error(`protoc-gen-es-stamp: ${result.detail}`); + if (result.output) console.error(result.output); + return 1; + } + console.log(`protoc-gen-es-stamp ok: ${result.detail}`); + return 0; +} diff --git a/tools/stamp-gate/tsconfig.json b/tools/stamp-gate/tsconfig.json new file mode 100644 index 00000000..612d04ce --- /dev/null +++ b/tools/stamp-gate/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "preserve", + "moduleResolution": "bundler", + "strict": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["bun"] + }, + "include": ["."] +}