Files
msd-core/tests/repo-invariants.test.cjs
Tom Boucher 697cbb1f05 test(#1977): consolidate 22 misc + repo-invariant regression tests
Final epic-#1969 batch. Fold 22 issue-named files: the 4 genuine repo-wide invariant
scans (551-eslint-bin-lib-coverage, bug-3054 stale /gsd-next, bug-3810 no-gsd-sdk-runtime-refs,
feat-3593 cli-negative-universal) into a NEW shared repo-invariants.test.cjs; the other 18 as
singletons into their nearest module suite (model-resolver, codex-config, runtime-converters,
security, state-transition, worktree-safety, roadmap-parser, etc.). Verbatim block-scoped
describe wrappers; 334 subtests conserved 1:1.

Host-env pre-check (B2+B6): the 6 CLI folds into GSD_TEST_MODE-setting hosts (model-resolver/
codex-config/runtime-converters) are benign — each origin independently sets GSD_TEST_MODE=1
itself (idempotent), unlike the B6 real-install case.

Regenerates regression-name allowlist (222->213), ratchets file-count allowlist (state 17->16),
makes 7 relocated allow-test-rule exemptions issue-ref-compliant (ADR-456; prunes stale ids).
Repoints 2 tests/ refs in docs/TESTING-SUITES.md. lint:ci green.

Part of epic #1969. Closes #1977.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 10:06:11 -04:00

463 lines
21 KiB
JavaScript

'use strict';
// Repo-wide invariant scans.
//
// Consolidated home (epic #1969, batch B8 #1977) for regression tests that walk
// whole top-level directories (docs/, agents/, commands/, workflows/, references/,
// templates/, bin/lib/, …) asserting a CROSS-CUTTING invariant with no single
// module subject — e.g. "no retired /gsd-next token in any doc", "no gsd-sdk
// reference in any runtime surface", "ESLint coverage tracks the bin/lib migration",
// "every CLI command family fails cleanly on bad input". Each block below is folded
// verbatim from its origin issue-named file and keeps its origin issue number.
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/551-eslint-bin-lib-coverage.test.cjs — consolidation epic #1969 (B8 #1977)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:551-eslint-bin-lib-coverage (consolidation epic #1969 B8 #1977)", () => {
'use strict';
/**
* Regression / migration-gate test for #551 and ADR-457 (TS migration).
*
* ESLint must apply the correct policy to every gsd-core/bin/lib/*.cjs
* file as modules migrate from hand-written CJS to tsc-generated artifacts:
*
* - tsc-generated artifact (has src/<basename>.cts counterpart) → MUST be
* eslint-ignored. We lint the *.cts source instead (ADR-457).
* - Genuinely hand-written (no src/*.cts counterpart) → MUST be linted
* (NOT ignored). Includes scripts-generated package-identity.cjs which
* has no *.cts source.
*
* The test is filesystem-driven — it scans bin/lib at runtime and checks each
* file against the src/ directory, so it stays correct automatically as more
* modules migrate. No hardcoded lists.
*
* ESLint behaviour is verified via ESLint's own `isPathIgnored()` API so the
* test reflects real resolved flat-config precedence, not a textual scan of
* eslint.config.mjs.
*/
const { describe, test, before } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { ESLint } = require('eslint');
const ROOT = path.resolve(__dirname, '..');
const LIB_DIR = path.join(ROOT, 'gsd-core', 'bin', 'lib');
const SRC_DIR = path.join(ROOT, 'src');
/**
* Returns true if the given bin/lib/*.cjs file has a corresponding
* src/<basename>.cts TypeScript source (meaning it is tsc-generated).
*/
function hasTsSource(absPath) {
const base = path.basename(absPath, '.cjs');
return (
fs.existsSync(path.join(SRC_DIR, `${base}.cts`)) ||
fs.existsSync(path.join(SRC_DIR, `${base}.ts`))
);
}
let eslint;
before(() => {
eslint = new ESLint({ cwd: ROOT });
});
describe('ESLint coverage tracks the bin/lib TS migration (ADR-457 / #537)', () => {
/**
* Main invariant: scan every *.cjs in bin/lib and assert the correct ESLint
* policy is applied.
*/
test('each bin/lib/*.cjs is linted xor ignored according to migration state', async () => {
const wronglyIgnored = []; // hand-written but ignored — should be linted
const wronglyLinted = []; // tsc-generated but not ignored — should be ignored
const entries = fs.readdirSync(LIB_DIR).filter((e) => e.endsWith('.cjs'));
for (const entry of entries) {
const abs = path.join(LIB_DIR, entry);
const generated = hasTsSource(abs);
const ignored = await eslint.isPathIgnored(abs);
if (generated && !ignored) {
wronglyLinted.push(entry);
} else if (!generated && ignored) {
wronglyIgnored.push(entry);
}
}
assert.deepEqual(
wronglyLinted,
[],
`tsc-generated bin/lib modules not yet added to ESLint ignore list: ${wronglyLinted.join(', ')}`,
);
assert.deepEqual(
wronglyIgnored,
[],
`Hand-written bin/lib modules silently excluded from ESLint: ${wronglyIgnored.join(', ')}`,
);
});
test('semver-compare.cjs (tsc-generated publish artifact) stays eslint-ignored (ADR-457)', async () => {
const f = path.join(LIB_DIR, 'semver-compare.cjs');
assert.equal(
await eslint.isPathIgnored(f),
true,
'semver-compare.cjs is a tsc-generated publish-time artifact and must stay ignored',
);
});
test('package-identity.cjs (script-generated, no *.cts source) is linted, not ignored (#551)', async () => {
const f = path.join(LIB_DIR, 'package-identity.cjs');
assert.equal(
await eslint.isPathIgnored(f),
false,
'package-identity.cjs has no src/*.cts counterpart and must be linted, not ignored',
);
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-3054-stale-gsd-next-references.test.cjs — consolidation epic #1969 (B8 #1977)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-3054-stale-gsd-next-references (consolidation epic #1969 B8 #1977)", () => {
'use strict';
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
function walkMd(dir, out = []) {
if (!fs.existsSync(dir)) return out;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walkMd(full, out);
else if (entry.name.endsWith('.md')) out.push(full);
}
return out;
}
function extractSlashCommandTokens(markdown) {
const tokenRe = /\/gsd-[a-z0-9-]+/gi;
const tokens = new Set();
let m;
while ((m = tokenRe.exec(markdown)) !== null) {
tokens.add(m[0]);
}
return tokens;
}
describe('bug #3054: user-facing docs should not reference removed /gsd-next command', () => {
test('docs, workflows, and README surfaces use /gsd-progress --next instead', () => {
const root = path.join(__dirname, '..');
const files = [
...walkMd(path.join(root, 'docs')),
...walkMd(path.join(root, 'gsd-core', 'workflows')),
...fs.readdirSync(root).filter((f) => /^README.*\.md$/.test(f)).map((f) => path.join(root, f)),
];
const offenders = [];
for (const file of files) {
const content = fs.readFileSync(file, 'utf8');
const tokens = extractSlashCommandTokens(content);
if (tokens.has('/gsd-next')) offenders.push(path.relative(root, file));
}
assert.deepStrictEqual(offenders, [], `stale /gsd-next references remain in: ${offenders.join(', ')}`);
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-3810-no-gsd-sdk-runtime-refs.test.cjs — consolidation epic #1969 (B8 #1977)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-3810-no-gsd-sdk-runtime-refs (consolidation epic #1969 B8 #1977)", () => {
// allow-test-rule: source-text-is-the-product (see #3810)
// Runtime prompt/hook files are deployed verbatim — their text IS what the
// runtime loads and executes. Asserting that text carries no retired `gsd-sdk`
// reference tests the deployed contract, which no behavioral seam can observe
// (there is no runtime API that enumerates "did any shipped prompt name the
// removed SDK binary").
/**
* Regression guard: no `gsd-sdk` references in runtime-facing surfaces (#339).
*
* The `@opengsd/gsd-sdk` package and its `gsd-sdk` binary were retired (ADR 0174,
* #191). The bulk runtime cleanup is already done — this test locks it in so a
* `gsd-sdk` / `GSD_SDK` reference cannot creep back into a shipped prompt or hook
* and re-introduce drift between the documented surface and the supported
* `gsd-tools` binary.
*
* Scope: runtime surfaces only — the prompts and hooks the installer ships into
* a user's runtime config dir. Explicitly NOT covered here:
* - `bin/install.js` — installer code, not a runtime-deployed prompt/hook
* surface. (It carries zero `gsd-sdk` references today; the SDK-shim
* verification subsystem was removed in #515 and the shim retired in #522.)
* - `gsd-core/bin/` — executable library code, not deployed prompt text; it
* may legitimately reference the SDK retirement in comments.
* - `tests/`, `docs/`, `.changeset/`, CI/lint scripts — legitimately reference
* the SDK retirement as history or detect its stale artifacts.
*
* Complements `tests/gsd-tools-path-refs.test.cjs`, which only catches the
* `gsd-sdk query` binary-invocation form; this catches ANY runtime reference.
*/
'use strict';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const REPO_ROOT = path.join(__dirname, '..');
// Runtime surfaces the installer ships. Each entry is { dir, exts } — dir is
// repo-relative, exts is the set of file extensions whose text is deployed.
const RUNTIME_SURFACES = [
// .md prompts plus the non-.md runtime artifacts this dir also ships:
// _runtime-launcher.snippet.sh (the canonical launcher synced into every hook
// by scripts/sync-runtime-launcher.cjs) and discuss-phase/templates/*.json
// (loaded at runtime by discuss-phase.md). Scanning only .md left these two
// deployed files uncovered. (#691 review)
{ dir: path.join('gsd-core', 'workflows'), exts: ['.md', '.sh', '.json'] },
{ dir: path.join('gsd-core', 'references'), exts: ['.md'] },
// Prompt surfaces the installer deep-copies and the runtime loads via
// `@~/.claude/gsd-core/templates/*.md` anchors in workflows/commands; the
// lone config.json under templates/ ships too. (#691 review)
{ dir: path.join('gsd-core', 'templates'), exts: ['.md', '.json'] },
{ dir: path.join('gsd-core', 'contexts'), exts: ['.md'] },
{ dir: path.join('commands', 'gsd'), exts: ['.md'] },
{ dir: 'agents', exts: ['.md'] },
// Hooks ship as executable text (.js/.cjs/.sh). `hooks/dist/` is a gitignored
// build artifact regenerated from these sources, so scanning the sources is
// sufficient and avoids asserting against generated copies.
{ dir: 'hooks', exts: ['.js', '.cjs', '.sh'], skipDirs: ['dist'] },
];
// Matches every casing/separator variant of the retired SDK token:
// gsd-sdk, gsd_sdk, GSD-SDK, GSD_SDK, etc.
const SDK_REF = /gsd[-_]sdk/i;
/**
* Recursively collect files under `absDir` whose extension is in `exts`,
* skipping any directory name listed in `skipDirs`.
*/
function collectFiles(absDir, exts, skipDirs) {
if (!fs.existsSync(absDir)) return [];
const out = [];
for (const entry of fs.readdirSync(absDir, { withFileTypes: true })) {
if (entry.isDirectory()) {
if (skipDirs.includes(entry.name)) continue;
out.push(...collectFiles(path.join(absDir, entry.name), exts, skipDirs));
} else if (entry.isFile() && exts.includes(path.extname(entry.name))) {
out.push(path.join(absDir, entry.name));
}
}
return out;
}
function rel(file) {
return path.relative(REPO_ROOT, file).split(path.sep).join('/');
}
describe('#339 no gsd-sdk references in runtime surfaces', () => {
test('shipped prompts and hooks carry no retired gsd-sdk reference', () => {
const violations = [];
for (const { dir, exts, skipDirs = [] } of RUNTIME_SURFACES) {
const files = collectFiles(path.join(REPO_ROOT, dir), exts, skipDirs);
for (const file of files) {
const lines = fs.readFileSync(file, 'utf-8').split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
if (SDK_REF.test(lines[i])) {
violations.push(`${rel(file)}:${i + 1}: ${lines[i].trim()}`);
}
}
}
}
assert.strictEqual(
violations.length,
0,
'Runtime surfaces must not reference the retired gsd-sdk binary/package — ' +
'use gsd-tools instead.\nViolations:\n' + violations.join('\n')
);
});
test('at least one file per configured extension is scanned (guards against an empty sweep)', () => {
// A path typo, directory rename, or stale extension could silently make
// collectFiles() return [] for part of a surface, turning the guard above
// into a no-op that always passes. Checking per-surface isn't enough: for
// gsd-core/workflows the .md files alone keep a per-surface count > 0, so
// dropping .sh/.json would stop covering _runtime-launcher.snippet.sh and
// discuss-phase/templates/*.json while the test stayed green. Assert each
// configured extension actually resolves to scanned files. (#691 review)
for (const { dir, exts, skipDirs = [] } of RUNTIME_SURFACES) {
for (const ext of exts) {
const count = collectFiles(path.join(REPO_ROOT, dir), [ext], skipDirs).length;
assert.ok(
count > 0,
`Runtime surface "${dir}" resolved to 0 "${ext}" files — the path may ` +
'have moved or the extension is stale; update RUNTIME_SURFACES so the ' +
'guard keeps covering it.'
);
}
}
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/feat-3593-cli-negative-universal.test.cjs — consolidation epic #1969 (B8 #1977)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:feat-3593-cli-negative-universal (consolidation epic #1969 B8 #1977)", () => {
/**
* Universal CLI negative-matrix sweep across the seven command families
* named in #3593: phase, roadmap, state, config, workstream, init,
* validate.
*
* The full per-case matrix for each family belongs in dedicated files
* (the `config` family is the template — see
* `feat-3593-cli-negative-config.test.cjs`). This file pins a much
* narrower contract that EVERY family must satisfy:
*
* 1. Bare top-level command with no subcommand: must not crash with
* a V8 stack trace, must exit non-zero (or, where the bare form
* is legitimate, return a clean payload).
* 2. Unknown subcommand at command depth: must emit a typed reason
* under --json-errors and must not crash.
* 3. Shell-metacharacter as an argv element: must not be executed.
* Sentinel-file probe in the project temp dir proves this.
*
* Future per-family files will deepen each family's matrix; this file
* is the floor.
*/
'use strict';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { runCli } = require('./helpers/cli-negative.cjs');
const { createTempProject, createTempGitProject, cleanup } = require('./helpers.cjs');
/**
* Each entry names a top-level command, a representative subcommand
* (for the "unknown sub" probe), and the temp-project factory.
*
* The `representativeSubcommand` is not asserted on directly — it
* exists so the test layout reads "for each family, run probe X" and
* so a future maintainer adding a family doesn't need to invent one.
*/
const FAMILIES = [
{ name: 'phase', bare: ['phase'], unknown: ['phase', 'this-sub-does-not-exist'], projectFactory: createTempProject },
{ name: 'roadmap', bare: ['roadmap'], unknown: ['roadmap', 'this-sub-does-not-exist'], projectFactory: createTempProject },
{ name: 'state', bare: ['state'], unknown: ['state', 'this-sub-does-not-exist'], projectFactory: createTempProject },
{ name: 'config', bare: ['config'], unknown: ['config', 'this-sub-does-not-exist'], projectFactory: createTempProject },
{ name: 'workstream', bare: ['workstream'], unknown: ['workstream', 'this-sub-does-not-exist'], projectFactory: createTempGitProject },
{ name: 'init', bare: ['init'], unknown: ['init', 'this-sub-does-not-exist'], projectFactory: createTempProject },
{ name: 'validate', bare: ['validate'], unknown: ['validate', 'this-sub-does-not-exist'], projectFactory: createTempProject },
];
describe('feat-3593: bare top-level command does not crash', () => {
for (const fam of FAMILIES) {
test(`${fam.name}: bare invocation exits cleanly without a stack trace`, (t) => {
const projectDir = fam.projectFactory(`cli-neg-univ-bare-${fam.name}-`);
t.after(() => cleanup(projectDir));
const result = runCli(fam.bare, { cwd: projectDir });
assert.equal(result.hasStackTrace, false, `${fam.name} bare must not leak a V8 stack frame`);
assert.equal(result.signal, null, `${fam.name} bare must not be killed by a signal`);
// We do not pin exit code here: some families legitimately treat the
// bare form as a list/status command (status 0); others reject it as
// a usage error (status ≠ 0). What we pin is "no crash."
});
}
});
describe('feat-3593: unknown subcommand emits a typed reason', () => {
for (const fam of FAMILIES) {
test(`${fam.name}: unknown subcommand fails with reason set and no stack trace`, (t) => {
const projectDir = fam.projectFactory(`cli-neg-univ-unk-${fam.name}-`);
t.after(() => cleanup(projectDir));
const result = runCli(fam.unknown, { cwd: projectDir });
assert.notEqual(result.status, 0, `${fam.name} unknown sub must exit non-zero`);
assert.equal(result.hasStackTrace, false, `${fam.name} unknown sub must not leak a stack frame`);
// When --json-errors is on, every typed-failure path lands a non-empty
// reason string from ERROR_REASON. A null reason here means the family
// is using throw/console.error somewhere — a TDD signal to wire that
// failure path through error(msg, ERROR_REASON.X).
assert.equal(typeof result.reason, 'string', `${fam.name}: reason must be a typed enum string (got: ${result.reason})`);
assert.notEqual(result.reason, '', `${fam.name}: reason must be non-empty`);
});
}
});
describe('feat-3593: shell-metacharacter argv values are NOT executed', () => {
for (const fam of FAMILIES) {
test(`${fam.name}: shell-payload as subcommand argv does NOT execute the payload`, (t) => {
const projectDir = fam.projectFactory(`cli-neg-univ-shell-${fam.name}-`);
t.after(() => cleanup(projectDir));
// Place the payload where the subcommand value goes. The payload
// would, if shell-interpreted, create a sentinel file in the
// project dir. Argv-based invocation must treat it as opaque text.
const sentinelPayload = `$(touch ${projectDir}/INJ-${fam.name})`;
const argv = [fam.name, sentinelPayload];
const result = runCli(argv, { cwd: projectDir });
// No stack trace — opaque text must not crash.
assert.equal(result.hasStackTrace, false, `${fam.name}: shell payload must not crash`);
// The sentinel file must NOT exist. We check the project dir's
// listing rather than fs.existsSync of a single path so the test
// surfaces any spelling drift.
const entries = fs.readdirSync(projectDir);
const sentinels = entries.filter((n) => n.startsWith('INJ-'));
assert.deepEqual(
sentinels,
[],
`${fam.name}: shell payload was executed — sentinel files exist: ${sentinels.join(', ')}`,
);
});
}
});
// ─── Cross-family invariants on the global --cwd flag ──────────────────────
test('--cwd with an empty value fails the same way regardless of command family', () => {
for (const fam of FAMILIES) {
const result = runCli(['--cwd', '', ...fam.bare], { cwd: process.cwd() });
assert.notEqual(result.status, 0, `${fam.name}: empty --cwd must fail`);
assert.equal(result.hasStackTrace, false, `${fam.name}: empty --cwd must not crash`);
assert.equal(result.reason, 'usage', `${fam.name}: empty --cwd reason should be 'usage', got: ${result.reason}`);
}
});
test('--cwd pointing at a non-existent path fails uniformly across families', () => {
const nonExistent = path.join(require('os').tmpdir(), 'cli-neg-univ-no-such-' + Date.now() + '-' + Math.random());
assert.equal(fs.existsSync(nonExistent), false, 'pre-check: temp path must not exist');
for (const fam of FAMILIES) {
const result = runCli(['--cwd', nonExistent, ...fam.bare], { cwd: process.cwd() });
assert.notEqual(result.status, 0, `${fam.name}: invalid --cwd must fail`);
assert.equal(result.hasStackTrace, false);
assert.equal(result.reason, 'usage', `${fam.name}: invalid --cwd reason should be 'usage'`);
}
});
});
}