fix(#2725): repoint the pre-commit alias guard at sources git can actually stage, drop nine dead ones (#3273)
* 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>
This commit is contained in:
5
.changeset/sturdy-finches-caper.md
Normal file
5
.changeset/sturdy-finches-caper.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 3273
|
||||
---
|
||||
**The optional pre-commit hook now actually checks command-alias drift** — every guard in `.githooks/pre-commit` was inert: nine matched paths under the retired `sdk/` tree and invoked npm scripts that no longer exist, and the tenth watched gitignored build outputs that git can never stage. Staging `src/command-aliases.cts` now runs `check:alias-drift` instead of passing silently. (#2725)
|
||||
@@ -6,42 +6,33 @@ set -euo pipefail
|
||||
GIT_CMD="${GIT_OVERRIDE:-git}"
|
||||
NPM_CMD="${NPM_OVERRIDE:-npm}"
|
||||
|
||||
if "$GIT_CMD" diff --cached --name-only | grep -Eq "^sdk/src/query/command-manifest\.|^sdk/src/query/command-aliases\.generated\.ts$|^gsd-core/bin/lib/command-aliases\.cjs$|^sdk/scripts/gen-command-aliases\.ts$"; then
|
||||
# Tracked sources of the command-alias surface. These are the `src/*.cts` files,
|
||||
# never the `gsd-core/bin/lib/*.cjs` artifacts they compile to: those are
|
||||
# gitignored, so `git diff --cached --name-only` never lists them and a guard
|
||||
# keyed on them can never fire (#2725). Kept in sync with
|
||||
# scripts/lib/alias-drift-families.cjs by tests/precommit-alias-drift-hook.test.cjs.
|
||||
alias_drift_paths=(
|
||||
'src/command-aliases.cts'
|
||||
'src/state-command-router.cts'
|
||||
'src/verify-command-router.cts'
|
||||
'src/init-command-router.cts'
|
||||
'src/phase-command-router.cts'
|
||||
'src/phases-command-router.cts'
|
||||
'src/validate-command-router.cts'
|
||||
'src/roadmap-command-router.cts'
|
||||
'src/eval-command-router.cts'
|
||||
'scripts/check-alias-drift.cjs'
|
||||
'scripts/lib/alias-drift-families.cjs'
|
||||
)
|
||||
|
||||
# `tr -d '\r'` first: a whole-line match fails on a CR-terminated path.
|
||||
# Collected into a variable rather than piped straight into grep, 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.
|
||||
staged_paths="$("$GIT_CMD" diff --cached --name-only | tr -d '\r')"
|
||||
|
||||
# -F -x: fixed strings, whole line. Exact path equality, no regex anchoring to
|
||||
# get wrong, and no escaping burden as the list grows.
|
||||
if grep -Fxqf <(printf '%s\n' "${alias_drift_paths[@]}") <<< "$staged_paths"; then
|
||||
"$NPM_CMD" run check:alias-drift
|
||||
fi
|
||||
|
||||
if "$GIT_CMD" diff --cached --name-only | grep -Eq "^sdk/src/query/state-document\.|^gsd-core/bin/lib/state-document\.generated\.cjs$|^sdk/scripts/gen-state-document\.ts$|^sdk/scripts/check-state-document-fresh\.mjs$"; then
|
||||
"$NPM_CMD" run check:state-document-fresh
|
||||
fi
|
||||
|
||||
if "$GIT_CMD" diff --cached --name-only | grep -Eq "^sdk/src/configuration/|^sdk/shared/config-(defaults|schema)\.manifest\.json$|^gsd-core/bin/lib/configuration\.generated\.cjs$|^sdk/scripts/gen-configuration\.mjs$"; then
|
||||
"$NPM_CMD" run check:configuration-fresh
|
||||
fi
|
||||
|
||||
if "$GIT_CMD" diff --cached --name-only | grep -Eq "^sdk/src/workstream-inventory/|^gsd-core/bin/lib/workstream-inventory-builder\.generated\.cjs$|^sdk/scripts/gen-workstream-inventory-builder\.mjs$|^sdk/scripts/check-workstream-inventory-builder-fresh\.mjs$"; then
|
||||
"$NPM_CMD" run check:workstream-inventory-builder-fresh
|
||||
fi
|
||||
|
||||
if "$GIT_CMD" diff --cached --name-only | grep -Eq "^sdk/src/project-root/|^gsd-core/bin/lib/project-root\.generated\.cjs$|^sdk/scripts/gen-project-root\.mjs$|^sdk/scripts/check-project-root-fresh\.mjs$"; then
|
||||
"$NPM_CMD" run check:project-root-fresh
|
||||
fi
|
||||
|
||||
if "$GIT_CMD" diff --cached --name-only | grep -Eq "^sdk/src/query/plan-scan\.ts$|^gsd-core/bin/lib/plan-scan\.generated\.cjs$|^sdk/scripts/gen-plan-scan\.mjs$|^sdk/scripts/check-plan-scan-fresh\.mjs$"; then
|
||||
"$NPM_CMD" run check:plan-scan-fresh
|
||||
fi
|
||||
|
||||
if "$GIT_CMD" diff --cached --name-only | grep -Eq "^sdk/src/query/secrets\.ts$|^gsd-core/bin/lib/secrets\.generated\.cjs$|^sdk/scripts/gen-secrets\.mjs$|^sdk/scripts/check-secrets-fresh\.mjs$"; then
|
||||
"$NPM_CMD" run check:secrets-fresh
|
||||
fi
|
||||
|
||||
if "$GIT_CMD" diff --cached --name-only | grep -Eq "^sdk/src/query/schema-detect\.ts$|^gsd-core/bin/lib/schema-detect\.generated\.cjs$|^sdk/scripts/gen-schema-detect\.mjs$|^sdk/scripts/check-schema-detect-fresh\.mjs$"; then
|
||||
"$NPM_CMD" run check:schema-detect-fresh
|
||||
fi
|
||||
|
||||
if "$GIT_CMD" diff --cached --name-only | grep -Eq "^sdk/src/query/decisions\.ts$|^gsd-core/bin/lib/decisions\.generated\.cjs$|^sdk/scripts/gen-decisions\.mjs$|^sdk/scripts/check-decisions-fresh\.mjs$"; then
|
||||
"$NPM_CMD" run check:decisions-fresh
|
||||
fi
|
||||
|
||||
if "$GIT_CMD" diff --cached --name-only | grep -Eq "^sdk/src/workstream-name-policy\.ts$|^gsd-core/bin/lib/workstream-name-policy\.generated\.cjs$|^sdk/scripts/gen-workstream-name-policy\.mjs$|^sdk/scripts/check-workstream-name-policy-fresh\.mjs$"; then
|
||||
"$NPM_CMD" run check:workstream-name-policy-fresh
|
||||
fi
|
||||
|
||||
@@ -927,13 +927,18 @@ This gives maintainers a faster, higher-confidence signal than CI-only validatio
|
||||
|
||||
### Pre-PR Seam Checks (Manifest/Alias Routing)
|
||||
|
||||
If you touched any of the command-manifest or generated alias files, run:
|
||||
If you touched `src/command-aliases.cts` or any of the eight `src/*-command-router.cts`
|
||||
sources it feeds, run:
|
||||
|
||||
```bash
|
||||
npm run check:alias-drift
|
||||
```
|
||||
|
||||
This verifies generated alias artifacts are in sync with manifest source-of-truth.
|
||||
This verifies the built alias artifacts under `gsd-core/bin/lib/` agree with their
|
||||
source of truth — each family's `*_SUBCOMMANDS` list must match the `subcommand`
|
||||
values derived from its `*_COMMAND_ALIASES` table, in order, and each router must
|
||||
reference its own list. The surface is enumerated once in
|
||||
`scripts/lib/alias-drift-families.cjs`.
|
||||
|
||||
### Editing shipped content (gsd-core/workflows, references, templates, contexts, agents/, commands/gsd/)
|
||||
|
||||
@@ -1002,61 +1007,43 @@ npm run regen:derived
|
||||
|
||||
Optional local pre-commit hook entry (Git-native):
|
||||
|
||||
`.githooks/pre-commit` is **committed** — you do not write it, you only point git at
|
||||
it. It runs `check:alias-drift` when you stage one of the tracked sources that check
|
||||
reads, and stays silent otherwise.
|
||||
|
||||
```bash
|
||||
# one-time setup
|
||||
mkdir -p .githooks
|
||||
cat > .githooks/pre-commit <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if git diff --cached --name-only | grep -Eq "^sdk/src/query/command-manifest\.|^sdk/src/query/command-aliases\.generated\.ts$|^gsd-core/bin/lib/command-aliases\.generated\.cjs$|^sdk/scripts/gen-command-aliases\.ts$"; then
|
||||
npm run check:alias-drift
|
||||
fi
|
||||
EOF
|
||||
chmod +x .githooks/pre-commit
|
||||
git config core.hooksPath .githooks
|
||||
```
|
||||
|
||||
This is opt-in and stays that way: nothing in `npm install` sets `core.hooksPath` for
|
||||
you, so a fresh clone acquires no hooks. To stop using them, `git config --unset
|
||||
core.hooksPath`.
|
||||
|
||||
Do not paste a copy of the hook body into your own `.githooks/pre-commit`. Bash cannot
|
||||
`require()` a CommonJS module, so the hook does carry the watched paths as literals —
|
||||
but `tests/precommit-alias-drift-hook.test.cjs` runs the real hook against every source
|
||||
derived from `scripts/lib/alias-drift-families.cjs` and fails in **both** directions: if
|
||||
the hook stops watching a source the checker reads, and if it keeps watching a router the
|
||||
checker dropped. A copy in your own tree has no such test behind it, and a hand-maintained
|
||||
copy is exactly what silently rotted the previous version of this recipe (#2725) — every
|
||||
path in it named the retired `sdk/` tree or a gitignored build output, so the guard
|
||||
matched nothing for months.
|
||||
|
||||
Optional local pre-push hook to block a private author-email pattern:
|
||||
|
||||
`.githooks/pre-push` is committed too, and is covered by the same
|
||||
`core.hooksPath` opt-in above. It is a no-op until you set the regex, so enabling
|
||||
hooks does not enable this check:
|
||||
|
||||
```bash
|
||||
# set locally in your shell profile (example)
|
||||
export GSD_BLOCKED_AUTHOR_REGEX='@example-corp\\.com$'
|
||||
|
||||
cat > .githooks/pre-push <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
zero_sha='0000000000000000000000000000000000000000'
|
||||
blocked_regex="${GSD_BLOCKED_AUTHOR_REGEX:-}"
|
||||
[[ -z "$blocked_regex" ]] && exit 0
|
||||
violations=()
|
||||
|
||||
while read -r local_ref local_sha remote_ref remote_sha; do
|
||||
[[ "$local_sha" == "$zero_sha" ]] && continue
|
||||
if [[ "$remote_sha" == "$zero_sha" ]]; then
|
||||
commits=$(git rev-list "$local_sha" --not --remotes)
|
||||
else
|
||||
commits=$(git rev-list "$remote_sha..$local_sha")
|
||||
fi
|
||||
while read -r commit; do
|
||||
[[ -z "$commit" ]] && continue
|
||||
email=$(git show -s --format='%ae' "$commit" | tr '[:upper:]' '[:lower:]')
|
||||
if printf '%s' "$email" | grep -Eq "$blocked_regex"; then
|
||||
violations+=("$commit <$email>")
|
||||
fi
|
||||
done <<< "$commits"
|
||||
done
|
||||
|
||||
if [[ ${#violations[@]} -gt 0 ]]; then
|
||||
echo "Push blocked: commit author email matched local blocked regex ($blocked_regex)." >&2
|
||||
printf ' - %s\n' "${violations[@]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
EOF
|
||||
chmod +x .githooks/pre-push
|
||||
export GSD_BLOCKED_AUTHOR_REGEX='@example-corp\.com$'
|
||||
```
|
||||
|
||||
With that exported, a push carrying a commit whose author email matches is blocked,
|
||||
and the hook names the offending commits. Unset the variable to disable it.
|
||||
|
||||
### CI Test Quality Checks
|
||||
|
||||
The following checks run on every PR in addition to the test suite:
|
||||
|
||||
@@ -374,7 +374,7 @@ const GSD_CHANGESET_FILES = [
|
||||
'github-release-notes.cjs', 'lint.cjs', 'new.cjs',
|
||||
'README.md', // documentation only — not user-authored
|
||||
];
|
||||
const GSD_SCRIPTS_LIB_FILES = ['cli-exit.cjs', 'allowlist-ratchet.cjs', 'drift-scan.cjs'];
|
||||
const GSD_SCRIPTS_LIB_FILES = ['cli-exit.cjs', 'allowlist-ratchet.cjs', 'drift-scan.cjs', 'alias-drift-families.cjs'];
|
||||
|
||||
/**
|
||||
* Resolve a runtime's shared-hooks directory name from its descriptor.
|
||||
|
||||
@@ -4,9 +4,10 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
|
||||
const { FAMILIES, ALIAS_TABLE } = require('./lib/alias-drift-families.cjs');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const aliasesPath = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'command-aliases.cjs');
|
||||
const aliasesPath = path.join(ROOT, 'gsd-core', 'bin', 'lib', `${ALIAS_TABLE}.cjs`);
|
||||
|
||||
function fail(message) {
|
||||
process.stderr.write(`${message}\n`);
|
||||
@@ -36,48 +37,11 @@ function main() {
|
||||
|
||||
const aliases = require(aliasesPath);
|
||||
|
||||
const families = [
|
||||
{
|
||||
commandAliases: 'STATE_COMMAND_ALIASES',
|
||||
subcommands: 'STATE_SUBCOMMANDS',
|
||||
routerPath: path.join(ROOT, 'gsd-core', 'bin', 'lib', 'state-command-router.cjs'),
|
||||
},
|
||||
{
|
||||
commandAliases: 'VERIFY_COMMAND_ALIASES',
|
||||
subcommands: 'VERIFY_SUBCOMMANDS',
|
||||
routerPath: path.join(ROOT, 'gsd-core', 'bin', 'lib', 'verify-command-router.cjs'),
|
||||
},
|
||||
{
|
||||
commandAliases: 'INIT_COMMAND_ALIASES',
|
||||
subcommands: 'INIT_SUBCOMMANDS',
|
||||
routerPath: path.join(ROOT, 'gsd-core', 'bin', 'lib', 'init-command-router.cjs'),
|
||||
},
|
||||
{
|
||||
commandAliases: 'PHASE_COMMAND_ALIASES',
|
||||
subcommands: 'PHASE_SUBCOMMANDS',
|
||||
routerPath: path.join(ROOT, 'gsd-core', 'bin', 'lib', 'phase-command-router.cjs'),
|
||||
},
|
||||
{
|
||||
commandAliases: 'PHASES_COMMAND_ALIASES',
|
||||
subcommands: 'PHASES_SUBCOMMANDS',
|
||||
routerPath: path.join(ROOT, 'gsd-core', 'bin', 'lib', 'phases-command-router.cjs'),
|
||||
},
|
||||
{
|
||||
commandAliases: 'VALIDATE_COMMAND_ALIASES',
|
||||
subcommands: 'VALIDATE_SUBCOMMANDS',
|
||||
routerPath: path.join(ROOT, 'gsd-core', 'bin', 'lib', 'validate-command-router.cjs'),
|
||||
},
|
||||
{
|
||||
commandAliases: 'ROADMAP_COMMAND_ALIASES',
|
||||
subcommands: 'ROADMAP_SUBCOMMANDS',
|
||||
routerPath: path.join(ROOT, 'gsd-core', 'bin', 'lib', 'roadmap-command-router.cjs'),
|
||||
},
|
||||
{
|
||||
commandAliases: 'EVAL_COMMAND_ALIASES',
|
||||
subcommands: 'EVAL_SUBCOMMANDS',
|
||||
routerPath: path.join(ROOT, 'gsd-core', 'bin', 'lib', 'eval-command-router.cjs'),
|
||||
},
|
||||
];
|
||||
const families = FAMILIES.map((family) => ({
|
||||
commandAliases: family.commandAliases,
|
||||
subcommands: family.subcommands,
|
||||
routerPath: path.join(ROOT, 'gsd-core', 'bin', 'lib', `${family.router}.cjs`),
|
||||
}));
|
||||
|
||||
for (const family of families) {
|
||||
const commandAliases = aliases[family.commandAliases];
|
||||
|
||||
46
scripts/lib/alias-drift-families.cjs
Normal file
46
scripts/lib/alias-drift-families.cjs
Normal file
@@ -0,0 +1,46 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Single source of truth for the command-alias drift surface.
|
||||
*
|
||||
* Two parallel surfaces consume this: `scripts/check-alias-drift.cjs`, which
|
||||
* validates the built alias artifacts, and `.githooks/pre-commit`, which decides
|
||||
* when to run that validation. Until #2725 the hook's watched-path list had
|
||||
* drifted from the checker's family table until every pattern named either the
|
||||
* retired `sdk/` tree (ADR-0174) or a gitignored build output, so the guard was
|
||||
* silently inert. Both surfaces derive from this list now, and
|
||||
* `tests/precommit-alias-drift-hook.test.cjs` fails if they diverge again.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Alias families the drift check validates. `router` is the module basename,
|
||||
* shared by the `src/<router>.cts` source and the `gsd-core/bin/lib/<router>.cjs`
|
||||
* artifact it compiles to.
|
||||
*/
|
||||
const FAMILIES = Object.freeze([
|
||||
{ commandAliases: 'STATE_COMMAND_ALIASES', subcommands: 'STATE_SUBCOMMANDS', router: 'state-command-router' },
|
||||
{ commandAliases: 'VERIFY_COMMAND_ALIASES', subcommands: 'VERIFY_SUBCOMMANDS', router: 'verify-command-router' },
|
||||
{ commandAliases: 'INIT_COMMAND_ALIASES', subcommands: 'INIT_SUBCOMMANDS', router: 'init-command-router' },
|
||||
{ commandAliases: 'PHASE_COMMAND_ALIASES', subcommands: 'PHASE_SUBCOMMANDS', router: 'phase-command-router' },
|
||||
{ commandAliases: 'PHASES_COMMAND_ALIASES', subcommands: 'PHASES_SUBCOMMANDS', router: 'phases-command-router' },
|
||||
{ commandAliases: 'VALIDATE_COMMAND_ALIASES', subcommands: 'VALIDATE_SUBCOMMANDS', router: 'validate-command-router' },
|
||||
{ commandAliases: 'ROADMAP_COMMAND_ALIASES', subcommands: 'ROADMAP_SUBCOMMANDS', router: 'roadmap-command-router' },
|
||||
{ commandAliases: 'EVAL_COMMAND_ALIASES', subcommands: 'EVAL_SUBCOMMANDS', router: 'eval-command-router' },
|
||||
].map(Object.freeze));
|
||||
|
||||
/** Basename of the generated alias table every family is checked against. */
|
||||
const ALIAS_TABLE = 'command-aliases';
|
||||
|
||||
/**
|
||||
* Repo-relative TRACKED sources whose edit should re-run the drift check.
|
||||
*
|
||||
* Deliberately the `src/*.cts` sources, never the `gsd-core/bin/lib/*.cjs`
|
||||
* build outputs they compile to: those are gitignored, so
|
||||
* `git diff --cached --name-only` can never list them and a pre-commit guard
|
||||
* keyed on them can never fire (#2725).
|
||||
*/
|
||||
function stagedSourcePaths() {
|
||||
return [`src/${ALIAS_TABLE}.cts`, ...FAMILIES.map((family) => `src/${family.router}.cts`)];
|
||||
}
|
||||
|
||||
module.exports = { FAMILIES, ALIAS_TABLE, stagedSourcePaths };
|
||||
1
tests/fixtures/install-tree/antigravity.json
vendored
1
tests/fixtures/install-tree/antigravity.json
vendored
@@ -413,6 +413,7 @@
|
||||
"scripts/fix-slash-commands.cjs",
|
||||
"scripts/gen-capability-registry.cjs",
|
||||
"scripts/gen-loop-host-contract.cjs",
|
||||
"scripts/lib/alias-drift-families.cjs",
|
||||
"scripts/lib/allowlist-ratchet.cjs",
|
||||
"scripts/lib/cli-exit.cjs",
|
||||
"scripts/lib/drift-scan.cjs",
|
||||
|
||||
1
tests/fixtures/install-tree/augment.json
vendored
1
tests/fixtures/install-tree/augment.json
vendored
@@ -483,6 +483,7 @@
|
||||
"scripts/fix-slash-commands.cjs",
|
||||
"scripts/gen-capability-registry.cjs",
|
||||
"scripts/gen-loop-host-contract.cjs",
|
||||
"scripts/lib/alias-drift-families.cjs",
|
||||
"scripts/lib/allowlist-ratchet.cjs",
|
||||
"scripts/lib/cli-exit.cjs",
|
||||
"scripts/lib/drift-scan.cjs",
|
||||
|
||||
@@ -482,6 +482,7 @@
|
||||
"scripts/fix-slash-commands.cjs",
|
||||
"scripts/gen-capability-registry.cjs",
|
||||
"scripts/gen-loop-host-contract.cjs",
|
||||
"scripts/lib/alias-drift-families.cjs",
|
||||
"scripts/lib/allowlist-ratchet.cjs",
|
||||
"scripts/lib/cli-exit.cjs",
|
||||
"scripts/lib/drift-scan.cjs"
|
||||
|
||||
1
tests/fixtures/install-tree/claude.json
vendored
1
tests/fixtures/install-tree/claude.json
vendored
@@ -411,6 +411,7 @@
|
||||
"scripts/fix-slash-commands.cjs",
|
||||
"scripts/gen-capability-registry.cjs",
|
||||
"scripts/gen-loop-host-contract.cjs",
|
||||
"scripts/lib/alias-drift-families.cjs",
|
||||
"scripts/lib/allowlist-ratchet.cjs",
|
||||
"scripts/lib/cli-exit.cjs",
|
||||
"scripts/lib/drift-scan.cjs",
|
||||
|
||||
1
tests/fixtures/install-tree/cline.json
vendored
1
tests/fixtures/install-tree/cline.json
vendored
@@ -383,6 +383,7 @@
|
||||
"scripts/fix-slash-commands.cjs",
|
||||
"scripts/gen-capability-registry.cjs",
|
||||
"scripts/gen-loop-host-contract.cjs",
|
||||
"scripts/lib/alias-drift-families.cjs",
|
||||
"scripts/lib/allowlist-ratchet.cjs",
|
||||
"scripts/lib/cli-exit.cjs",
|
||||
"scripts/lib/drift-scan.cjs",
|
||||
|
||||
1
tests/fixtures/install-tree/codebuddy.json
vendored
1
tests/fixtures/install-tree/codebuddy.json
vendored
@@ -483,6 +483,7 @@
|
||||
"scripts/fix-slash-commands.cjs",
|
||||
"scripts/gen-capability-registry.cjs",
|
||||
"scripts/gen-loop-host-contract.cjs",
|
||||
"scripts/lib/alias-drift-families.cjs",
|
||||
"scripts/lib/allowlist-ratchet.cjs",
|
||||
"scripts/lib/cli-exit.cjs",
|
||||
"scripts/lib/drift-scan.cjs",
|
||||
|
||||
1
tests/fixtures/install-tree/codex.json
vendored
1
tests/fixtures/install-tree/codex.json
vendored
@@ -491,6 +491,7 @@
|
||||
"scripts/fix-slash-commands.cjs",
|
||||
"scripts/gen-capability-registry.cjs",
|
||||
"scripts/gen-loop-host-contract.cjs",
|
||||
"scripts/lib/alias-drift-families.cjs",
|
||||
"scripts/lib/allowlist-ratchet.cjs",
|
||||
"scripts/lib/cli-exit.cjs",
|
||||
"scripts/lib/drift-scan.cjs"
|
||||
|
||||
1
tests/fixtures/install-tree/copilot.json
vendored
1
tests/fixtures/install-tree/copilot.json
vendored
@@ -382,6 +382,7 @@
|
||||
"scripts/fix-slash-commands.cjs",
|
||||
"scripts/gen-capability-registry.cjs",
|
||||
"scripts/gen-loop-host-contract.cjs",
|
||||
"scripts/lib/alias-drift-families.cjs",
|
||||
"scripts/lib/allowlist-ratchet.cjs",
|
||||
"scripts/lib/cli-exit.cjs",
|
||||
"scripts/lib/drift-scan.cjs",
|
||||
|
||||
1
tests/fixtures/install-tree/cursor.json
vendored
1
tests/fixtures/install-tree/cursor.json
vendored
@@ -389,6 +389,7 @@
|
||||
"scripts/fix-slash-commands.cjs",
|
||||
"scripts/gen-capability-registry.cjs",
|
||||
"scripts/gen-loop-host-contract.cjs",
|
||||
"scripts/lib/alias-drift-families.cjs",
|
||||
"scripts/lib/allowlist-ratchet.cjs",
|
||||
"scripts/lib/cli-exit.cjs",
|
||||
"scripts/lib/drift-scan.cjs",
|
||||
|
||||
1
tests/fixtures/install-tree/hermes.json
vendored
1
tests/fixtures/install-tree/hermes.json
vendored
@@ -412,6 +412,7 @@
|
||||
"scripts/fix-slash-commands.cjs",
|
||||
"scripts/gen-capability-registry.cjs",
|
||||
"scripts/gen-loop-host-contract.cjs",
|
||||
"scripts/lib/alias-drift-families.cjs",
|
||||
"scripts/lib/allowlist-ratchet.cjs",
|
||||
"scripts/lib/cli-exit.cjs",
|
||||
"scripts/lib/drift-scan.cjs",
|
||||
|
||||
1
tests/fixtures/install-tree/kilo.json
vendored
1
tests/fixtures/install-tree/kilo.json
vendored
@@ -486,6 +486,7 @@
|
||||
"scripts/fix-slash-commands.cjs",
|
||||
"scripts/gen-capability-registry.cjs",
|
||||
"scripts/gen-loop-host-contract.cjs",
|
||||
"scripts/lib/alias-drift-families.cjs",
|
||||
"scripts/lib/allowlist-ratchet.cjs",
|
||||
"scripts/lib/cli-exit.cjs",
|
||||
"scripts/lib/drift-scan.cjs",
|
||||
|
||||
1
tests/fixtures/install-tree/kimi-code.json
vendored
1
tests/fixtures/install-tree/kimi-code.json
vendored
@@ -412,6 +412,7 @@
|
||||
"scripts/fix-slash-commands.cjs",
|
||||
"scripts/gen-capability-registry.cjs",
|
||||
"scripts/gen-loop-host-contract.cjs",
|
||||
"scripts/lib/alias-drift-families.cjs",
|
||||
"scripts/lib/allowlist-ratchet.cjs",
|
||||
"scripts/lib/cli-exit.cjs",
|
||||
"scripts/lib/drift-scan.cjs",
|
||||
|
||||
1
tests/fixtures/install-tree/kimi.json
vendored
1
tests/fixtures/install-tree/kimi.json
vendored
@@ -448,6 +448,7 @@
|
||||
"scripts/fix-slash-commands.cjs",
|
||||
"scripts/gen-capability-registry.cjs",
|
||||
"scripts/gen-loop-host-contract.cjs",
|
||||
"scripts/lib/alias-drift-families.cjs",
|
||||
"scripts/lib/allowlist-ratchet.cjs",
|
||||
"scripts/lib/cli-exit.cjs",
|
||||
"scripts/lib/drift-scan.cjs",
|
||||
|
||||
1
tests/fixtures/install-tree/opencode.json
vendored
1
tests/fixtures/install-tree/opencode.json
vendored
@@ -486,6 +486,7 @@
|
||||
"scripts/fix-slash-commands.cjs",
|
||||
"scripts/gen-capability-registry.cjs",
|
||||
"scripts/gen-loop-host-contract.cjs",
|
||||
"scripts/lib/alias-drift-families.cjs",
|
||||
"scripts/lib/allowlist-ratchet.cjs",
|
||||
"scripts/lib/cli-exit.cjs",
|
||||
"scripts/lib/drift-scan.cjs",
|
||||
|
||||
1
tests/fixtures/install-tree/pi.json
vendored
1
tests/fixtures/install-tree/pi.json
vendored
@@ -380,6 +380,7 @@
|
||||
"scripts/fix-slash-commands.cjs",
|
||||
"scripts/gen-capability-registry.cjs",
|
||||
"scripts/gen-loop-host-contract.cjs",
|
||||
"scripts/lib/alias-drift-families.cjs",
|
||||
"scripts/lib/allowlist-ratchet.cjs",
|
||||
"scripts/lib/cli-exit.cjs",
|
||||
"scripts/lib/drift-scan.cjs"
|
||||
|
||||
1
tests/fixtures/install-tree/qwen.json
vendored
1
tests/fixtures/install-tree/qwen.json
vendored
@@ -412,6 +412,7 @@
|
||||
"scripts/fix-slash-commands.cjs",
|
||||
"scripts/gen-capability-registry.cjs",
|
||||
"scripts/gen-loop-host-contract.cjs",
|
||||
"scripts/lib/alias-drift-families.cjs",
|
||||
"scripts/lib/allowlist-ratchet.cjs",
|
||||
"scripts/lib/cli-exit.cjs",
|
||||
"scripts/lib/drift-scan.cjs",
|
||||
|
||||
1
tests/fixtures/install-tree/trae.json
vendored
1
tests/fixtures/install-tree/trae.json
vendored
@@ -380,6 +380,7 @@
|
||||
"scripts/fix-slash-commands.cjs",
|
||||
"scripts/gen-capability-registry.cjs",
|
||||
"scripts/gen-loop-host-contract.cjs",
|
||||
"scripts/lib/alias-drift-families.cjs",
|
||||
"scripts/lib/allowlist-ratchet.cjs",
|
||||
"scripts/lib/cli-exit.cjs",
|
||||
"scripts/lib/drift-scan.cjs",
|
||||
|
||||
1
tests/fixtures/install-tree/windsurf.json
vendored
1
tests/fixtures/install-tree/windsurf.json
vendored
@@ -383,6 +383,7 @@
|
||||
"scripts/fix-slash-commands.cjs",
|
||||
"scripts/gen-capability-registry.cjs",
|
||||
"scripts/gen-loop-host-contract.cjs",
|
||||
"scripts/lib/alias-drift-families.cjs",
|
||||
"scripts/lib/allowlist-ratchet.cjs",
|
||||
"scripts/lib/cli-exit.cjs",
|
||||
"scripts/lib/drift-scan.cjs"
|
||||
|
||||
1
tests/fixtures/install-tree/zcode.json
vendored
1
tests/fixtures/install-tree/zcode.json
vendored
@@ -451,6 +451,7 @@
|
||||
"scripts/fix-slash-commands.cjs",
|
||||
"scripts/gen-capability-registry.cjs",
|
||||
"scripts/gen-loop-host-contract.cjs",
|
||||
"scripts/lib/alias-drift-families.cjs",
|
||||
"scripts/lib/allowlist-ratchet.cjs",
|
||||
"scripts/lib/cli-exit.cjs",
|
||||
"scripts/lib/drift-scan.cjs",
|
||||
|
||||
@@ -4,13 +4,33 @@ 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
|
||||
@@ -22,58 +42,266 @@ function writeMock(tmpDir, name, content) {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
describe('.githooks/pre-commit alias drift guard', () => {
|
||||
test('runs npm check when staged files include command-manifest/alias artifacts', (t) => {
|
||||
const tmpDir = createTempDir('gsd-precommit-hook-');
|
||||
t.after(() => cleanup(tmpDir));
|
||||
/**
|
||||
* 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 mockGit = writeMock(tmpDir, 'git', `#!/usr/bin/env bash\nprintf "%s\\n" "${'sdk/src/query/command-manifest.phase.ts'}"\n`);
|
||||
const mockNpm = writeMock(tmpDir, 'npm', `#!/usr/bin/env bash\nprintf "called" > "$GSD_TEST_NPM_MARKER"\n`);
|
||||
const marker = path.join(tmpDir, 'npm-calls.txt');
|
||||
|
||||
const marker = path.join(tmpDir, 'npm-called.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`,
|
||||
);
|
||||
|
||||
throwIfFailed(
|
||||
runHook(HOOK_PATH, [], {
|
||||
interpreter: 'bash',
|
||||
cwd: ROOT,
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_OVERRIDE: mockGit,
|
||||
NPM_OVERRIDE: mockNpm,
|
||||
GSD_TEST_NPM_MARKER: marker,
|
||||
},
|
||||
timeoutMs: 15000,
|
||||
}),
|
||||
`bash ${HOOK_PATH}`,
|
||||
);
|
||||
|
||||
assert.ok(fs.existsSync(marker), 'expected npm run check:alias-drift to be invoked');
|
||||
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,
|
||||
});
|
||||
|
||||
test('does not run npm check when staged files are unrelated', (t) => {
|
||||
const tmpDir = createTempDir('gsd-precommit-hook-');
|
||||
t.after(() => cleanup(tmpDir));
|
||||
const npmCalls = fs.existsSync(marker)
|
||||
? fs.readFileSync(marker, 'utf8').split(/\r?\n/).filter(Boolean).length
|
||||
: 0;
|
||||
|
||||
const mockGit = writeMock(tmpDir, 'git', `#!/usr/bin/env bash\nprintf "%s\\n" "README.md"\n`);
|
||||
const mockNpm = writeMock(tmpDir, 'npm', `#!/usr/bin/env bash\nprintf "called" > "$GSD_TEST_NPM_MARKER"\n`);
|
||||
return { result, npmCalls };
|
||||
}
|
||||
|
||||
const marker = path.join(tmpDir, 'npm-called.txt');
|
||||
/** Join paths the way `git diff --cached --name-only` emits them. */
|
||||
function staged(...paths) {
|
||||
return paths.length === 0 ? '' : `${paths.join('\n')}\n`;
|
||||
}
|
||||
|
||||
throwIfFailed(
|
||||
runHook(HOOK_PATH, [], {
|
||||
interpreter: 'bash',
|
||||
cwd: ROOT,
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_OVERRIDE: mockGit,
|
||||
NPM_OVERRIDE: mockNpm,
|
||||
GSD_TEST_NPM_MARKER: marker,
|
||||
},
|
||||
timeoutMs: 15000,
|
||||
}),
|
||||
`bash ${HOOK_PATH}`,
|
||||
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'),
|
||||
);
|
||||
|
||||
assert.ok(!fs.existsSync(marker), 'expected npm check to be skipped for unrelated staged files');
|
||||
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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user