@sdsrs/code-graph 0.94.0 → 0.95.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/claude-plugin/.claude-plugin/plugin.json +1 -1
  2. package/claude-plugin/scripts/auto-update.js +69 -9
  3. package/package.json +8 -7
  4. package/claude-plugin/scripts/adopt.test.js +0 -679
  5. package/claude-plugin/scripts/auto-update.test.js +0 -515
  6. package/claude-plugin/scripts/cg-answer.test.js +0 -309
  7. package/claude-plugin/scripts/claude-config.test.js +0 -58
  8. package/claude-plugin/scripts/covering-tests.test.js +0 -78
  9. package/claude-plugin/scripts/doctor.test.js +0 -215
  10. package/claude-plugin/scripts/find-binary.test.js +0 -246
  11. package/claude-plugin/scripts/hook-fire.test.js +0 -117
  12. package/claude-plugin/scripts/hooks.test.js +0 -230
  13. package/claude-plugin/scripts/incremental-index.test.js +0 -102
  14. package/claude-plugin/scripts/lifecycle.e2e.test.js +0 -179
  15. package/claude-plugin/scripts/lifecycle.test.js +0 -786
  16. package/claude-plugin/scripts/mcp-launcher.test.js +0 -162
  17. package/claude-plugin/scripts/mcp-stub.test.js +0 -207
  18. package/claude-plugin/scripts/post-grep-inject.test.js +0 -531
  19. package/claude-plugin/scripts/pr-impact-comment.test.js +0 -110
  20. package/claude-plugin/scripts/pre-edit-guide.test.js +0 -218
  21. package/claude-plugin/scripts/pre-grep-guide.test.js +0 -1682
  22. package/claude-plugin/scripts/pre-read-guide.test.js +0 -363
  23. package/claude-plugin/scripts/project-detect.test.js +0 -95
  24. package/claude-plugin/scripts/recommendation-log.test.js +0 -79
  25. package/claude-plugin/scripts/session-init.test.js +0 -479
  26. package/claude-plugin/scripts/statusline-composite.test.js +0 -65
  27. package/claude-plugin/scripts/statusline.test.js +0 -235
  28. package/claude-plugin/scripts/tmp-dir.test.js +0 -50
  29. package/claude-plugin/scripts/user-prompt-context.test.js +0 -743
  30. package/claude-plugin/scripts/version-utils.test.js +0 -141
