Files
msd-core/tests/workflow-guard-registration.test.cjs
Jakub Zych fe3ed06691
Some checks failed
Tests / PR mergeability (push) Successful in 18s
Tests / Base branch health (push) Successful in 9s
Tests / Detect test scope (push) Successful in 16s
Tests / lint-tests (push) Failing after 1m43s
Tests / plugin-validate (push) Successful in 58s
Tests / test (ubuntu-latest, 24, shard 1/3) (push) Failing after 19s
Tests / test (ubuntu-latest, 24, shard 2/3) (push) Failing after 20s
Tests / test (ubuntu-latest, 24, shard 3/3) (push) Failing after 20s
Tests / test (ubuntu-latest, 24) (push) Failing after 18s
Tests / test (inert CI) (push) Has been skipped
Tests / QA loop walk (smell ratchet) (push) Failing after 19s
Tests / Coverage gate (merged shards) (push) Has been skipped
Tests / Publish emitted-baseline artifact (push) Has been skipped
Duplicate auto-close sweep / sweep (push) Successful in 19s
CI timeout budget report / report (push) Failing after 14s
Close Draft PRs (sweep) / Sweep open draft PRs (push) Successful in 9s
Dismiss Unauthorized PR Approvals / dismiss-unauthorized-approval (push) Successful in 9s
Tests / conformance test (macos-latest, 24) (push) Has been cancelled
Tests / conformance test (windows-latest, 24, shard 1/3) (push) Has been cancelled
Tests / conformance test (windows-latest, 24, shard 2/3) (push) Has been cancelled
Tests / conformance test (windows-latest, 24, shard 3/3) (push) Has been cancelled
Tests / Required tests (push) Has been cancelled
chore: clear dead test and allowlist leftovers of dropped runtimes
2026-10-06 20:35:12 +02:00

269 lines
12 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: msd-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 msdHooks 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.
'msd-cursor-session-start.js',
'msd-cursor-post-tool.js',
'msd-cursor-pre-tool.js',
'msd-cursor-stop.js',
'msd-cursor-subagent-start.js',
'msd-cursor-subagent-stop.js',
// msd-check-update-worker.js is an implementation detail of msd-check-update.js
// (spawned internally via child_process.spawn), never itself registered as a
// hook entry point.
'msd-check-update-worker.js',
// msd-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.
'msd-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 msd-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('msd-workflow-guard.js') &&
(line.includes('buildHookCommand') || line.includes("'node '"))
);
assert.ok(
commandConstructionLines.length > 0,
[
'install.js must construct a command path for msd-workflow-guard.js',
'(e.g. buildHookCommand or node + dirName pattern).',
'Currently only referenced in msdHooks 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 msdHooks (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 { MSD_UNINSTALL_HOOKS } = require('../bin/install.js');
assert.ok(Array.isArray(MSD_UNINSTALL_HOOKS), 'MSD_UNINSTALL_HOOKS must be exported from install.js');
// Cursor hooks (msd-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 = MSD_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 msdHooks 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 msdHooks (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, 'msd-cursor-session-start.js'), '// stub\n');
fs.writeFileSync(path.join(srcHooksDir, 'msd-cursor-post-tool.js'), '// stub\n');
// Capture the Set BEFORE the call so we can diff it after.
const hooksSurface = require('../msd-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('../msd-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 msd-cursor-session-start and msd-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, 'msd-cursor-session-start.js'), '// stub\n');
fs.writeFileSync(path.join(srcHooksDir, 'msd-cursor-post-tool.js'), '// stub\n');
const { MSD_CURSOR_HOOK_MARKER } = require('../bin/install.js');
const { writeCursorHooksJson } = require('../msd-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 (msd-cursor-session-start.js)'
);
assert.ok(
Array.isArray(hookTable.postToolUse) && hookTable.postToolUse.length > 0,
'hooks.json must contain a postToolUse entry (msd-cursor-post-tool.js)'
);
// Both entries must be MSD-managed (not user-authored stubs).
assert.equal(
hookTable.sessionStart[0][MSD_CURSOR_HOOK_MARKER], true,
'sessionStart entry must carry the MSD managed marker'
);
assert.equal(
hookTable.postToolUse[0][MSD_CURSOR_HOOK_MARKER], true,
'postToolUse entry must carry the MSD 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('msd-cursor-session-start.js'),
`sessionStart command must reference msd-cursor-session-start.js, got: ${sessionCmd}`
);
assert.ok(
postToolCmd.includes('msd-cursor-post-tool.js'),
`postToolUse command must reference msd-cursor-post-tool.js, got: ${postToolCmd}`
);
});
});