Files
msd-core/tests/precommit-alias-drift-hook.test.cjs
Jakub Zych a9a7a328e6 refactor: hard-fork GSD -> MSD (Make Software Done)
Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD
across contents and paths, upstream package/repo coordinates -> @golem15/msd-core
and golem15com/msd-core. Deep links into upstream history, sibling upstream
packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is.

Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line,
package/plugin identity, regenerated lockfile, install-tree fixtures, derived
registries and benchmark baseline; migration checksum baseline re-locked
(MSD keeps its own install state, so no install had applied the old sums);
sort-order and regex-escaped expectations in tests adjusted.
2026-10-06 01:47:40 +02:00

316 lines
12 KiB
JavaScript

// docs-guard-exempt: 'docs/adr/0174-...md' and 'docs/' are synthetic fixture path/prefix values, never read as content.
'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 { HOOK_FANOUT_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('msd-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' "$*" >> "$MSD_TEST_NPM_MARKER"\n`,
);
// This IS the fan-out class HOOK_FANOUT_TIMEOUT_MS documents: the
// pre-commit hook runs under `bash` and shells out to `git diff`, `tr`,
// `grep`, and conditionally `npm` (which itself runs node). Same class as
// the observed CI failures in tests/quick-branching.test.cjs (PR #3787 run
// 32668773524) and tests/worktree-safety.test.cjs (`next` run
// 32608945654). See HOOK_FANOUT_TIMEOUT_MS in ./helpers/timeouts.cjs for
// the class rationale.
const result = runHook(HOOK_PATH, [], {
interpreter: 'bash',
cwd: ROOT,
env: {
...process.env,
GIT_OVERRIDE: mockGit,
NPM_OVERRIDE: mockNpm,
MSD_TEST_NPM_MARKER: marker,
},
timeoutMs: HOOK_FANOUT_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('msd-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-msd-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 },
);
});
});