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.
316 lines
12 KiB
JavaScript
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 },
|
|
);
|
|
});
|
|
});
|