|
| 1 | +// Drift-protection for the CLI stdout output contract. |
| 2 | +// |
| 3 | +// Contract (see openspec/specs/cli-output-shape/spec.md): |
| 4 | +// - Success-path stdout SHALL NOT contain a top-level `status` field. |
| 5 | +// - The stderr error envelope (in cli/lib/sdk-errors.mjs) DOES use |
| 6 | +// `status: "error"` as a sentinel; that is the allowlisted exception. |
| 7 | +// - Per-item `status` fields inside payload objects (e.g. doctor's |
| 8 | +// `checks[].status`) are NOT envelope statuses and are not matched |
| 9 | +// by this scanner (the regex only matches `JSON.stringify({ status:`). |
| 10 | +// |
| 11 | +// If you are tempted to add a new `JSON.stringify({ status: ... })` to a |
| 12 | +// CLI subcommand handler, instead emit the raw payload. If the mutation |
| 13 | +// has no natural payload, echo the affected resource identifiers plus an |
| 14 | +// explicit boolean state field (e.g. `{ key, project_id, deleted: true }`). |
| 15 | + |
| 16 | +import { describe, it } from "node:test"; |
| 17 | +import assert from "node:assert/strict"; |
| 18 | +import { readFileSync, readdirSync } from "node:fs"; |
| 19 | +import { join, dirname } from "node:path"; |
| 20 | +import { fileURLToPath } from "node:url"; |
| 21 | + |
| 22 | +const __dirname = dirname(fileURLToPath(import.meta.url)); |
| 23 | +const CLI_LIB_DIR = join(__dirname, "cli", "lib"); |
| 24 | + |
| 25 | +// Allowlist: file basenames whose `JSON.stringify({ status: ...` emissions |
| 26 | +// are legitimately stderr-bound error envelopes. |
| 27 | +const STDERR_ERROR_ENVELOPE_ALLOWLIST = new Set([ |
| 28 | + "sdk-errors.mjs", |
| 29 | +]); |
| 30 | + |
| 31 | +// Match `JSON.stringify({ status: "<literal>"` allowing whitespace and |
| 32 | +// newlines between paren, brace, and the `status` key. Multi-line tolerant. |
| 33 | +const ENVELOPE_PATTERN = /JSON\.stringify\s*\(\s*\{\s*status\s*:\s*"([^"]+)"/g; |
| 34 | + |
| 35 | +describe("CLI output contract drift protection", () => { |
| 36 | + it("no cli/lib/*.mjs file emits a top-level `status` field on success paths", () => { |
| 37 | + const files = readdirSync(CLI_LIB_DIR).filter((f) => f.endsWith(".mjs") && !f.endsWith(".test.mjs")); |
| 38 | + const violations = []; |
| 39 | + |
| 40 | + for (const file of files) { |
| 41 | + if (STDERR_ERROR_ENVELOPE_ALLOWLIST.has(file)) continue; |
| 42 | + const fullPath = join(CLI_LIB_DIR, file); |
| 43 | + const source = readFileSync(fullPath, "utf-8"); |
| 44 | + const matches = source.matchAll(ENVELOPE_PATTERN); |
| 45 | + for (const match of matches) { |
| 46 | + const offset = match.index ?? 0; |
| 47 | + const lineNumber = source.slice(0, offset).split("\n").length; |
| 48 | + const statusValue = match[1]; |
| 49 | + violations.push({ file, line: lineNumber, statusValue }); |
| 50 | + } |
| 51 | + } |
| 52 | + |
| 53 | + if (violations.length > 0) { |
| 54 | + const summary = violations |
| 55 | + .map((v) => ` cli/lib/${v.file}:${v.line} → JSON.stringify({ status: "${v.statusValue}" ...`) |
| 56 | + .join("\n"); |
| 57 | + assert.fail( |
| 58 | + `Found ${violations.length} disallowed top-level \`status\` emission${violations.length === 1 ? "" : "s"} ` + |
| 59 | + `in CLI success paths:\n${summary}\n\n` + |
| 60 | + `The CLI stdout envelope contract (openspec/specs/cli-output-shape/spec.md) forbids a top-level ` + |
| 61 | + `\`status\` field on success-path stdout. Emit the raw payload instead. For mutations with no natural ` + |
| 62 | + `payload, echo the affected resource identifiers plus an explicit boolean state field ` + |
| 63 | + `(e.g. \`{ key, project_id, deleted: true }\`). The only allowlisted emission is the stderr error ` + |
| 64 | + `envelope in cli/lib/sdk-errors.mjs.`, |
| 65 | + ); |
| 66 | + } |
| 67 | + }); |
| 68 | + |
| 69 | + it("the stderr error envelope in sdk-errors.mjs continues to use status: \"error\"", () => { |
| 70 | + // This is a positive assertion: sdk-errors.mjs MUST keep the |
| 71 | + // `status: "error"` sentinel on stderr so consumers can branch on it. |
| 72 | + const source = readFileSync(join(CLI_LIB_DIR, "sdk-errors.mjs"), "utf-8"); |
| 73 | + assert.match(source, /status:\s*"error"/, "sdk-errors.mjs must keep the `status: \"error\"` sentinel for the stderr error envelope"); |
| 74 | + }); |
| 75 | +}); |
0 commit comments