-
Notifications
You must be signed in to change notification settings - Fork 0
fix(version): derive every CLI version surface from package.json + gate it in CI (VER-001) #46
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,26 +1,173 @@ | ||
| import { readFileSync } from "node:fs"; | ||
| import { dirname, join } from "node:path"; | ||
| import { readFileSync, readdirSync } from "node:fs"; | ||
| import { dirname, join, relative, sep } from "node:path"; | ||
| import { fileURLToPath } from "node:url"; | ||
| import { describe, expect, it } from "vitest"; | ||
| import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | ||
| import { createProgram } from "./cli.js"; | ||
| import { CLI_VERSION, UNKNOWN_VERSION, cliUserAgent } from "./lib/version.js"; | ||
|
|
||
| const __dirname = dirname(fileURLToPath(import.meta.url)); | ||
| const SRC_DIR = __dirname; | ||
| const PKG_VERSION = ( | ||
| JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8")) as { | ||
| version: string; | ||
| } | ||
| ).version; | ||
|
|
||
| /** Strip SGR colour codes so assertions run against the text a user actually reads. */ | ||
| const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g"); | ||
| const stripAnsi = (s: string): string => s.replace(ANSI, ""); | ||
|
|
||
| /** | ||
| * Regression test for `wave --version` printing a hardcoded "1.0.0" instead of the actual | ||
| * published package version (1.0.8+). See CHANGELOG for the incident. | ||
| * VER-001 — every version-bearing surface must agree with package.json. | ||
| * | ||
| * Background: published @wave-av/cli@1.0.8 printed `1.0.0` from `wave --version` because the | ||
| * version was a hardcoded literal that stopped tracking package.json. `--version` was fixed to | ||
| * derive from package.json, but two literals survived on the wire (`X-Wave-CLI-Version` and the | ||
| * `User-Agent`), so the gateway still saw 1.0.0. These tests fail if ANY of the four surfaces — | ||
| * package.json, `--version`, the help banner, the outbound headers — drift apart again, and the | ||
| * scan below fails if a new hardcoded literal is introduced anywhere under src/. | ||
| */ | ||
| describe("wave --version", () => { | ||
| it("reports the version from package.json, not a hardcoded string", () => { | ||
| const pkg = JSON.parse( | ||
| readFileSync(join(__dirname, "..", "package.json"), "utf-8"), | ||
| ) as { version: string }; | ||
| describe("VER-001: CLI version is a single source of truth", () => { | ||
| it("derives CLI_VERSION from package.json", () => { | ||
| expect(CLI_VERSION).toBe(PKG_VERSION); | ||
| expect(CLI_VERSION).not.toBe(UNKNOWN_VERSION); | ||
| }); | ||
|
|
||
| it("reports the version from package.json via --version, not a hardcoded string", () => { | ||
| const program = createProgram(); | ||
| expect(program.version()).toBe(pkg.version); | ||
| expect(program.version()).toBe(PKG_VERSION); | ||
| // The bug shipped as literally "1.0.0" regardless of the real published version. | ||
| if (pkg.version !== "1.0.0") { | ||
| if (PKG_VERSION !== "1.0.0") { | ||
| expect(program.version()).not.toBe("1.0.0"); | ||
| } | ||
| }); | ||
|
|
||
| it("sends the same version on the wire as it prints", () => { | ||
| expect(cliUserAgent()).toBe(`wave-cli/${PKG_VERSION}`); | ||
| }); | ||
| }); | ||
|
|
||
| /** | ||
| * The banner is a SECOND, independent rendering of the version — published 1.0.8 disagreed with | ||
| * itself here. `printBanner` is module-private, and the banner hook is only installed for humans, | ||
| * so the reachable path is: clear the CI/agent env vars, then call `program.helpInformation()`. | ||
| */ | ||
| describe("VER-001: help banner agrees with package.json", () => { | ||
| const SUPPRESSING_ENV = [ | ||
| "CI", | ||
| "GITHUB_ACTIONS", | ||
| "VERCEL", | ||
| "BUILDKITE", | ||
| "GITLAB_CI", | ||
| "CIRCLECI", | ||
| "WAVE_AGENT", | ||
| "CLAUDE_CODE", | ||
| "CURSOR_SESSION", | ||
| "AIDER_SESSION", | ||
| "CONTINUE_SESSION", | ||
| ] as const; | ||
|
|
||
| let saved: Record<string, string | undefined> = {}; | ||
|
|
||
| beforeEach(() => { | ||
| saved = {}; | ||
| for (const key of SUPPRESSING_ENV) { | ||
| saved[key] = process.env[key]; | ||
| delete process.env[key]; | ||
| } | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| for (const [key, value] of Object.entries(saved)) { | ||
| if (value === undefined) delete process.env[key]; | ||
| else process.env[key] = value; | ||
| } | ||
| vi.restoreAllMocks(); | ||
| }); | ||
|
|
||
| it("prints v<package.json version> in the banner", () => { | ||
| const lines: string[] = []; | ||
| vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => { | ||
| lines.push(args.map(String).join(" ")); | ||
| }); | ||
|
|
||
| const program = createProgram(); | ||
| program.helpInformation(); | ||
|
|
||
| const banner = stripAnsi(lines.join("\n")); | ||
| const match = /\bv(\d+\.\d+\.\d+\S*)/.exec(banner); | ||
|
|
||
| expect(match, `no version found in banner:\n${banner}`).not.toBeNull(); | ||
| expect(match?.[1]).toBe(PKG_VERSION); | ||
| }); | ||
| }); | ||
|
|
||
| /** | ||
| * The defect CLASS gate. Updating a literal to the current version reproduces the bug at the next | ||
| * release; the only durable fix is that no version literal exists in src/ at all. Anything matched | ||
| * here must either derive from `lib/version.ts` or earn an explicit, reasoned allowlist entry that | ||
| * a reviewer has to see in the diff. | ||
| */ | ||
| const LITERAL_ALLOWLIST: ReadonlyArray<{ file: string; literal: string; reason: string }> = [ | ||
| { | ||
| file: "lib/config/schema.ts", | ||
| literal: "1.0.0", | ||
| reason: | ||
| "on-disk CONFIG FILE schema version. Deliberately independent of the CLI version — it " + | ||
| "changes only when the config file format changes, and must NOT track releases.", | ||
| }, | ||
| { | ||
| file: "lib/version.ts", | ||
| literal: "0.0.0", | ||
| reason: | ||
| "the UNKNOWN_VERSION sentinel returned when package.json cannot be read. Intentionally " + | ||
| "not a plausible version so a broken install is obvious rather than silently wrong.", | ||
| }, | ||
| ]; | ||
|
|
||
| function listTsFiles(dir: string): string[] { | ||
| const out: string[] = []; | ||
| for (const entry of readdirSync(dir, { withFileTypes: true })) { | ||
| const full = join(dir, entry.name); | ||
| if (entry.isDirectory()) { | ||
| out.push(...listTsFiles(full)); | ||
| } else if (entry.name.endsWith(".ts") && !entry.name.endsWith(".test.ts")) { | ||
| out.push(full); | ||
| } | ||
| } | ||
| return out; | ||
| } | ||
|
|
||
| /** Whole-line comments only: a doc comment may legitimately narrate the 1.0.0 incident. */ | ||
| function isCommentLine(line: string): boolean { | ||
| const t = line.trimStart(); | ||
| return t.startsWith("//") || t.startsWith("*") || t.startsWith("/*"); | ||
| } | ||
|
|
||
| describe("VER-001: no hardcoded version literals under src/", () => { | ||
| it("finds every x.y.z literal derived from lib/version.ts or explicitly allowlisted", () => { | ||
| const offenders: string[] = []; | ||
|
|
||
| for (const file of listTsFiles(SRC_DIR)) { | ||
| const rel = relative(SRC_DIR, file).split(sep).join("/"); | ||
| const lines = readFileSync(file, "utf-8").split("\n"); | ||
|
|
||
| lines.forEach((line, i) => { | ||
| if (isCommentLine(line)) return; | ||
| for (const m of line.matchAll(/\b\d+\.\d+\.\d+/g)) { | ||
| const allowed = LITERAL_ALLOWLIST.some( | ||
| (a) => a.file === rel && a.literal === m[0], | ||
| ); | ||
| if (!allowed) offenders.push(`${rel}:${i + 1} ${line.trim()}`); | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| expect( | ||
| offenders, | ||
| "Hardcoded version literal(s) found. Import CLI_VERSION / cliUserAgent() from " + | ||
| "src/lib/version.ts instead of writing a version string, or add a reasoned entry to " + | ||
| "LITERAL_ALLOWLIST in this file.", | ||
| ).toEqual([]); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| import { existsSync, readFileSync } from "node:fs"; | ||
| import { dirname, join, parse } from "node:path"; | ||
| import { fileURLToPath } from "node:url"; | ||
|
|
||
| /** | ||
| * The single source of truth for "what version of the WAVE CLI is this?". | ||
| * | ||
| * Every version-bearing surface — `wave --version`, the help banner, the outbound | ||
| * `X-Wave-CLI-Version` header and the `User-Agent` — MUST derive from this module. | ||
| * A hardcoded version literal anywhere else is the defect class, not a typo: the | ||
| * published CLI shipped a literal that stopped tracking package.json and reported a | ||
| * stale version to users and to the gateway long after the real version had moved on. | ||
| * `src/cli.test.ts` scans `src/` and fails the build if a new literal appears. | ||
| * | ||
| * Resolution walks UP from this module's own location to the nearest directory holding | ||
| * a package.json with a string `version`. That is deliberately depth-independent: in | ||
| * development this file is `src/lib/version.ts` (two levels below the package root), | ||
| * while the shipped bundle is a single `dist/index.js` (one level below it). A fixed | ||
| * `../package.json` would be correct in exactly one of those two layouts and silently | ||
| * wrong in the other, which is how depth-coupled version reads break at publish time. | ||
| */ | ||
|
|
||
| /** Returned when package.json cannot be located or parsed — never a plausible-looking version. */ | ||
| export const UNKNOWN_VERSION = "0.0.0-unknown"; | ||
|
|
||
| function readOwnVersion(): string { | ||
| try { | ||
| let dir = dirname(fileURLToPath(import.meta.url)); | ||
| const { root } = parse(dir); | ||
|
|
||
| for (;;) { | ||
| const candidate = join(dir, "package.json"); | ||
| if (existsSync(candidate)) { | ||
| const pkg = JSON.parse(readFileSync(candidate, "utf-8")) as { version?: unknown }; | ||
| if (typeof pkg.version === "string" && pkg.version.length > 0) { | ||
| return pkg.version; | ||
| } | ||
| } | ||
|
|
||
| if (dir === root) break; | ||
| const parent = dirname(dir); | ||
|
Comment on lines
+27
to
+41
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💡 Edge Case: A malformed ancestor package.json aborts the walk-up entirelyThe try/catch wraps the whole walk-up loop, so if any ancestor directory (not the CLI's own) contains a package.json with invalid JSON — plausible when installed nested inside another tool's node_modules or a monorepo workspace — Catch parse errors per-directory so one bad ancestor package.json doesn't abort the whole resolution.: Was this helpful? React with 👍 / 👎 |
||
| if (parent === dir) break; | ||
| dir = parent; | ||
| } | ||
|
|
||
| return UNKNOWN_VERSION; | ||
| } catch { | ||
| return UNKNOWN_VERSION; | ||
| } | ||
| } | ||
|
|
||
| /** The running CLI's version, read once from package.json at process start. */ | ||
| export const CLI_VERSION = readOwnVersion(); | ||
|
|
||
| /** | ||
| * The canonical outbound User-Agent. Centralised so every HTTP caller reports the same | ||
| * version as `wave --version` — see the Corridor guardrail on constructing outbound | ||
| * request headers through a single utility rather than inline per call site. | ||
| */ | ||
| export function cliUserAgent(): string { | ||
| return `wave-cli/${CLI_VERSION}`; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💡 Quality: No direct test asserts the outbound header values at the call sites
cliUserAgent()andCLI_VERSIONare unit-tested in isolation, but no test asserts that the actualUser-Agent/X-Wave-CLI-Versionheaders built incommands/api/index.tsandlib/api-client.tsuse them (e.g. via a mocked client/fetch). This is low-risk given the trivial substitution, but a quick assertion on the constructedheadersobject in each call site would close the gap the incident was specifically about (headers silently drifting from the printed version).Was this helpful? React with 👍 / 👎