@@ -1,246 +0,0 @@
1
- 'use strict';
2
- const test = require('node:test');
3
- const assert = require('node:assert/strict');
4
- const fs = require('fs');
5
- const os = require('os');
6
- const path = require('path');
7
-
8
- const { globalNodeModulesCandidates, findPlatformBinary, BINARY_NAME,
9
- compareVersions, getPackageVersion, isCachedBinaryFresh,
10
- unsupportedPlatformHint } = require('./find-binary');
11
-
12
- function mkDir(t, prefix) {
13
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
14
- t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
15
- return dir;
16
- }
17
-
18
- test('globalNodeModulesCandidates includes derivation from process.execPath', () => {
19
- const candidates = globalNodeModulesCandidates();
20
- assert.ok(candidates.length > 0, 'at least one candidate path');
21
-
22
- const nodeBinDir = path.dirname(process.execPath);
23
- const expected = process.platform === 'win32'
24
- ? path.join(nodeBinDir, 'node_modules')
25
- : path.resolve(nodeBinDir, '..', 'lib', 'node_modules');
26
- assert.ok(candidates.includes(expected), `expected ${expected} in ${JSON.stringify(candidates)}`);
27
- });
28
-
29
- test('globalNodeModulesCandidates honors NPM_CONFIG_PREFIX', (t) => {
30
- const original = process.env.NPM_CONFIG_PREFIX;
31
- process.env.NPM_CONFIG_PREFIX = '/tmp/fake-npm-prefix';
32
- t.after(() => {
33
- if (original === undefined) delete process.env.NPM_CONFIG_PREFIX;
34
- else process.env.NPM_CONFIG_PREFIX = original;
35
- });
36
-
37
- const candidates = globalNodeModulesCandidates();
38
- const expected = process.platform === 'win32'
39
- ? path.join('/tmp/fake-npm-prefix', 'node_modules')
40
- : path.join('/tmp/fake-npm-prefix', 'lib', 'node_modules');
41
- assert.ok(candidates.includes(expected),
42
- `expected NPM_CONFIG_PREFIX-derived path in candidates: ${JSON.stringify(candidates)}`);
43
- });
44
-
45
- test('globalNodeModulesCandidates dedupes overlapping paths', (t) => {
46
- const original = process.env.NPM_CONFIG_PREFIX;
47
- // Force NPM_CONFIG_PREFIX to match the execPath-derived prefix
48
- const nodeBinDir = path.dirname(process.execPath);
49
- const matchedPrefix = process.platform === 'win32'
50
- ? nodeBinDir
51
- : path.resolve(nodeBinDir, '..');
52
- process.env.NPM_CONFIG_PREFIX = matchedPrefix;
53
- t.after(() => {
54
- if (original === undefined) delete process.env.NPM_CONFIG_PREFIX;
55
- else process.env.NPM_CONFIG_PREFIX = original;
56
- });
57
-
58
- const candidates = globalNodeModulesCandidates();
59
- const seen = new Set();
60
- for (const c of candidates) {
61
- assert.ok(!seen.has(c), `duplicate candidate: ${c}`);
62
- seen.add(c);
63
- }
64
- });
65
-
66
- test('findPlatformBinary locates platform pkg in NPM_CONFIG_PREFIX-derived global node_modules', (t) => {
67
- // Mirror what `npm install -g` produces for @sdsrs/code-graph-{platform}-{arch}.
68
- const fakePrefix = mkDir(t, 'find-binary-test-');
69
- const platDir = process.platform === 'win32'
70
- ? path.join(fakePrefix, 'node_modules', '@sdsrs', `code-graph-${process.platform}-${process.arch}`)
71
- : path.join(fakePrefix, 'lib', 'node_modules', '@sdsrs', `code-graph-${process.platform}-${process.arch}`);
72
- fs.mkdirSync(platDir, { recursive: true });
73
-
74
- // Copy node executable so realpathSync(candidate)'s basename === BINARY_NAME
75
- // (isNativeBinary check). Plain copy, not symlink, so basename matches.
76
- const fakeBinary = path.join(platDir, BINARY_NAME);
77
- fs.copyFileSync(process.execPath, fakeBinary);
78
- if (process.platform !== 'win32') fs.chmodSync(fakeBinary, 0o755);
79
-
80
- const original = process.env.NPM_CONFIG_PREFIX;
81
- process.env.NPM_CONFIG_PREFIX = fakePrefix;
82
- t.after(() => {
83
- if (original === undefined) delete process.env.NPM_CONFIG_PREFIX;
84
- else process.env.NPM_CONFIG_PREFIX = original;
85
- });
86
-
87
- const found = findPlatformBinary();
88
- assert.equal(found, fakeBinary, `expected ${fakeBinary}, got ${found}`);
89
- });
90
-
91
- test('findPlatformBinary returns null when no platform pkg installed anywhere reachable', (t) => {
92
- // Point NPM_CONFIG_PREFIX at an empty dir so global probe cannot match.
93
- const fakePrefix = mkDir(t, 'find-binary-empty-');
94
- const original = process.env.NPM_CONFIG_PREFIX;
95
- process.env.NPM_CONFIG_PREFIX = fakePrefix;
96
- t.after(() => {
97
- if (original === undefined) delete process.env.NPM_CONFIG_PREFIX;
98
- else process.env.NPM_CONFIG_PREFIX = original;
99
- });
100
-
101
- // Note: this test only proves the negative if no real install of the platform
102
- // package is reachable via require.resolve OR any other candidate path. On a
103
- // dev machine that has `@sdsrs/code-graph-linux-x64` installed globally, this
104
- // assertion will fail — that's not a defect of the helper but of test setup.
105
- // Skip if a real install is detected.
106
- const real = findPlatformBinary();
107
- if (real && !real.startsWith(fakePrefix)) {
108
- t.skip(`real platform pkg installed at ${real}, cannot test the null path here`);
109
- return;
110
- }
111
- assert.equal(real, null);
112
- });
113
-
114
- // ─── compareVersions (B fix: cache version invalidation helper) ───────────
115
-
116
- test('compareVersions: equal', () => {
117
- assert.equal(compareVersions('1.2.3', '1.2.3'), 0);
118
- });
119
-
120
- test('compareVersions: cache older than pkg', () => {
121
- // After `npm update` to 0.16.8, an auto-update cache from 0.16.7 must NOT
122
- // shadow the freshly-installed platform-pkg binary. Returns -1 here so
123
- // findBinaryUncached falls through to platform-pkg.
124
- assert.equal(compareVersions('0.16.7', '0.16.8'), -1);
125
- });
126
-
127
- test('compareVersions: cache newer than pkg', () => {
128
- // Auto-update may legitimately be ahead of npm pkg (cache fetched 0.17.0
129
- // before npm shipped it). Returns 1 → cache wins.
130
- assert.equal(compareVersions('0.17.0', '0.16.8'), 1);
131
- });
132
-
133
- test('compareVersions: minor and patch boundaries', () => {
134
- assert.equal(compareVersions('1.0.0', '0.999.999'), 1);
135
- assert.equal(compareVersions('1.10.0', '1.9.99'), 1); // numeric, not lexical
136
- assert.equal(compareVersions('1.0.10', '1.0.9'), 1);
137
- });
138
-
139
- test('compareVersions: tolerates non-numeric / short input', () => {
140
- // Non-numeric → treated as 0; shorter strings padded with 0.
141
- assert.equal(compareVersions('1.2', '1.2.0'), 0);
142
- assert.equal(compareVersions('foo', '0.0.0'), 0);
143
- });
144
-
145
- test('getPackageVersion reads root package.json', () => {
146
- const v = getPackageVersion();
147
- assert.match(v, /^\d+\.\d+\.\d+$/, `expected semver-ish, got: ${v}`);
148
- });
149
-
150
- // ─── isCachedBinaryFresh: disk cache version-check (mem #8454) ────────────
151
- //
152
- // Builds a fake binary that responds to `--version` with a controllable
153
- // string. process.execPath (node itself) won't do — we need a binary
154
- // whose --version line we control. Smallest approach: shell wrapper.
155
-
156
- function buildFakeBinary(t, versionLine) {
157
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cgmcp-fake-bin-'));
158
- t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
159
- const binPath = path.join(dir, BINARY_NAME);
160
- // readBinaryVersion parses "code-graph-mcp X.Y.Z" via the binary's first
161
- // stdout line on `--version`. Shell wrapper is simpler than compiling.
162
- const script = process.platform === 'win32'
163
- ? `@echo off\r\necho ${versionLine}\r\n`
164
- : `#!/bin/sh\necho '${versionLine}'\n`;
165
- fs.writeFileSync(binPath, script);
166
- if (process.platform !== 'win32') fs.chmodSync(binPath, 0o755);
167
- return binPath;
168
- }
169
-
170
- test('isCachedBinaryFresh: cache binary version >= pkg → fresh', (t) => {
171
- const bin = buildFakeBinary(t, 'code-graph-mcp 9.9.9');
172
- assert.equal(isCachedBinaryFresh(bin, '0.25.0'), true);
173
- });
174
-
175
- test('isCachedBinaryFresh: cache binary version equals pkg → fresh', (t) => {
176
- const bin = buildFakeBinary(t, 'code-graph-mcp 0.25.0');
177
- assert.equal(isCachedBinaryFresh(bin, '0.25.0'), true);
178
- });
179
-
180
- test('isCachedBinaryFresh: cache binary version < pkg → stale (THE BUG)', (t) => {
181
- // Reproduces mem #8454: cache pointed at bin/code-graph-mcp v0.5.28
182
- // while pkg was v0.25.0 → cache was returned silently with no
183
- // version-check, shadowing the installed 0.25.0 platform binary.
184
- // After this fix, returns false → caller clears cache + falls through.
185
- const bin = buildFakeBinary(t, 'code-graph-mcp 0.5.28');
186
- assert.equal(isCachedBinaryFresh(bin, '0.25.0'), false);
187
- });
188
-
189
- test('isCachedBinaryFresh: missing pkg version → permissive (trust cache)', (t) => {
190
- // Caller couldn't read package.json; refusing the cache would leave us
191
- // with nothing. Better to trust the one path we have.
192
- const bin = buildFakeBinary(t, 'code-graph-mcp 0.5.28');
193
- assert.equal(isCachedBinaryFresh(bin, null), true);
194
- assert.equal(isCachedBinaryFresh(bin, ''), true);
195
- });
196
-
197
- test('isCachedBinaryFresh: unreadable cache binary version → permissive', (t) => {
198
- // Old binary that doesn't support `--version`, or output we can't
199
- // parse. Same permissive path as missing pkg version.
200
- const bin = buildFakeBinary(t, 'whatever garbage no semver here');
201
- assert.equal(isCachedBinaryFresh(bin, '0.25.0'), true);
202
- });
203
-
204
- test('isCachedBinaryFresh: cache path does not exist → not fresh', () => {
205
- assert.equal(isCachedBinaryFresh('/nonexistent/path/code-graph-mcp', '0.25.0'), false);
206
- });
207
-
208
- test('isCachedBinaryFresh: empty/null cache path → not fresh', () => {
209
- assert.equal(isCachedBinaryFresh('', '0.25.0'), false);
210
- assert.equal(isCachedBinaryFresh(null, '0.25.0'), false);
211
- assert.equal(isCachedBinaryFresh(undefined, '0.25.0'), false);
212
- });
213
-
214
- test('isCachedBinaryFresh: file basename mismatch → not fresh', (t) => {
215
- // realpathSync.basename check inside isNativeBinary — wrong name = not ours.
216
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cgmcp-wrongname-'));
217
- t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
218
- const wrongName = path.join(dir, 'other-tool');
219
- fs.writeFileSync(wrongName, '#!/bin/sh\necho wrong\n');
220
- if (process.platform !== 'win32') fs.chmodSync(wrongName, 0o755);
221
- assert.equal(isCachedBinaryFresh(wrongName, '0.25.0'), false);
222
- });
223
-
224
- // ── unsupportedPlatformHint (actionable message for tails with no prebuilt binary) ──
225
-
226
- test('unsupportedPlatformHint flags Alpine/musl with a source/glibc-image hint', () => {
227
- const hint = unsupportedPlatformHint('linux', 'x64', 'musl');
228
- assert.ok(hint, 'musl should produce a hint');
229
- assert.match(hint, /musl|Alpine/);
230
- assert.match(hint, /cargo install/);
231
- });
232
-
233
- test('unsupportedPlatformHint flags native Windows-on-ARM with emulation/source hint', () => {
234
- const hint = unsupportedPlatformHint('win32', 'arm64', 'glibc');
235
- assert.ok(hint, 'win32-arm64 should produce a hint');
236
- assert.match(hint, /Windows on ARM|arm64/);
237
- assert.match(hint, /x64|cargo install/);
238
- });
239
-
240
- test('unsupportedPlatformHint returns null for supported platforms', () => {
241
- assert.equal(unsupportedPlatformHint('linux', 'x64', 'glibc'), null);
242
- assert.equal(unsupportedPlatformHint('linux', 'arm64', 'glibc'), null);
243
- assert.equal(unsupportedPlatformHint('darwin', 'arm64', 'glibc'), null);
244
- assert.equal(unsupportedPlatformHint('darwin', 'x64', 'glibc'), null);
245
- assert.equal(unsupportedPlatformHint('win32', 'x64', 'glibc'), null);
246
- });
@@ -1,117 +0,0 @@
1
- 'use strict';
2
- // Layer-A "does the hook really fire" smoke test (v0.67.0). Distinct from
3
- // hooks.test.js (which inspects registration STRINGS) and the per-hook unit
4
- // tests (which import predicates): this spawns each REGISTERED hook script the
5
- // way Claude Code would — node + a synthetic CC stdin payload — and asserts it
6
- // runs end-to-end without erroring. Catches the "registered but inert on this
7
- // machine" class (broken require-chain, node-version, corrupt install) that
8
- // string/predicate tests can't see. See feedback_pretooluse_dark_under_green_health.md.
9
- const test = require('node:test');
10
- const assert = require('node:assert/strict');
11
- const fs = require('fs');
12
- const os = require('os');
13
- const path = require('path');
14
- const { spawnSync } = require('child_process');
15
- const { verifyHooksFire } = require('./lifecycle');
16
- const { hookFireWarning, analyzeHookDark } = require('./session-init');
17
-
18
- test('verifyHooksFire: all real registered hooks run cleanly (exit 0)', () => {
19
- const { ok, results } = verifyHooksFire();
20
- // 3 PreToolUse + 2 PostToolUse (incremental-index + compound-grep inject) + 1 UserPromptSubmit = 6 settings.json hooks
21
- assert.ok(results.length >= 6, `expected >=6 hook probes, got ${results.length}`);
22
- for (const r of results) {
23
- assert.ok(r.ok, `hook ${r.label} (${r.script}) did not fire cleanly: code=${r.code} err=${r.error}`);
24
- }
25
- assert.equal(ok, true);
26
- });
27
-
28
- test('verifyHooksFire: the grep hook actually engages (emits a decision)', () => {
29
- const { results } = verifyHooksFire();
30
- const grep = results.find(r => /pre-grep-guide/.test(r.script));
31
- assert.ok(grep, 'no grep hook probe found');
32
- assert.ok(grep.emitted,
33
- 'pre-grep-guide produced no output on an engaging grep payload — the firing path did not engage');
34
- });
35
-
36
- test('verifyHooksFire: reports a broken hook script (teeth)', () => {
37
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-hookfire-teeth-'));
38
- const broken = path.join(dir, 'broken-hook.js');
39
- fs.writeFileSync(broken, 'throw new Error("boom at runtime");\n');
40
- try {
41
- const { ok, results } = verifyHooksFire({ hooks: [{ label: 'broken', script: broken, payload: {} }] });
42
- assert.equal(ok, false, 'a hook that throws must make ok=false');
43
- assert.equal(results[0].ok, false);
44
- } finally {
45
- fs.rmSync(dir, { recursive: true, force: true });
46
- }
47
- });
48
-
49
- test('verifyHooksFire: missing hook script is reported, not thrown (teeth)', () => {
50
- const { ok, results } = verifyHooksFire({
51
- hooks: [{ label: 'gone', script: path.join(os.tmpdir(), 'definitely-not-here-xyz.js'), payload: {} }],
52
- });
53
- assert.equal(ok, false);
54
- assert.equal(results[0].ok, false);
55
- });
56
-
57
- // ── Layer A surface: hookFireWarning (pure interpreter of cached state) ──
58
-
59
- test('hookFireWarning: ok / absent state → no warning', () => {
60
- assert.equal(hookFireWarning({ ok: true, failures: [] }), null);
61
- assert.equal(hookFireWarning(null), null);
62
- assert.equal(hookFireWarning({ ok: false, failures: [] }), null); // no names → nothing to say
63
- });
64
-
65
- test('hookFireWarning: failed state names the failed hook + points to doctor', () => {
66
- const w = hookFireWarning({ ok: false, failures: ['PreToolUse:Bash'] });
67
- assert.match(w, /PreToolUse:Bash/);
68
- assert.match(w, /doctor/);
69
- });
70
-
71
- // ── Layer B dispatch canary: analyzeHookDark (pure) ──
72
-
73
- test('analyzeHookDark: edit fires repeatedly but grep/read never → warns', () => {
74
- const lines = ['{"hook":"edit"}', '{"hook":"edit"}', '{"hook":"edit"}'].join('\n');
75
- assert.match(analyzeHookDark(lines), /grep\/read/);
76
- });
77
-
78
- test('analyzeHookDark: any grep/read event present → no warning', () => {
79
- const lines = ['{"hook":"edit"}', '{"hook":"edit"}', '{"hook":"edit"}', '{"hook":"read","action":"observe"}'].join('\n');
80
- assert.equal(analyzeHookDark(lines), null);
81
- });
82
-
83
- test('analyzeHookDark: below the edit threshold / empty → no warning (low false-positive)', () => {
84
- assert.equal(analyzeHookDark('{"hook":"edit"}\n{"hook":"edit"}'), null);
85
- assert.equal(analyzeHookDark(''), null);
86
- assert.equal(analyzeHookDark('garbage\n{not json}'), null);
87
- });
88
-
89
- // ── CLI wiring: `lifecycle.js verify-hooks-fire` writes the state file ──
90
-
91
- test('CLI verify-hooks-fire runs and writes hook-fire-state.json (HOME-redirected)', () => {
92
- const home = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-hf-home-'));
93
- try {
94
- const r = spawnSync(process.execPath, [path.join(__dirname, 'lifecycle.js'), 'verify-hooks-fire'], {
95
- env: { ...process.env, HOME: home }, encoding: 'utf8', timeout: 30000,
96
- });
97
- assert.equal(r.status, 0, `CLI exit ${r.status}: ${r.stderr}`);
98
- assert.match(r.stdout, /Hook firing: (OK|FAIL)/);
99
- const statePath = path.join(home, '.cache', 'code-graph', 'hook-fire-state.json');
100
- assert.ok(fs.existsSync(statePath), 'hook-fire-state.json was not written');
101
- const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
102
- assert.equal(typeof state.ok, 'boolean');
103
- assert.ok(state.ts, 'state missing timestamp');
104
- } finally {
105
- fs.rmSync(home, { recursive: true, force: true });
106
- }
107
- });
108
-
109
- // ── doctor wiring: runDiagnostics surfaces a "Hook firing" check ──
110
-
111
- test('doctor runDiagnostics includes a Hook firing check', () => {
112
- const { runDiagnostics } = require('./doctor');
113
- const results = runDiagnostics();
114
- const hf = results.find(r => r.name === 'Hook firing');
115
- assert.ok(hf, 'doctor did not report a "Hook firing" check');
116
- assert.ok(['ok', 'warn'].includes(hf.status), `unexpected status ${hf.status}`);
117
- });
@@ -1,230 +0,0 @@
1
- 'use strict';
2
- const test = require('node:test');
3
- const assert = require('node:assert/strict');
4
- const fs = require('fs');
5
- const path = require('path');
6
- const { execFileSync } = require('child_process');
7
-
8
- // Regression gate for v0.31.1: hooks.json matchers must be Claude Code's
9
- // literal/regex form, NOT the expression DSL `tool == "X"`. The earlier
10
- // matchers parsed as regex against tool names, never matched anything,
11
- // and left every PreToolUse hook silently inert from v0.25.0 through
12
- // v0.31.0. The bug was invisible to the existing unit tests because they
13
- // spawn the hook scripts directly via stdin, bypassing Claude Code's
14
- // matcher dispatch.
15
-
16
- const HOOKS_JSON = path.resolve(__dirname, '..', 'hooks', 'hooks.json');
17
-
18
- function loadHooks() {
19
- const raw = fs.readFileSync(HOOKS_JSON, 'utf8');
20
- return JSON.parse(raw);
21
- }
22
-
23
- function* iterMatchers(hooksByEvent) {
24
- for (const [event, entries] of Object.entries(hooksByEvent || {})) {
25
- if (!Array.isArray(entries)) continue;
26
- for (let i = 0; i < entries.length; i++) {
27
- const e = entries[i];
28
- yield { event, idx: i, matcher: e && e.matcher };
29
- }
30
- }
31
- }
32
-
33
- test('hooks.json: file parses as JSON', () => {
34
- assert.doesNotThrow(loadHooks);
35
- });
36
-
37
- test('hooks.json: every entry has a string matcher', () => {
38
- const cfg = loadHooks();
39
- let count = 0;
40
- for (const { event, idx, matcher } of iterMatchers(cfg.hooks)) {
41
- assert.equal(typeof matcher, 'string',
42
- `hooks.${event}[${idx}].matcher should be a string, got ${typeof matcher}`);
43
- count++;
44
- }
45
- assert.ok(count > 0, 'expected at least one matcher in hooks.json');
46
- });
47
-
48
- // The actual regression gate. Each banned token reflects a specific
49
- // failure mode we hit and want to keep out forever.
50
- const BANNED_TOKENS = [
51
- // The original v0.25.0 → v0.31.0 bug: expression-style matcher treated
52
- // as regex against tool name → never matched.
53
- { token: '==', why: 'expression DSL (e.g. `tool == "Edit"`) is not supported; use literal tool name' },
54
- // `tool ==` or `tool name == "X"` — same family, different spelling.
55
- { token: 'tool ', why: 'expression DSL with `tool` variable is not supported' },
56
- // Boolean ORs as expression operators (regex uses `|`, not `||`).
57
- { token: '||', why: 'use `|` for pipe-list (e.g. `Write|Edit`), not `||`' },
58
- // Boolean AND has no meaning in tool-name matching.
59
- { token: '&&', why: '`&&` has no meaning in matchers' },
60
- // Double-quotes inside the matcher are a strong hint of expression DSL
61
- // (the broken syntax was `"tool == \"Edit\""`).
62
- { token: '"', why: 'literal double-quote in matcher is almost always a copy-paste of expression DSL' },
63
- ];
64
-
65
- test('hooks.json: matchers avoid banned expression-DSL tokens', () => {
66
- const cfg = loadHooks();
67
- const offenders = [];
68
- for (const { event, idx, matcher } of iterMatchers(cfg.hooks)) {
69
- for (const { token, why } of BANNED_TOKENS) {
70
- if (matcher.includes(token)) {
71
- offenders.push(`hooks.${event}[${idx}].matcher = ${JSON.stringify(matcher)} — contains banned ${JSON.stringify(token)} (${why})`);
72
- }
73
- }
74
- }
75
- assert.deepEqual(offenders, [],
76
- 'hooks.json matcher syntax regression — see v0.31.1 CHANGELOG:\n ' + offenders.join('\n '));
77
- });
78
-
79
- // v0.32.0 architecture: plugin-cache hooks.json ONLY carries SessionStart.
80
- // PreToolUse / PostToolUse / UserPromptSubmit are registered into
81
- // ~/.claude/settings.json by lifecycle.js (current Claude Code silently
82
- // ignores plugin-cache hooks.json entries for those events — confirmed
83
- // 2026-05-24 via session jsonl, see feedback_pretooluse_dark_under_green_health.md).
84
- test('hooks.json: contains SessionStart only (v0.32.0)', () => {
85
- const cfg = loadHooks();
86
- assert.deepEqual(Object.keys(cfg.hooks || {}), ['SessionStart'],
87
- 'plugin-cache hooks.json must contain only SessionStart; other events go via settings.json. ' +
88
- 'Adding entries here for PreToolUse/PostToolUse/UserPromptSubmit would be dead config — CC does not load them.');
89
- });
90
-
91
- test('hooks.json: SessionStart wires session-init.js', () => {
92
- const cfg = loadHooks();
93
- const entries = (cfg.hooks && cfg.hooks.SessionStart) || [];
94
- assert.ok(entries.length > 0, 'SessionStart entry missing');
95
- const cmd = entries[0].hooks && entries[0].hooks[0] && entries[0].hooks[0].command;
96
- assert.match(cmd || '', /session-init\.js/);
97
- });
98
-
99
- // Cross-validate that lifecycle.js's buildSettingsHookEntries covers the
100
- // matchers we removed from hooks.json — keeps the migration whole. If a
101
- // future refactor accidentally drops a matcher in one place, this fails.
102
- test('lifecycle.buildSettingsHookEntries covers PreToolUse Edit/Bash/Read', () => {
103
- const { buildSettingsHookEntries } = require('./lifecycle');
104
- const desired = buildSettingsHookEntries();
105
- const ptu = (desired.PreToolUse || []).map(e => e.matcher);
106
- for (const tool of ['Edit', 'Bash', 'Read']) {
107
- assert.ok(ptu.includes(tool), `lifecycle.js PreToolUse missing matcher: ${tool}; got ${JSON.stringify(ptu)}`);
108
- }
109
- });
110
-
111
- test('lifecycle.buildSettingsHookEntries covers PostToolUse Write|Edit + UserPromptSubmit', () => {
112
- const { buildSettingsHookEntries } = require('./lifecycle');
113
- const desired = buildSettingsHookEntries();
114
- const postMatchers = (desired.PostToolUse || []).map(e => e.matcher);
115
- assert.ok(postMatchers.some(m => m === 'Write|Edit'),
116
- `PostToolUse must have 'Write|Edit' matcher; got ${JSON.stringify(postMatchers)}`);
117
- const upsMatchers = (desired.UserPromptSubmit || []).map(e => e.matcher);
118
- assert.ok(upsMatchers.length > 0, 'UserPromptSubmit must have at least one matcher');
119
- });
120
-
121
- test('lifecycle.buildSettingsHookEntries: every entry carries description marker', () => {
122
- // Description marker is the primary cleanup discriminator (immune to
123
- // path/env pollution per feedback_plugin_env_isolation.md). If an entry
124
- // lacks a description, isOurHookEntry falls back to path-fragment match
125
- // which is less reliable. Force every entry to have one.
126
- const { buildSettingsHookEntries } = require('./lifecycle');
127
- const desired = buildSettingsHookEntries();
128
- for (const [event, entries] of Object.entries(desired)) {
129
- for (let i = 0; i < entries.length; i++) {
130
- assert.ok(entries[i].description && entries[i].description.includes('[code-graph-mcp'),
131
- `${event}[${i}] missing or malformed description marker`);
132
- }
133
- }
134
- });
135
-
136
- test('lifecycle.buildSettingsHookEntries: hook commands use absolute paths (no env vars)', () => {
137
- // settings.json hook commands run with env pollution risk
138
- // (feedback_plugin_env_isolation.md). Paths MUST be absolute, derived
139
- // from __dirname, never from ${CLAUDE_PLUGIN_ROOT}.
140
- const { buildSettingsHookEntries } = require('./lifecycle');
141
- const desired = buildSettingsHookEntries();
142
- for (const entries of Object.values(desired)) {
143
- for (const e of entries) {
144
- for (const h of e.hooks) {
145
- assert.ok(!h.command.includes('${CLAUDE_PLUGIN_ROOT}'),
146
- `command must not use \${CLAUDE_PLUGIN_ROOT}: ${h.command}`);
147
- assert.ok(h.command.startsWith('node "/') || h.command.match(/node "[A-Z]:\\/),
148
- `command path must be absolute: ${h.command}`);
149
- }
150
- }
151
- }
152
- });
153
-
154
- // v0.67.0 hook-reliability Layer 1 (static firing invariants):
155
- // The tests above inspect matcher STRINGS but never the target script file. A
156
- // renamed/typo'd/moved hook script makes Claude Code unable to run it → the hook
157
- // is SILENTLY inert (the "dark hook" class — feedback_pretooluse_dark_under_green_health.md).
158
- // This collects every script CC will actually load — both registration channels —
159
- // and asserts each exists and parses. Cheapest possible guard against silent dark.
160
- const PLUGIN_ROOT = path.resolve(__dirname, '..'); // claude-plugin/
161
-
162
- function resolveHookScript(cmd) {
163
- // command form: node "<path>" (<path> may contain ${CLAUDE_PLUGIN_ROOT})
164
- const m = (cmd || '').match(/"([^"]+\.js)"/);
165
- return m ? m[1].replace('${CLAUDE_PLUGIN_ROOT}', PLUGIN_ROOT) : null;
166
- }
167
-
168
- function allRegisteredHookCommands() {
169
- const commands = [];
170
- // (1) settings.json side — lifecycle.buildSettingsHookEntries (PreToolUse/PostToolUse/UserPromptSubmit)
171
- const { buildSettingsHookEntries } = require('./lifecycle');
172
- for (const entries of Object.values(buildSettingsHookEntries())) {
173
- for (const e of entries) for (const h of e.hooks || []) commands.push(h.command);
174
- }
175
- // (2) plugin-cache hooks.json side — SessionStart (the only event CC loads from here)
176
- for (const entries of Object.values(loadHooks().hooks || {})) {
177
- if (!Array.isArray(entries)) continue;
178
- for (const e of entries) for (const h of e.hooks || []) commands.push(h.command);
179
- }
180
- return commands;
181
- }
182
-
183
- test('every registered hook script exists on disk', () => {
184
- const commands = allRegisteredHookCommands();
185
- // 3 PreToolUse + 2 PostToolUse (incremental-index + compound-grep inject) + 1 UserPromptSubmit + 1 SessionStart = 7
186
- assert.ok(commands.length >= 7, `expected >=7 registered hook commands, got ${commands.length}`);
187
- for (const cmd of commands) {
188
- const p = resolveHookScript(cmd);
189
- assert.ok(p, `could not extract a .js path from hook command: ${JSON.stringify(cmd)}`);
190
- assert.ok(fs.existsSync(p),
191
- `hook script missing on disk: ${p}\n (from command ${JSON.stringify(cmd)})\n` +
192
- ` A renamed/typo'd/moved script makes the hook silently inert — Claude Code cannot run a missing file.`);
193
- }
194
- });
195
-
196
- test('every registered hook script parses (node --check)', () => {
197
- for (const cmd of allRegisteredHookCommands()) {
198
- const p = resolveHookScript(cmd);
199
- assert.doesNotThrow(
200
- () => execFileSync(process.execPath, ['--check', p], { stdio: 'pipe' }),
201
- `hook script has a syntax error (node --check failed): ${p}`);
202
- }
203
- });
204
-
205
- // Pin the EXACT matcher surface, not just "covers". The earlier tests assert the
206
- // set INCLUDES Edit/Bash/Read etc.; this asserts it EQUALS the intended set, so
207
- // adding/dropping a matcher must update this test — a deliberate decision, never a
208
- // silent coverage drift. A PreToolUse hook fires only on the literal tool name.
209
- // Deliberate exclusions (verified 2026-06-23; revisit if either premise changes):
210
- // - MultiEdit: NOT a tool in current Claude Code (absent from the tool surface;
211
- // the plugin targets recent CC per the v0.32.0 settings.json architecture), so
212
- // a matcher for it would be dead config. Re-add only if CC (re)introduces it.
213
- // - NotebookEdit: a real tool, but code-graph does NOT parse .ipynb (no jupyter
214
- // support in the parser / supported-language set), so both pre-edit-guide
215
- // (needs graph symbols) and incremental-index (needs to re-index the file)
216
- // would no-op on a notebook. Prerequisite is .ipynb PARSING support (a parser
217
- // feature); add the matcher as PART of that work, never before it.
218
- test('buildSettingsHookEntries: matcher surface is exactly the intended set', () => {
219
- const { buildSettingsHookEntries } = require('./lifecycle');
220
- const desired = buildSettingsHookEntries();
221
- const setOf = (event) => (desired[event] || []).map(e => e.matcher).sort();
222
- assert.deepEqual(setOf('PreToolUse'), ['Bash', 'Edit', 'Read'],
223
- 'PreToolUse matcher set changed — update this gate intentionally (does the new tool need a guide hook?)');
224
- assert.deepEqual(setOf('PostToolUse'), ['Bash', 'Write|Edit'],
225
- 'PostToolUse matcher set changed — incremental-index (Write|Edit) + compound-grep inject (Bash) trigger surface must be deliberate');
226
- assert.deepEqual(setOf('UserPromptSubmit'), [''],
227
- 'UserPromptSubmit matcher set changed unexpectedly');
228
- assert.deepEqual(Object.keys(desired).sort(), ['PostToolUse', 'PreToolUse', 'UserPromptSubmit'],
229
- 'a new top-level hook event is registered into settings.json — confirm it is intended (SessionStart belongs in hooks.json)');
230
- });