* enh(#2876): retire the dead and pass-through exports from bin/install.js The installer exported 197 names and had zero production consumers - every non-test require of it repo-wide sits inside a comment. Its interface was shaped by test access, not by callers. Removes 9 dead exports and 61 pass-throughs, repointing their tests onto the extracted modules' own interfaces. 197 down to 127. Every count in the issue was wrong: 197 exports not 188, 9 dead not 12, 61 pass-throughs not 49, 44 test files not 42 - and the audit itself then missed 7 more consumer files. restoreUserArtifacts was on the dead list but ceased to exist in phase 6, and two _GSD_EFFORT_MANIFEST_* names listed as dead are now genuinely asserted, so acting on that list would have deleted live exports. 7 of the 9 dead names collide with an independent declaration that install.js delegates TO. Each removal was justified by which declaration a reference resolves to, never by whether the name appears somewhere. Coverage parity was the gate rather than test greenness: per-file counts were captured before any edit and diffed after. 44 of 45 files are byte-identical; the single delta is one added assertion, not a loss. The sweep for scattered require sites found two forms static grep misses - require(VARIABLE) and multi-line require() - plus tests asserting that install.js re-exports the SAME object, which now assert retirement instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#2876): close review findings — restore the duplicate-body guard, sweep orphaned code Both review engines found real defects in the first cut. The DEFECT.GENERATIVE-FIX single-owner guard from #1511 had been repointed from a reference-identity check to install.X === undefined. Those are not equivalent: the guard exists to catch a duplicate function body reintroduced into install.js, and the replacement passes cleanly if that duplicate is used internally and never exported. It now walks bin/install.js's real top-level bindings, so it catches a duplicate under either shape, exported or not - strictly stronger than the check it replaced. Proved by injecting a duplicate and watching it go red. That weakening survived the coverage-parity gate because the assertion count never moved. The gate compares counts, so an assertion that changes meaning rather than number is invisible to it. Removing the exports had orphaned their wrapper bodies: 14 dead wrappers, 9 consts and 9 destructure entries, several pre-existing and found by the same sweep. Dead code left in the file this phase exists to shrink. Three more comments claimed re-exports this phase removed, and tests were reading Cursor and Windsurf hook constants from install.js's local copy while calling functions from the hooks surface - equal today, with nothing holding them equal. The local consts now reference the owning module. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#2876): backfill changeset pr number Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
275 lines
13 KiB
JavaScript
275 lines
13 KiB
JavaScript
// allow-test-rule: structural-regression-guard
|
|
// Reads hook .js or bin/install.js source to assert structural invariants
|
|
// (search array order, function wiring, path constants) that cannot be
|
|
// verified by observing runtime outputs alone. Per CONTRIBUTING.md exception matrix.
|
|
|
|
/**
|
|
* Regression guard for #1767: gsd-workflow-guard.js must be registered in settings.json
|
|
*
|
|
* The hook file is built, copied, and installed — but was never registered as a
|
|
* PreToolUse hook entry in install.js. This test ensures the registration block
|
|
* exists with the correct structure.
|
|
*
|
|
* Also tests the broader anti-pattern: every hook in gsdHooks that is a JS
|
|
* PreToolUse/PostToolUse hook should have a corresponding registration block.
|
|
* Hooks owned by the runtime-hooks-surface module (Cursor) are validated
|
|
* behaviorally rather than by source-scan of install.js.
|
|
*/
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { createTempDir, cleanup } = require('./helpers.cjs');
|
|
|
|
const INSTALL_JS = path.join(__dirname, '..', 'bin', 'install.js');
|
|
// ADR-857 phase 5f-1b: settings-json hook registration moved from install.js into
|
|
// applySettingsJsonHooks in src/runtime-hooks-surface.cts. Source-scan checks must
|
|
// include both files so structural invariants are verified against the correct source.
|
|
const HOOKS_SURFACE_SRC = path.join(__dirname, '..', 'src', 'runtime-hooks-surface.cts');
|
|
|
|
// Hooks whose registration is NOT a literal buildHookCommand(..., '<name>', ...)
|
|
// call in install.js/runtime-hooks-surface.cts, so the source-scan below cannot
|
|
// find them there. These are excluded from that scan and validated/documented
|
|
// elsewhere.
|
|
const MODULE_OWNED_HOOKS = new Set([
|
|
// Cursor lifecycle hooks — registered by writeCursorHooksJson via the
|
|
// descriptor-driven CURSOR_EVENT_SCRIPT_MAP indirection
|
|
// (src/host-integration-adapters/imperative-hook-bus.cts), never a literal
|
|
// buildHookCommand(..., '<hook-name>', ...) call this source-scan can match.
|
|
// Validated behaviorally in the describe block below.
|
|
'gsd-cursor-session-start.js',
|
|
'gsd-cursor-post-tool.js',
|
|
'gsd-cursor-pre-tool.js',
|
|
'gsd-cursor-stop.js',
|
|
'gsd-cursor-subagent-start.js',
|
|
'gsd-cursor-subagent-stop.js',
|
|
// Windsurf/Cascade blocking hooks — registered by writeWindsurfHooksJson via the
|
|
// WINDSURF_EVENT_SCRIPT_MAP indirection (src/runtime-hooks-surface.cts), never a
|
|
// literal buildHookCommand(..., '<hook-name>', ...) call this source-scan matches.
|
|
// Validated behaviorally by tests/windsurf-hooks-bridge.test.cjs.
|
|
'gsd-windsurf-pre-write.js',
|
|
'gsd-windsurf-pre-command.js',
|
|
// gsd-check-update-worker.js is an implementation detail of gsd-check-update.js
|
|
// (spawned internally via child_process.spawn), never itself registered as a
|
|
// hook entry point.
|
|
'gsd-check-update-worker.js',
|
|
// gsd-ensure-canonical-path.js (#997) is registered ONLY in the
|
|
// marketplace-plugin manifest (hooks/hooks.json's SessionStart entry), not
|
|
// via install.js's classic-installer settings.json path — the plugin
|
|
// install mode never runs bin/install.js at all.
|
|
'gsd-ensure-canonical-path.js',
|
|
]);
|
|
|
|
// ADR-857 phase 5f-1b: settings-json hook registration moved to runtime-hooks-surface.cts.
|
|
// Concatenate both sources so structural assertions find patterns in either file.
|
|
function readInstallSources() {
|
|
const installSrc = fs.readFileSync(INSTALL_JS, 'utf-8');
|
|
let hooksSurfaceSrc = '';
|
|
try { hooksSurfaceSrc = fs.readFileSync(HOOKS_SURFACE_SRC, 'utf-8'); } catch { /* ok */ }
|
|
return installSrc + '\n' + hooksSurfaceSrc;
|
|
}
|
|
|
|
describe('workflow-guard hook registration (#1767)', () => {
|
|
test('install.js constructs a command path variable for gsd-workflow-guard.js', () => {
|
|
const content = readInstallSources();
|
|
const lines = content.split('\n');
|
|
// Every registered JS hook has a command variable constructed via
|
|
// buildHookCommand() or string concatenation. Filter out references
|
|
// that are only in the cleanup/uninstall arrays.
|
|
const commandConstructionLines = lines.filter(line =>
|
|
line.includes('gsd-workflow-guard.js') &&
|
|
(line.includes('buildHookCommand') || line.includes("'node '"))
|
|
);
|
|
assert.ok(
|
|
commandConstructionLines.length > 0,
|
|
[
|
|
'install.js must construct a command path for gsd-workflow-guard.js',
|
|
'(e.g. buildHookCommand or node + dirName pattern).',
|
|
'Currently only referenced in gsdHooks cleanup array.',
|
|
].join(' ')
|
|
);
|
|
});
|
|
|
|
test('install.js has a hasWorkflowGuardHook dedup check', () => {
|
|
const content = readInstallSources();
|
|
// Every registered hook has a dedup check: hasXxxHook = settings.hooks[...].some(...)
|
|
const hasDedup = content.includes('hasWorkflowGuardHook') ||
|
|
content.includes('hasWorkflowGuard');
|
|
assert.ok(
|
|
hasDedup,
|
|
'install.js must have a dedup check variable for workflow-guard (like hasPromptGuardHook)'
|
|
);
|
|
});
|
|
|
|
test('install.js pushes workflow-guard entry with correct matcher', () => {
|
|
const content = readInstallSources();
|
|
// Extract the workflow-guard registration section. It should install the
|
|
// Bash-aware matcher and upgrade old edit-only entries on reinstall.
|
|
const workflowGuardSection = content.match(
|
|
/workflowGuardCommand[\s\S]*?Configure commit validation hook/i
|
|
);
|
|
assert.ok(
|
|
workflowGuardSection,
|
|
'install.js must have a push block for workflow-guard with a console.log confirmation'
|
|
);
|
|
assert.ok(
|
|
workflowGuardSection[0].includes("const workflowGuardMatcher = 'Bash|Edit|Write|MultiEdit'") &&
|
|
workflowGuardSection[0].includes('matcher: workflowGuardMatcher'),
|
|
'workflow guard must be registered for Bash so worktree-agent git safety checks can run'
|
|
);
|
|
assert.ok(
|
|
workflowGuardSection[0].includes('workflowGuardHookEntry.matcher = workflowGuardMatcher'),
|
|
'installer must upgrade existing workflow guard hook entries to the Bash-aware matcher'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('hook registration completeness anti-pattern guard', () => {
|
|
test('every JS hook in gsdHooks (except module-owned) has a command construction in install.js', () => {
|
|
const content = readInstallSources();
|
|
// Use the typed export instead of source-grep regex (branch #455: retire source-grep)
|
|
const { GSD_UNINSTALL_HOOKS } = require('../bin/install.js');
|
|
assert.ok(Array.isArray(GSD_UNINSTALL_HOOKS), 'GSD_UNINSTALL_HOOKS must be exported from install.js');
|
|
|
|
// Cursor hooks (gsd-cursor-*.js) are registered by writeCursorHooksJson in
|
|
// runtime-hooks-surface module, not by direct buildHookCommand calls in install.js.
|
|
// They are validated behaviorally in the describe block below.
|
|
const jsHooks = GSD_UNINSTALL_HOOKS.filter(h => h.endsWith('.js') && !MODULE_OWNED_HOOKS.has(h));
|
|
|
|
const missing = [];
|
|
for (const hook of jsHooks) {
|
|
// Each JS hook should have a buildHookCommand or 'node ' command construction
|
|
// that references the hook filename (not just the gsdHooks array or uninstall filter)
|
|
const lines = content.split('\n').filter(line =>
|
|
line.includes(hook) &&
|
|
(line.includes('buildHookCommand') || line.includes("'node '"))
|
|
);
|
|
if (lines.length === 0) {
|
|
missing.push(hook);
|
|
}
|
|
}
|
|
|
|
assert.strictEqual(
|
|
missing.length, 0,
|
|
[
|
|
'Every JS hook in gsdHooks (excluding module-owned cursor hooks) must have a command',
|
|
'construction in install.js. Missing registration for:',
|
|
...missing.map(h => ` - ${h}`),
|
|
].join('\n')
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('atomic write temp-tracking — shared Set parity guard', () => {
|
|
// FIX 1 verification: atomic writes from the runtime-hooks-surface module
|
|
// must register their temp paths into the SAME __atomicWrittenTmps Set that
|
|
// install.js's _cleanTmpFiles() uses. If the module had its own separate Set,
|
|
// temp files from Cursor/Codex writes would never be cleaned up.
|
|
test('writeCursorHooksJson atomic write registers temp path in shared __atomicWrittenTmps', (t) => {
|
|
const targetDir = createTempDir('atomic-track-target-');
|
|
const srcDir = createTempDir('atomic-track-src-');
|
|
t.after(() => {
|
|
cleanup(targetDir);
|
|
cleanup(srcDir);
|
|
});
|
|
|
|
const srcHooksDir = path.join(srcDir, 'hooks');
|
|
fs.mkdirSync(srcHooksDir, { recursive: true });
|
|
fs.writeFileSync(path.join(srcHooksDir, 'gsd-cursor-session-start.js'), '// stub\n');
|
|
fs.writeFileSync(path.join(srcHooksDir, 'gsd-cursor-post-tool.js'), '// stub\n');
|
|
|
|
// Capture the Set BEFORE the call so we can diff it after.
|
|
const hooksSurface = require('../gsd-core/bin/lib/runtime-hooks-surface.cjs');
|
|
const tmpsSet = hooksSurface.__atomicWrittenTmps;
|
|
assert.ok(tmpsSet instanceof Set, '__atomicWrittenTmps must be a Set');
|
|
const sizeBefore = tmpsSet.size;
|
|
|
|
const { writeCursorHooksJson } = require('../gsd-core/bin/lib/runtime-hooks-surface.cjs');
|
|
writeCursorHooksJson(targetDir, srcDir, {});
|
|
|
|
// After the write, the Set must have grown: the hooks.json temp path
|
|
// was recorded so _cleanTmpFiles can clean it if it lingers.
|
|
assert.ok(
|
|
tmpsSet.size > sizeBefore,
|
|
`__atomicWrittenTmps must grow after writeCursorHooksJson (was ${sizeBefore}, now ${tmpsSet.size})`
|
|
);
|
|
|
|
// Every new entry must be an absolute path matching the tmp pattern.
|
|
for (const tmp of tmpsSet) {
|
|
if (tmp.startsWith(targetDir)) {
|
|
// This is the entry we added; it must look like hooks.json.tmp-<pid>-<n>
|
|
assert.ok(
|
|
/hooks\.json\.tmp-\d+-\d+$/.test(tmp),
|
|
`Registered temp path must match hooks.json.tmp-<pid>-<n> pattern, got: ${tmp}`
|
|
);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('cursor hook registration — behavioral guard (module-owned hooks)', () => {
|
|
// The cursor hook scripts are registered by writeCursorHooksJson inside
|
|
// runtime-hooks-surface. This test calls the function directly and asserts
|
|
// that both managed entries appear in hooks.json. Deleting either cursor
|
|
// hook registration from the module will cause this test to fail.
|
|
test('writeCursorHooksJson writes both gsd-cursor-session-start and gsd-cursor-post-tool to hooks.json', (t) => {
|
|
// Create a temp target dir (simulated ~/.cursor) and a src dir with stub scripts.
|
|
const targetDir = createTempDir('cursor-guard-target-');
|
|
const srcDir = createTempDir('cursor-guard-src-');
|
|
t.after(() => {
|
|
cleanup(targetDir);
|
|
cleanup(srcDir);
|
|
});
|
|
|
|
// Stub the hook scripts in srcDir/hooks/ so writeCursorHooksJson can copy them.
|
|
const srcHooksDir = path.join(srcDir, 'hooks');
|
|
fs.mkdirSync(srcHooksDir, { recursive: true });
|
|
fs.writeFileSync(path.join(srcHooksDir, 'gsd-cursor-session-start.js'), '// stub\n');
|
|
fs.writeFileSync(path.join(srcHooksDir, 'gsd-cursor-post-tool.js'), '// stub\n');
|
|
|
|
const { GSD_CURSOR_HOOK_MARKER } = require('../bin/install.js');
|
|
const { writeCursorHooksJson } = require('../gsd-core/bin/lib/runtime-hooks-surface.cjs');
|
|
const result = writeCursorHooksJson(targetDir, srcDir, {});
|
|
|
|
assert.ok(result, 'writeCursorHooksJson must return a result');
|
|
|
|
const hooksJsonPath = path.join(targetDir, 'hooks.json');
|
|
assert.ok(fs.existsSync(hooksJsonPath), 'hooks.json must be created in targetDir');
|
|
|
|
const parsed = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8'));
|
|
const hookTable = (parsed.hooks && typeof parsed.hooks === 'object') ? parsed.hooks : parsed;
|
|
|
|
// Both cursor hook entries must be present.
|
|
assert.ok(
|
|
Array.isArray(hookTable.sessionStart) && hookTable.sessionStart.length > 0,
|
|
'hooks.json must contain a sessionStart entry (gsd-cursor-session-start.js)'
|
|
);
|
|
assert.ok(
|
|
Array.isArray(hookTable.postToolUse) && hookTable.postToolUse.length > 0,
|
|
'hooks.json must contain a postToolUse entry (gsd-cursor-post-tool.js)'
|
|
);
|
|
|
|
// Both entries must be GSD-managed (not user-authored stubs).
|
|
assert.equal(
|
|
hookTable.sessionStart[0][GSD_CURSOR_HOOK_MARKER], true,
|
|
'sessionStart entry must carry the GSD managed marker'
|
|
);
|
|
assert.equal(
|
|
hookTable.postToolUse[0][GSD_CURSOR_HOOK_MARKER], true,
|
|
'postToolUse entry must carry the GSD managed marker'
|
|
);
|
|
|
|
// Both commands must reference the correct script filenames.
|
|
const sessionCmd = hookTable.sessionStart[0].command || '';
|
|
const postToolCmd = hookTable.postToolUse[0].command || '';
|
|
assert.ok(
|
|
sessionCmd.includes('gsd-cursor-session-start.js'),
|
|
`sessionStart command must reference gsd-cursor-session-start.js, got: ${sessionCmd}`
|
|
);
|
|
assert.ok(
|
|
postToolCmd.includes('gsd-cursor-post-tool.js'),
|
|
`postToolUse command must reference gsd-cursor-post-tool.js, got: ${postToolCmd}`
|
|
);
|
|
});
|
|
});
|