* test(#2725): failing-first coverage for the inert pre-commit alias guard Replaces two stale tests that asserted on `sdk/src/query/command-manifest.phase.ts` — a path retired with the SDK boundary (ADR-0174), so both passed forever while guarding nothing. The new matrix drives .githooks/pre-commit through its GIT_OVERRIDE/NPM_OVERRIDE seams and asserts on the real tracked sources the drift checker reads. Red until the guard is repointed off the gitignored build outputs it currently watches. * fix(#2725): repoint the pre-commit alias guard at sources git can actually stage `.githooks/pre-commit` carried ten staged-path guards and not one of them could do its job. Nine anchored on `^sdk/…`, a tree retired by ADR-0174, and invoked npm scripts that no longer exist (`check:state-document-fresh` and eight siblings). Their only reachable behavior was to abort the commit with `Missing script` — which required matching a path that cannot exist, so they were dead twice over. The tenth was the real defect. Its npm target does exist, but it matched `^gsd-core/bin/lib/command-aliases\.cjs$` — a gitignored build output (.gitignore:172). An ignored path never appears in `git diff --cached --name-only`, so the guard was not stale, it was structurally unmatchable: it watched the derived layer instead of the source layer, from the day it was written. Repointed at the nine tracked `src/*.cts` sources `scripts/check-alias-drift.cjs` actually reads. The family table moves to `scripts/lib/alias-drift-families.cjs` so the checker and the hook derive their surface from one list, and a new parity test fails if a family is added to the checker without the hook learning to watch its source — the rot mechanism, not just this instance of it. Matching is now `grep -Fxqf` (fixed strings, whole line): exact path equality, no regex anchors to get wrong as the list grows. Staged paths are collected into a variable before matching, because `grep -q` exits on first match and would SIGPIPE its upstream, which under `set -o pipefail` turns a successful match into a non-zero pipeline status. The two CONTRIBUTING.md recipes pasted copies of the hook bodies inline — a third parallel surface, and one that had already drifted: the pre-commit copy carried the same dead `sdk/` patterns, and the pre-push copy would have overwritten the committed hook with a paraphrase that drops the GIT_OVERRIDE seam its test drives. Both now just point git at the committed files. The pre-push recipe's `'@example-corp\\.com$'` also never matched anything — inside single quotes bash keeps both backslashes. `.githooks/pre-push` was audited for the same rot and has none: it keys on no paths, no-ops unless GSD_BLOCKED_AUTHOR_REGEX is set, and is covered. Unchanged. Hooks stay opt-in. Nothing registers `core.hooksPath` for you, per CONTRIBUTING.md's documented one-time setup. * test(#2725): make the hook/checker parity assertion bidirectional Review finding from the isolated adversarial pass: the parity row only caught the hook UNDER-watching relative to scripts/lib/alias-drift-families.cjs. Drop a family from the module and the hook would keep watching its source with nothing to notice — the same divergence class this change exists to close, just pointed the other way. The new row takes every `src/*-command-router.cts` on disk as the universe and asserts the hook stays silent for the eight routers the drift check does not read. Both directions are now covered by running the real hook, not by comparing two lists in the test. Also corrects a CONTRIBUTING.md overclaim caught by the standards pass: 9 of the 11 watched paths derive from the module, not all 11 — bash cannot require a CJS module, so the hook carries literals and the test is what binds them. * fix(#2725): ship the shared family table and fix the mock that hid its own rows Three defects the remote runner caught that local probing did not. The mock `git` in the regression test emitted its staged-path payload as `printf '%s' "src/command-aliases.cts\n"`. Bash does not expand `\n` inside a double-quoted string and printf does not expand escapes in a `%s` argument, so the mock produced one unterminated line containing a literal backslash-n. No whole-line match could ever succeed, and every row that expects the hook to FIRE failed while every row that expects silence passed — which is exactly the signature the run reported: 9 failures, all of them fire-expecting rows. The payload now goes through a file the mock `cat`s, which is byte-exact and is what makes the CR-terminated and empty-staged-list rows mean what they say. `scripts/lib/alias-drift-families.cjs` was not enumerated in `GSD_SCRIPTS_LIB_FILES` (bin/install.js:377), so the installer never copied it. That is not cosmetic: `scripts/check-alias-drift.cjs` ships, and it now requires this module — an installed tree would have failed with MODULE_NOT_FOUND the first time a consumer ran `check:alias-drift`. Added to the manifest, which is what the #3184 install/uninstall parity tests assert against `readdirSync`. Regenerated the 19 committed install-tree fixtures via `npm run gen:install-tree` to record the new emitted path. The diff is +1 line per fixture and nothing else. `npm run lint:ci` exits 0. The earlier claim that `scripts/` is outside the emitted surface was wrong: `scripts/lib/` is copied into every runtime's install tree, which is why 19 golden-install-tree cases moved. * chore(#2725): backfill changeset pr: 3273 --------- Co-authored-by: sim <sim@local>
308 lines
11 KiB
JavaScript
308 lines
11 KiB
JavaScript
'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 fc = require('fast-check');
|
|
const { runHook } = require('./helpers/process-seam.cjs');
|
|
const { throwIfFailed } = require('./helpers/git-fixture.cjs');
|
|
const { createTempDir, cleanup } = require('./helpers.cjs');
|
|
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
|
const { stagedSourcePaths } = require('../scripts/lib/alias-drift-families.cjs');
|
|
|
|
const ROOT = path.resolve(__dirname, '..');
|
|
const HOOK_PATH = path.join(ROOT, '.githooks', 'pre-commit');
|
|
|
|
/**
|
|
* Derived, never restated: the sources `scripts/check-alias-drift.cjs` actually
|
|
* reads. The hook's watched list and the checker's family table are parallel
|
|
* surfaces over one constant set, and their silent divergence is exactly what
|
|
* made the guard inert (#2725) — so this test asserts the hook against the
|
|
* checker's own table rather than against a second copy of it.
|
|
*/
|
|
const DRIFT_SOURCES = stagedSourcePaths();
|
|
|
|
/** The two script paths the hook additionally watches, beyond the sources above. */
|
|
const WATCHED_SCRIPTS = [
|
|
'scripts/check-alias-drift.cjs',
|
|
'scripts/lib/alias-drift-families.cjs',
|
|
];
|
|
|
|
const WATCHED_PATHS = new Set([...DRIFT_SOURCES, ...WATCHED_SCRIPTS]);
|
|
|
|
/**
|
|
* Write a mock bash script to a .sh file in tmpDir and return its absolute path.
|
|
* The hook invokes it via GIT_OVERRIDE / NPM_OVERRIDE — bash executes the path
|
|
* directly, so no PATH manipulation or NTFS execute-ACL fight is needed.
|
|
*/
|
|
function writeMock(tmpDir, name, content) {
|
|
const filePath = path.join(tmpDir, `${name}.sh`);
|
|
fs.writeFileSync(filePath, content, { mode: 0o755 });
|
|
return filePath;
|
|
}
|
|
|
|
/**
|
|
* Run the hook with a mock `git diff --cached --name-only` that emits
|
|
* `stagedLines` verbatim, and a mock `npm` that appends one line to a marker
|
|
* file per invocation.
|
|
*
|
|
* `stagedLines` is a raw string, not an array, so a row can exercise the exact
|
|
* byte shape git produces — including a CR-terminated line. Every other row
|
|
* builds it from LF-joined paths, which is what git actually emits.
|
|
*
|
|
* Returns `{ result, npmCalls }`. Counting invocations (rather than testing for
|
|
* file existence) is what lets the many-staged-files row assert the check runs
|
|
* exactly once.
|
|
*/
|
|
function runPreCommit(t, stagedLines) {
|
|
const tmpDir = createTempDir('gsd-precommit-hook-');
|
|
t.after(() => cleanup(tmpDir));
|
|
|
|
const marker = path.join(tmpDir, 'npm-calls.txt');
|
|
|
|
// The payload goes through a FILE that the mock `cat`s, never through printf.
|
|
// `printf '%s' "…\n"` emits a literal backslash-n — bash does not expand escapes
|
|
// in a double-quoted string and printf does not expand them in a %s argument — so
|
|
// the hook saw one unterminated line and no whole-line match could ever succeed.
|
|
// `cat` reproduces bytes verbatim, which is also what lets the CR-terminated row
|
|
// and the empty-staged-list row mean what they say.
|
|
const stagedFile = path.join(tmpDir, 'staged-paths.txt');
|
|
fs.writeFileSync(stagedFile, stagedLines);
|
|
const mockGit = writeMock(
|
|
tmpDir,
|
|
'git',
|
|
`#!/usr/bin/env bash\ncat ${JSON.stringify(stagedFile)}\n`,
|
|
);
|
|
const mockNpm = writeMock(
|
|
tmpDir,
|
|
'npm',
|
|
`#!/usr/bin/env bash\nprintf 'call %s\\n' "$*" >> "$GSD_TEST_NPM_MARKER"\n`,
|
|
);
|
|
|
|
const result = runHook(HOOK_PATH, [], {
|
|
interpreter: 'bash',
|
|
cwd: ROOT,
|
|
env: {
|
|
...process.env,
|
|
GIT_OVERRIDE: mockGit,
|
|
NPM_OVERRIDE: mockNpm,
|
|
GSD_TEST_NPM_MARKER: marker,
|
|
},
|
|
timeoutMs: PROBE_TIMEOUT_MS,
|
|
});
|
|
|
|
const npmCalls = fs.existsSync(marker)
|
|
? fs.readFileSync(marker, 'utf8').split(/\r?\n/).filter(Boolean).length
|
|
: 0;
|
|
|
|
return { result, npmCalls };
|
|
}
|
|
|
|
/** Join paths the way `git diff --cached --name-only` emits them. */
|
|
function staged(...paths) {
|
|
return paths.length === 0 ? '' : `${paths.join('\n')}\n`;
|
|
}
|
|
|
|
describe('.githooks/pre-commit alias drift guard', () => {
|
|
test('runs the drift check when the alias table source is staged', (t) => {
|
|
const { result, npmCalls } = runPreCommit(t, staged('src/command-aliases.cts'));
|
|
|
|
throwIfFailed(result, `bash ${HOOK_PATH}`);
|
|
assert.equal(
|
|
npmCalls,
|
|
1,
|
|
'staging the tracked alias source must invoke check:alias-drift — ' +
|
|
'the guard previously watched gitignored build output, which git never stages (#2725)',
|
|
);
|
|
});
|
|
|
|
test('fires when the drift checker itself is staged', (t) => {
|
|
const { result, npmCalls } = runPreCommit(t, staged('scripts/check-alias-drift.cjs'));
|
|
|
|
throwIfFailed(result, `bash ${HOOK_PATH}`);
|
|
assert.equal(npmCalls, 1, 'editing the checker must re-run it');
|
|
});
|
|
|
|
test('fires when the shared family table is staged', (t) => {
|
|
const { result, npmCalls } = runPreCommit(
|
|
t,
|
|
staged('scripts/lib/alias-drift-families.cjs'),
|
|
);
|
|
|
|
throwIfFailed(result, `bash ${HOOK_PATH}`);
|
|
assert.equal(npmCalls, 1, 'editing the family table must re-run the check');
|
|
});
|
|
|
|
test('fires for every source the drift check actually reads', (t) => {
|
|
assert.ok(DRIFT_SOURCES.length > 0, 'the drift surface must not be empty');
|
|
|
|
for (const source of DRIFT_SOURCES) {
|
|
const { npmCalls } = runPreCommit(t, staged(source));
|
|
|
|
assert.equal(
|
|
npmCalls,
|
|
1,
|
|
`${source} is in the drift checker's family table but .githooks/pre-commit ` +
|
|
'does not watch it — the two surfaces have diverged again (#2725)',
|
|
);
|
|
}
|
|
});
|
|
|
|
test('does not watch routers the drift check dropped', (t) => {
|
|
// The reverse direction of the parity row above. That row catches the hook
|
|
// UNDER-watching; this one catches it OVER-watching, using every
|
|
// `src/*-command-router.cts` on disk as the universe. Drop a family from
|
|
// scripts/lib/alias-drift-families.cjs without narrowing the hook and this
|
|
// fails — which is the half of the divergence #2725 would otherwise leave open.
|
|
const allRouters = fs
|
|
.readdirSync(path.join(ROOT, 'src'))
|
|
.filter((name) => name.endsWith('-command-router.cts'))
|
|
.map((name) => `src/${name}`);
|
|
|
|
const unwatched = allRouters.filter((routerPath) => !WATCHED_PATHS.has(routerPath));
|
|
|
|
assert.ok(
|
|
allRouters.length > DRIFT_SOURCES.length - 1,
|
|
'expected more routers on disk than the drift check reads — otherwise this row proves nothing',
|
|
);
|
|
assert.ok(unwatched.length > 0, 'expected at least one router outside the drift surface');
|
|
|
|
for (const routerPath of unwatched) {
|
|
const { npmCalls } = runPreCommit(t, staged(routerPath));
|
|
|
|
assert.equal(
|
|
npmCalls,
|
|
0,
|
|
`${routerPath} is not in the drift checker's family table, but .githooks/pre-commit ` +
|
|
'still watches it — the hook over-watches relative to the module (#2725)',
|
|
);
|
|
}
|
|
});
|
|
|
|
test('does not run the drift check for unrelated staged files', (t) => {
|
|
const { result, npmCalls } = runPreCommit(t, staged('README.md'));
|
|
|
|
throwIfFailed(result, `bash ${HOOK_PATH}`);
|
|
assert.equal(npmCalls, 0, 'expected npm check to be skipped for unrelated staged files');
|
|
});
|
|
|
|
test('does not fire for a src file outside the drift surface', (t) => {
|
|
const outsider = 'src/milestone.cts';
|
|
// Guards the row against going vacuous if milestone ever joins the family.
|
|
assert.ok(
|
|
!WATCHED_PATHS.has(outsider),
|
|
`${outsider} joined the drift surface — pick a different outsider for this row`,
|
|
);
|
|
|
|
const { result, npmCalls } = runPreCommit(t, staged(outsider));
|
|
|
|
throwIfFailed(result, `bash ${HOOK_PATH}`);
|
|
assert.equal(
|
|
npmCalls,
|
|
0,
|
|
'a guard that fires on every src/ edit trains contributors to bypass it',
|
|
);
|
|
});
|
|
|
|
test('does not invoke a missing npm script for retired sdk paths', (t) => {
|
|
const { result, npmCalls } = runPreCommit(t, staged('sdk/src/query/state-document.ts'));
|
|
|
|
throwIfFailed(result, `bash ${HOOK_PATH}`);
|
|
assert.equal(
|
|
npmCalls,
|
|
0,
|
|
'the retired sdk/ guards called npm scripts that do not exist, aborting the commit (#2725)',
|
|
);
|
|
});
|
|
|
|
test('does not watch the gitignored build output', (t) => {
|
|
const { result, npmCalls } = runPreCommit(
|
|
t,
|
|
staged('gsd-core/bin/lib/command-aliases.cjs'),
|
|
);
|
|
|
|
throwIfFailed(result, `bash ${HOOK_PATH}`);
|
|
assert.equal(
|
|
npmCalls,
|
|
0,
|
|
'build outputs are gitignored and can never be staged — watching them is what made the guard inert',
|
|
);
|
|
});
|
|
|
|
test('exits clean when nothing is staged', (t) => {
|
|
const { result, npmCalls } = runPreCommit(t, staged());
|
|
|
|
throwIfFailed(result, `bash ${HOOK_PATH}`);
|
|
assert.equal(npmCalls, 0);
|
|
});
|
|
|
|
test('runs the check once when one of many staged files matches', (t) => {
|
|
const { result, npmCalls } = runPreCommit(
|
|
t,
|
|
staged(
|
|
'README.md',
|
|
'docs/adr/0174-retire-gsd-sdk-package-boundary.md',
|
|
'src/command-aliases.cts',
|
|
'tests/helpers.cjs',
|
|
),
|
|
);
|
|
|
|
throwIfFailed(result, `bash ${HOOK_PATH}`);
|
|
assert.equal(npmCalls, 1, 'expected exactly one check:alias-drift invocation');
|
|
});
|
|
|
|
test('matches a CR-terminated staged path', (t) => {
|
|
const { result, npmCalls } = runPreCommit(t, 'src/command-aliases.cts\r\n');
|
|
|
|
throwIfFailed(result, `bash ${HOOK_PATH}`);
|
|
assert.equal(
|
|
npmCalls,
|
|
1,
|
|
'a $-anchored grep fails on a CR-terminated line — the hook must strip \\r first',
|
|
);
|
|
});
|
|
|
|
describe('anchors the pattern exactly (limit-1 / limit / limit+1)', () => {
|
|
const cases = [
|
|
['src/command-aliases.ct', 0, 'limit-1: one character short of the extension'],
|
|
['src/command-aliases.cts', 1, 'limit: the exact tracked source'],
|
|
['src/command-aliases.ctsx', 0, 'limit+1: one character past the extension'],
|
|
['src/command-aliases.cts.bak', 0, 'suffix past the end anchor'],
|
|
['notsrc/command-aliases.cts', 0, 'prefix before the start anchor'],
|
|
];
|
|
|
|
for (const [stagedPath, expectedCalls, label] of cases) {
|
|
test(label, (t) => {
|
|
const { result, npmCalls } = runPreCommit(t, staged(stagedPath));
|
|
|
|
throwIfFailed(result, `bash ${HOOK_PATH}`);
|
|
assert.equal(npmCalls, expectedCalls, `${stagedPath} -> ${expectedCalls} npm call(s)`);
|
|
});
|
|
}
|
|
});
|
|
|
|
test('property: never fires for a path outside the watched set', (t) => {
|
|
// Biased toward near-misses (src/-prefixed, .cts-suffixed) because that is
|
|
// where over-matching actually hides. numRuns is held low: every sample
|
|
// spawns bash.
|
|
const nearMiss = fc
|
|
.tuple(
|
|
fc.constantFrom('src/', 'srcx/', 'notsrc/', 'docs/', 'tests/', ''),
|
|
fc.stringMatching(/^[a-z][a-z0-9-]{0,20}$/),
|
|
fc.constantFrom('.cts', '.cjs', '.ts', '.md', '.cts.bak', ''),
|
|
)
|
|
.map(([dir, stem, ext]) => `${dir}${stem}${ext}`)
|
|
.filter((p) => !WATCHED_PATHS.has(p));
|
|
|
|
fc.assert(
|
|
fc.property(nearMiss, (stagedPath) => {
|
|
const { result, npmCalls } = runPreCommit(t, staged(stagedPath));
|
|
throwIfFailed(result, `bash ${HOOK_PATH}`);
|
|
return npmCalls === 0;
|
|
}),
|
|
{ numRuns: 20 },
|
|
);
|
|
});
|
|
});
|