Files
msd-core/tests/docs-hooks-table-parity.test.cjs
Tom Boucher 2131fe13f3 enhance(#3464): exec() detection widening, citation-debt cleanup — Phase 8 (#4171)
* feat(#3464): widen no-source-grep to detect regex.exec() on tracked text

Adds an execCall kind alongside the existing regexTest detection --
regex.exec(tracked) was invisible to the rule while regex.test(tracked)
was already caught, despite both reading a source-derived string through
a regex. Measured: 4 previously-invisible sites across 2 files.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* test(#3464): migrate 4 sites newly flagged by the exec() widening

docs-hooks-table-parity.test.cjs's three regex-extraction loops are
site-scoped marked (source-text-is-the-product) -- the dynamic
preToolEvent/postToolEvent dialect branching they mirror is explicitly
documented as not statically parseable, so a literal-pattern mirror is
the practical minimum-cost check.

no-bare-gsd-tools-command-position.test.cjs's readRouterVerbs() now
requires HOST_COMMAND_ROUTERS directly instead of regex-walking
gsd-tools.cjs's source text -- the same accessor three other suites
already use.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#3464): pay down 6 grandfathered uncited allow-test-rule markers

Two were genuinely load-bearing (suppressing a real detected violation)
and just needed a citation added -- phase6-capstone-conformance.test.cjs,
runtime-name-policy.test.cjs, both now (#3464).

Four were dead-weight file-header markers suppressing nothing -- each
file's real effective sites are covered by separate, already-cited
markers elsewhere in the same file. Deleted outright rather than cited,
per Phase 1's own precedent (remove non-load-bearing markers instead of
grandfathering them forever) -- codex-config.test.cjs (two copies),
gsd-check-update-worker-platform-gate.test.cjs, orphaned-hooks.test.cjs,
settings-jsonc.test.cjs.

allowlist.json: 134 -> 128 entries.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* chore(#3464): re-baseline effective-exemption ceiling to 84

The exec() widening's 3 newly-marked sites are now suppressed and
counted; ceiling rises 81 -> 84, the exact measured high-water mark.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#3464): correct citation and restore a wrongly-deleted marker

Two review corrections, both found by the orthogonal review pass:

- docs-hooks-table-parity.test.cjs's 3 new exec() markers cited #3464
  (mechanically "the phase that widened the rule") when the file's own
  established, correct reference is #3839 (the issue this whole test
  exists to enforce, already cited in its file header) -- fixed to match.

- gsd-check-update-worker-platform-gate.test.cjs's deleted file-header
  marker was NOT dead weight: its codeOnly() helper wraps readFileSync
  and is called inline as an assert argument, a genuine source-grep
  pattern on real .cjs/.js source that the rule cannot currently see
  (helper-function indirection is a distinct blind spot from anything
  Phase 7/8 measured) -- CONTRIBUTING.md is explicit that "unverified"
  is not the same as "vestigial." Restored, site-scoped this time
  (directly above codeOnly(), not as an inert file-header comment) and
  cited (#3103, the issue the file's own docstring already references).

codex-config.test.cjs's two deletions and orphaned-hooks.test.cjs's /
settings-jsonc.test.cjs's deletions were independently re-verified and
stand: their flagged lines read generated .toml/.json OUTPUT, not
source, or have no residual pattern at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 08:11:23 -04:00

174 lines
8.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// allow-test-rule: source-text-is-the-product (#3839)
// Reads the docs hook tables and the hook-surface source whose registrations
// ARE the deployed contract — asserting the docs rows match the surface.
/**
* Docs hook-table parity — docs-hooks-table-parity.test.cjs
*
* #3839: the shipped hook tables documented `gsd-validate-commit.sh` as
* PostToolUse when it is registered PreToolUse (a PreToolUse hook BLOCKS a
* commit via exit 2; a PostToolUse hook cannot — the documented event
* misdescribes the hook's entire contract). The same scan found
* `gsd-session-state.sh` documented PostToolUse while registered
* SessionStart. Nine files carried the wrong rows (ARCHITECTURE.md +
* INVENTORY.md × en, ja-JP, zh-CN, ko-KR, pt-BR).
*
* Truth source: the literal hook-spec array in `buildKimiHooksTomlBlock`
* (src/runtime-hooks-surface.cts) — its own comment pins the invariant
* "mirrors applySettingsJsonHooks' settings.json wiring 1:1" — unioned with
* literal-event probe lines (`settings.hooks.<Event>.some(…
* referencesHook(…, 'gsd-…'))`). Registrations written through the
* runtime-resolved variables (`preToolEvent`/`postToolEvent`) are NOT
* statically parseable and are covered only via the mirror invariant; hooks
* registered on other surfaces (statusline, plugin-surface) are out of
* scope. Docs rows are exempt when their Event cell is not exactly one
* registered event (multi-event `A` / `B` cells, `statusLine`, `(helper)`,
* host-native names) — note docs/how-to/install-on-your-runtime.md also
* documents these mappings in transposed event-first tables, which this
* hook-first row parser intentionally does not read.
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const ROOT = path.join(__dirname, '..');
const SURFACE_PATH = path.join(ROOT, 'src', 'runtime-hooks-surface.cts');
const DOC_TABLES = [
'docs/ARCHITECTURE.md',
'docs/INVENTORY.md',
'docs/ja-JP/ARCHITECTURE.md',
'docs/ja-JP/INVENTORY.md',
'docs/zh-CN/ARCHITECTURE.md',
'docs/zh-CN/INVENTORY.md',
'docs/ko-KR/ARCHITECTURE.md',
'docs/ko-KR/INVENTORY.md',
'docs/pt-BR/ARCHITECTURE.md',
'docs/pt-BR/INVENTORY.md',
];
// The exact basename set the surface parser must resolve. If a registration
// disappears or the parser drifts, this pin fails instead of the parity
// checks silently narrowing to a subset.
const EXPECTED_SURFACE_HOOKS = [
'gsd-agent-isolation-guard.js',
'gsd-check-update.js',
'gsd-config-reload.js',
'gsd-context-monitor.js',
'gsd-graphify-update.sh',
'gsd-phase-boundary.sh',
'gsd-prompt-guard.js',
'gsd-read-guard.js',
'gsd-read-injection-scanner.js',
'gsd-session-state.sh',
'gsd-validate-commit.sh',
'gsd-workflow-guard.js',
'gsd-worktree-path-guard.js',
'gsd-write-guard.js',
];
/** hook basename → Set of events it is registered under on this surface. */
function registeredHookEvents() {
const src = fs.readFileSync(SURFACE_PATH, 'utf8');
const map = new Map();
// Probe lines name hooks WITHOUT file extensions (`'gsd-session-state'`).
// Resolve a bare name against the actual hooks/ directory (the file's
// extension is ground truth) so both spellings resolve to one entry.
const resolveBase = (name) => {
if (/\.(sh|js|cmd)$/.test(name)) return name;
for (const ext of ['.js', '.sh', '.cmd']) {
if (fs.existsSync(path.join(ROOT, 'hooks', `${name}${ext}`))) return `${name}${ext}`;
}
return name; // no file twin (e.g. a name never documented in docs tables)
};
const add = (base, event) => {
const key = resolveBase(base);
if (!map.has(key)) map.set(key, new Set());
map.get(key).add(event);
};
let m;
// Literal hook-spec array (the Kimi mirror of the settings.json wiring).
const specRe = /event:\s*'([A-Za-z]+)',\s*command:\s*cmd\('([^']+)'\)/g;
// allow-test-rule: source-text-is-the-product (#3839)
while ((m = specRe.exec(src)) !== null) add(path.basename(m[2]), m[1]);
// Probe lines paired with a literal event:
// settings.hooks.<Event>.some(… referencesHook(…, '<name>'))
const probeRe = /settings\.hooks\.([A-Za-z]+)\.some\(\(entry: HookGroup\) =>\s*\n\s*entry\.hooks && entry\.hooks\.some\(\(h: HookEntry\) => referencesHook\(h as Record<string, unknown>, '([^']+)'\)/g;
// allow-test-rule: source-text-is-the-product (#3839)
while ((m = probeRe.exec(src)) !== null) add(m[2], m[1]);
// Probe lines paired with the runtime-resolved variables — statically
// resolved to their non-Gemini canonical events (docs document the
// canonical Claude/GS wiring; BeforeTool/AfterTool are the Gemini twins):
// const preToolEvent = hookEvents === 'gemini' ? 'BeforeTool' : 'PreToolUse'
const dynRe = /settings\.hooks\[(preToolEvent|postToolEvent)\]\.some\(\(entry: HookGroup\) =>\s*\n\s*entry\.hooks && entry\.hooks\.some\(\(h: HookEntry\) => referencesHook\(h as Record<string, unknown>, '([^']+)'\)/g;
// allow-test-rule: source-text-is-the-product (#3839)
while ((m = dynRe.exec(src)) !== null) add(m[2], m[1] === 'preToolEvent' ? 'PreToolUse' : 'PostToolUse');
return map;
}
/** Hook-table rows: [basename, eventCell] for `gsd-*` hooks. */
function docHookRows(docPath) {
const lines = fs.readFileSync(path.join(ROOT, docPath), 'utf8').split(/\r?\n/);
const rows = [];
for (const line of lines) {
const m = line.match(/^\|\s*`?(gsd-[a-z0-9-]+\.(?:sh|js|cmd))`?\s*\|\s*`([^`]+)`\s*\|/);
if (m) rows.push([m[1], m[2]]);
}
return rows;
}
// Cells that are not a single surface event: multi-event (`A` / `B`),
// non-event identifiers (statusLine, host-native names), or placeholders.
const SINGLE_EVENT_CELL = /^[A-Z][A-Za-z]+$/;
describe('docs hook tables match runtime-hooks-surface registrations', () => {
const surface = registeredHookEvents();
test('surface parser resolves the exact expected hook set (no silent drift)', () => {
const withExt = [...surface.keys()].filter((k) => /\.(sh|js|cmd)$/.test(k)).sort();
assert.deepEqual(
withExt,
[...EXPECTED_SURFACE_HOOKS].sort(),
'parser must resolve exactly the expected surface registrations'
);
});
for (const doc of DOC_TABLES) {
test(`${doc}: every single-event hook row matches the surface`, () => {
const rows = docHookRows(doc);
assert.ok(rows.length >= 3, `${doc}: hook-table parser found rows (guard against silent table drift)`);
const mismatches = [];
for (const [base, cell] of rows) {
const events = surface.get(base);
if (!events) continue; // not registered on this surface (statusline, plugin-surface hooks…)
if (!SINGLE_EVENT_CELL.test(cell)) continue; // multi-event or non-event cell
if (!events.has(cell)) {
mismatches.push(`${base}: docs say \`${cell}\`, surface registers ${[...events].join(', ')}`);
}
}
assert.deepEqual(mismatches, [], `docs rows must match src/runtime-hooks-surface.cts (#3839)`);
});
}
test('#3839 regression pin: the two misdocumented hooks are asserted directly', () => {
assert.ok(surface.get('gsd-validate-commit.sh').has('PreToolUse'),
'gsd-validate-commit.sh blocks commits — must stay PreToolUse on the surface');
assert.ok(surface.get('gsd-session-state.sh').has('SessionStart'),
'gsd-session-state.sh orients the session — must stay SessionStart on the surface');
// ja-JP/ARCHITECTURE.md's hook table is a shorter translation that never
// listed these two hooks — it stays in the parity loop above but not here.
const PINNED = DOC_TABLES.filter((d) => d !== 'docs/ja-JP/ARCHITECTURE.md');
for (const doc of PINNED) {
const rows = docHookRows(doc);
const vc = rows.find(([b]) => b === 'gsd-validate-commit.sh');
assert.ok(vc, `${doc}: validate-commit row present`);
assert.equal(vc[1], 'PreToolUse', `${doc}: validate-commit documented as PreToolUse`);
const ss = rows.find(([b]) => b === 'gsd-session-state.sh');
assert.ok(ss, `${doc}: session-state row present`);
assert.equal(ss[1], 'SessionStart', `${doc}: session-state documented as SessionStart`);
}
});
});