Files
msd-core/tests/kimi-guard-normalization-parity.test.cjs
Jakub Zych a9a7a328e6 refactor: hard-fork GSD -> MSD (Make Software Done)
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.
2026-10-06 01:47:40 +02:00

218 lines
10 KiB
JavaScript

// allow-test-rule: source-text-is-the-product #2304 — this test's whole job is
// scanning hooks/*.js source text for the inlined KIMI_TOOL_NAMES copies; the
// text IS the artifact under test (the copies have no runtime binding).
/**
* Kimi guard-normalization parity test (#2304 / PR #2326 review Major 1;
* extended by PR #2301 review Major 2 for hooks/msd-write-guard.js).
*
* The KIMI_TOOL_NAMES map + normalizeKimiPayload helper is deliberately
* inlined per hook script (a sibling require is a staging dependency that
* can fail silently — see the rationale comment in each guard), which
* leaves five hand-maintained copies plus their inverse in bin/install.js
* (claudeToKimiTools / convertKimiToolName). Nothing at runtime binds them.
*
* This test is that binding, with zero runtime coupling:
* 1. the five inlined copies are byte-identical;
* 2. every entry in each guard map is the value-inverse of what the
* installer's matcher vocabulary emits for that Claude tool;
* 3. every guard-relevant Claude tool the installer translates has a
* reverse entry — so a vocabulary extension or rename that updates
* convertKimiToolName without updating the guards fails HERE instead
* of leaving a guard silently dormant (the #2304 failure mode).
*
* msd-write-guard.js is bound SEMANTICALLY, not byte-wise: its copy
* intentionally omits the Edit-class mapping (the guard exits 0 for any
* tool but Write, so StrReplaceFile/old_string handling there is dead code
* — #2301 review Major 1), so its map is checked against the installer
* inverse and for Write-dormancy, the only tool it inspects. It is still
* required to CARRY a block, so the copy cannot silently disappear.
*/
process.env.MSD_TEST_MODE = '1';
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { convertKimiToolName } = require('../bin/install.js');
// Enumerated by scanning, never hardcoded: a sixth guard added later with its
// own copy of the block must be swept in automatically, or the copies diverge
// exactly the way this test exists to prevent (PR #2326 review M2). The
// dynamic scan is also what makes the no-shared-module decision safe.
const KIMI_MARKER = 'const KIMI_TOOL_NAMES';
const HOOKS_DIR = path.join(__dirname, '..', 'hooks');
const ALL_BLOCK_FILES = fs
.readdirSync(HOOKS_DIR)
.filter((f) => f.endsWith('.js'))
.filter((f) => fs.readFileSync(path.join(HOOKS_DIR, f), 'utf8').includes(KIMI_MARKER))
.map((f) => `hooks/${f}`)
.sort();
// Bound semantically (map-inverse + Write-dormancy), never byte-wise — see header.
const WRITE_GUARD_FILE = 'hooks/msd-write-guard.js';
// The byte-identity cohort: every scanned guard except the deliberate subset.
const HOOK_FILES = ALL_BLOCK_FILES.filter((f) => f !== WRITE_GUARD_FILE);
// The five guards normalized for #2304. A scan that misses one of these is a
// broken scan, not a passing test — without this floor, an over-narrow filter
// would "pass" by finding nothing to check.
const KNOWN_NORMALIZED_GUARDS = [
'hooks/msd-prompt-guard.js',
'hooks/msd-read-guard.js',
'hooks/msd-read-injection-scanner.js',
'hooks/msd-secret-read-guard.js',
'hooks/msd-workflow-guard.js',
'hooks/msd-worktree-path-guard.js',
];
// Claude tool names whose PreToolUse/PostToolUse guards are registered with a
// translated matcher on Kimi (runtime-hooks-surface.cts buildKimiHooksTomlBlock):
// the write guards match WriteFile|StrReplaceFile, the injection scanner
// matches ReadFile, and msd-workflow-guard.js matches Shell|WriteFile|StrReplaceFile.
const GUARD_RELEVANT_CLAUDE_TOOLS = ['Write', 'Edit', 'MultiEdit', 'Read', 'Bash'];
function extractBlock(file) {
const src = fs.readFileSync(path.join(__dirname, '..', file), 'utf8');
const start = src.indexOf('const KIMI_TOOL_NAMES');
assert.notEqual(start, -1, `${file}: KIMI_TOOL_NAMES block not found`);
const endMarker = ' return data;\n}';
const end = src.indexOf(endMarker, start);
assert.notEqual(end, -1, `${file}: normalizeKimiPayload end not found`);
return src.slice(start, end + endMarker.length);
}
function parseMap(block) {
// The guards declare `new Map([['KimiName', 'ClaudeName'], …])` (a Map so
// prototype keys resolve to undefined — review M1); parse the pair list.
const m = block.match(/const KIMI_TOOL_NAMES = new Map\(\[([\s\S]*?)\]\);/);
assert.ok(m, 'KIMI_TOOL_NAMES Map literal not parseable');
const entries = {};
for (const kv of m[1].matchAll(/\['(\w+)', '(\w+)'\]/g)) {
entries[kv[1]] = kv[2];
}
assert.ok(Object.keys(entries).length > 0, 'KIMI_TOOL_NAMES parsed empty');
return entries;
}
function assertMapIsInstallerInverse(map, file) {
for (const [kimiName, claudeName] of Object.entries(map)) {
const modulePath = convertKimiToolName(claudeName);
assert.ok(
typeof modulePath === 'string' && modulePath.endsWith(`:${kimiName}`),
`${file}: KIMI_TOOL_NAMES.${kimiName} -> '${claudeName}' is not the inverse of ` +
`convertKimiToolName('${claudeName}') = ${modulePath}`
);
}
}
describe('Kimi guard normalization parity', () => {
test('the scan finds every known normalized guard (floor — a scan that finds nothing must fail)', () => {
for (const known of KNOWN_NORMALIZED_GUARDS) {
assert.ok(
HOOK_FILES.includes(known),
`${known} carries no '${KIMI_MARKER}' block — either its normalization ` +
'was removed or the scan filter broke; both mean lost coverage'
);
}
});
test('the write guard carries a normalization block (semantically bound, but never absent)', () => {
assert.ok(
ALL_BLOCK_FILES.includes(WRITE_GUARD_FILE),
`${WRITE_GUARD_FILE} carries no '${KIMI_MARKER}' block — the shrink guard ` +
'is silently dormant on Kimi (#2304)'
);
});
test('all inlined copies of the normalization block are byte-identical', () => {
const blocks = HOOK_FILES.map(extractBlock);
for (let i = 1; i < blocks.length; i++) {
assert.equal(
blocks[i],
blocks[0],
`${HOOK_FILES[i]} normalization block diverges from ${HOOK_FILES[0]}`
);
}
});
test('every guard map is the value-inverse of the installer matcher vocabulary', () => {
assertMapIsInstallerInverse(parseMap(extractBlock(HOOK_FILES[0])), HOOK_FILES[0]);
assertMapIsInstallerInverse(parseMap(extractBlock(WRITE_GUARD_FILE)), WRITE_GUARD_FILE);
});
test('every guard-relevant Claude tool has a reverse entry (dormancy alarm)', () => {
const map = parseMap(extractBlock(HOOK_FILES[0]));
for (const claudeName of GUARD_RELEVANT_CLAUDE_TOOLS) {
const modulePath = convertKimiToolName(claudeName);
assert.ok(modulePath, `installer no longer maps ${claudeName} — update this test`);
const kimiName = modulePath.slice(modulePath.lastIndexOf(':') + 1);
assert.ok(
map[kimiName] !== undefined,
`Kimi name '${kimiName}' (from ${claudeName}) has no KIMI_TOOL_NAMES ` +
`reverse entry — the matching guard would be silently dormant on Kimi (#2304)`
);
}
});
test('msd-write-guard.js maps the Kimi name for Write (its only inspected tool)', () => {
const map = parseMap(extractBlock(WRITE_GUARD_FILE));
const modulePath = convertKimiToolName('Write');
assert.ok(modulePath, "installer no longer maps 'Write' — update this test");
const kimiName = modulePath.slice(modulePath.lastIndexOf(':') + 1);
assert.equal(
map[kimiName],
'Write',
`${WRITE_GUARD_FILE}: Kimi name '${kimiName}' must map to 'Write' or the ` +
'shrink guard is silently dormant on Kimi (#2304)'
);
// The copy must also carry the payload-field half of the normalization —
// WriteFile delivers `path`, the guard reads `file_path`.
assert.ok(
extractBlock(WRITE_GUARD_FILE).includes('input.file_path'),
`${WRITE_GUARD_FILE}: normalizeKimiPayload no longer maps path -> file_path`
);
});
});
// The two shell guards (msd-graphify-update.sh, msd-phase-boundary.sh) carry
// the same #2304 normalization reimplemented in shell — a byte-identity
// assertion cannot span the JS↔shell boundary, so instead of faking one this
// block pins the two vocabulary facts each script depends on to the
// installer's live mapping. Behavior is covered by negative-controlled tests
// beside each hook's existing suite (graphify-auto-update.slow.test.cjs,
// hooks-opt-in.test.cjs); this block is only the vocabulary-drift alarm
// (a convertKimiToolName rename fails HERE).
describe('Kimi shell-guard vocabulary parity (#2304)', () => {
const readHook = (file) =>
fs.readFileSync(path.join(__dirname, '..', file), 'utf8');
test('msd-graphify-update.sh maps the installer\'s Bash vocabulary back to Bash', () => {
const modulePath = convertKimiToolName('Bash');
assert.ok(modulePath, 'installer no longer maps Bash — update this test');
const kimiName = modulePath.slice(modulePath.lastIndexOf(':') + 1);
const src = readHook('hooks/msd-graphify-update.sh');
assert.ok(
src.includes('TOOL_NAME="${TOOL_NAME##*:}"'),
'msd-graphify-update.sh no longer strips the Kimi module-path prefix'
);
assert.ok(
src.includes(`[ "$TOOL_NAME" = "${kimiName}" ]`) && src.includes('TOOL_NAME="Bash"'),
`msd-graphify-update.sh no longer maps Kimi '${kimiName}' to Bash — ` +
'the hook is silently dormant on Kimi (#2304)'
);
});
test('msd-phase-boundary.sh prefers Kimi\'s authoritative tool_input.path, falls back to file_path (#2752)', () => {
const src = readHook('hooks/msd-phase-boundary.sh');
assert.ok(
src.includes('(typeof i.path===\'string\'&&i.path)||(typeof i.file_path===\'string\'&&i.file_path)||\'\''),
'msd-phase-boundary.sh no longer prefers tool_input.path over file_path — ' +
'path is the authoritative field (kimi-cli executes on it); a model-supplied ' +
'decoy file_path must not suppress or fabricate a reminder (#2752, mirrors #2595)'
);
});
});