@sdsrs/code-graph 0.94.0 → 0.95.0

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,363 +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
- const crypto = require('crypto');
8
-
9
- const {
10
- isSourceFile, dirOf, recordRead, shouldHint, markHint,
11
- buildHint, buildHintWithAnswer, isSilenced, isAnswerDisabled,
12
- trackReadAndMaybeHint,
13
- FANOUT_THRESHOLD, COOLDOWN_MS, STATE_TTL_MS,
14
- loadState, saveState, statePath,
15
- } = require('./pre-read-guide');
16
-
17
- // ── isSourceFile ────────────────────────────────────────────────────
18
-
19
- test('isSourceFile: .rs is source', () => {
20
- assert.equal(isSourceFile('src/main.rs'), true);
21
- });
22
-
23
- test('isSourceFile: .py is source', () => {
24
- assert.equal(isSourceFile('backend/app/services/foo.py'), true);
25
- });
26
-
27
- test('isSourceFile: .ts and .tsx are source', () => {
28
- assert.equal(isSourceFile('src/index.ts'), true);
29
- assert.equal(isSourceFile('src/App.tsx'), true);
30
- });
31
-
32
- test('isSourceFile: .js .jsx .mjs .cjs are source', () => {
33
- assert.equal(isSourceFile('lib/a.js'), true);
34
- assert.equal(isSourceFile('lib/b.jsx'), true);
35
- assert.equal(isSourceFile('lib/c.mjs'), true);
36
- assert.equal(isSourceFile('lib/d.cjs'), true);
37
- });
38
-
39
- test('isSourceFile: .go .java .kt .rb .php .cs are source', () => {
40
- for (const ext of ['go', 'java', 'kt', 'rb', 'php', 'cs']) {
41
- assert.equal(isSourceFile('app/x.' + ext), true, ext + ' should be source');
42
- }
43
- });
44
-
45
- test('isSourceFile: .md is NOT source', () => {
46
- assert.equal(isSourceFile('CHANGELOG.md'), false);
47
- });
48
-
49
- test('isSourceFile: .json is NOT source', () => {
50
- assert.equal(isSourceFile('package.json'), false);
51
- });
52
-
53
- test('isSourceFile: .toml .lock .yml are NOT source', () => {
54
- assert.equal(isSourceFile('Cargo.toml'), false);
55
- assert.equal(isSourceFile('package-lock.json'), false);
56
- assert.equal(isSourceFile('.github/workflows/ci.yml'), false);
57
- });
58
-
59
- test('isSourceFile: .log is NOT source', () => {
60
- assert.equal(isSourceFile('logs/app.log'), false);
61
- });
62
-
63
- test('isSourceFile: empty / non-string returns false', () => {
64
- assert.equal(isSourceFile(''), false);
65
- assert.equal(isSourceFile(null), false);
66
- assert.equal(isSourceFile(undefined), false);
67
- assert.equal(isSourceFile(42), false);
68
- });
69
-
70
- test('isSourceFile: extensionless file returns false', () => {
71
- assert.equal(isSourceFile('Makefile'), false);
72
- });
73
-
74
- // ── dirOf ───────────────────────────────────────────────────────────
75
-
76
- test('dirOf: relative path returns parent dir', () => {
77
- assert.equal(dirOf('src/storage/queries.rs'), 'src/storage');
78
- });
79
-
80
- test('dirOf: top-level file returns "."', () => {
81
- assert.equal(dirOf('main.rs'), '.');
82
- });
83
-
84
- test('dirOf: empty / non-string returns ""', () => {
85
- assert.equal(dirOf(''), '');
86
- assert.equal(dirOf(null), '');
87
- });
88
-
89
- // ── recordRead + shouldHint ─────────────────────────────────────────
90
-
91
- test('shouldHint: first read does NOT hint', () => {
92
- const s = { by_dir: {} };
93
- recordRead(s, 'src/foo', 1000);
94
- assert.equal(shouldHint(s, 'src/foo', 1000), false);
95
- });
96
-
97
- test('shouldHint: 4 reads do NOT hint (threshold = 5)', () => {
98
- const s = { by_dir: {} };
99
- for (let i = 0; i < 4; i++) recordRead(s, 'src/foo', 1000 + i);
100
- assert.equal(shouldHint(s, 'src/foo', 1004), false);
101
- });
102
-
103
- test('shouldHint: 5th read DOES hint', () => {
104
- const s = { by_dir: {} };
105
- for (let i = 0; i < 5; i++) recordRead(s, 'src/foo', 1000 + i);
106
- assert.equal(shouldHint(s, 'src/foo', 1004), true);
107
- });
108
-
109
- test('shouldHint: cooldown suppresses re-fire', () => {
110
- const s = { by_dir: {} };
111
- for (let i = 0; i < 6; i++) recordRead(s, 'src/foo', 1000 + i);
112
- markHint(s, 'src/foo', 1005);
113
- // 1 sec later — still in cooldown
114
- recordRead(s, 'src/foo', 1005 + 1000);
115
- assert.equal(shouldHint(s, 'src/foo', 1005 + 1000), false);
116
- });
117
-
118
- test('shouldHint: past cooldown re-fires', () => {
119
- const s = { by_dir: {} };
120
- for (let i = 0; i < 5; i++) recordRead(s, 'src/foo', 1000 + i);
121
- markHint(s, 'src/foo', 1005);
122
- // COOLDOWN_MS + 1 later, plus one more read
123
- const after = 1005 + COOLDOWN_MS + 1;
124
- recordRead(s, 'src/foo', after);
125
- assert.equal(shouldHint(s, 'src/foo', after), true);
126
- });
127
-
128
- test('shouldHint: different dirs tracked independently', () => {
129
- const s = { by_dir: {} };
130
- for (let i = 0; i < 5; i++) recordRead(s, 'src/foo', 1000 + i);
131
- for (let i = 0; i < 2; i++) recordRead(s, 'src/bar', 2000 + i);
132
- assert.equal(shouldHint(s, 'src/foo', 1005), true);
133
- assert.equal(shouldHint(s, 'src/bar', 2002), false);
134
- });
135
-
136
- test('shouldHint: unknown dir returns false', () => {
137
- const s = { by_dir: {} };
138
- assert.equal(shouldHint(s, 'src/unseen', 1000), false);
139
- });
140
-
141
- test('shouldHint: empty dir returns false', () => {
142
- const s = { by_dir: {} };
143
- assert.equal(shouldHint(s, '', 1000), false);
144
- });
145
-
146
- // ── buildHint ───────────────────────────────────────────────────────
147
-
148
- test('buildHint: contains the directory + module_overview tool', () => {
149
- const out = buildHint('src/storage');
150
- assert.match(out, /src\/storage/);
151
- assert.match(out, /module_overview|overview/);
152
- });
153
-
154
- test('buildHint: stays under 300 bytes (single-line budget)', () => {
155
- assert.ok(buildHint('src/storage').length < 300,
156
- `hint length ${buildHint('src/storage').length} exceeds budget`);
157
- });
158
-
159
- test('buildHint: starts with [code-graph]', () => {
160
- assert.match(buildHint('any/dir'), /^\[code-graph\]/);
161
- });
162
-
163
- test('buildHint: single line (no embedded newlines)', () => {
164
- const out = buildHint('src/foo');
165
- // Trailing newline is added by the caller; the function itself should not embed any.
166
- assert.equal(out.indexOf('\n'), -1, `hint contains newline: ${JSON.stringify(out)}`);
167
- });
168
-
169
- // ── isSilenced ──────────────────────────────────────────────────────
170
-
171
- test('isSilenced: default (no env) → not silenced', () => {
172
- assert.equal(isSilenced({}), false);
173
- });
174
-
175
- test('isSilenced: CODE_GRAPH_QUIET_HOOKS=1 → silenced', () => {
176
- assert.equal(isSilenced({ CODE_GRAPH_QUIET_HOOKS: '1' }), true);
177
- });
178
-
179
- test('isSilenced: CODE_GRAPH_QUIET_HOOKS=0 → not silenced', () => {
180
- assert.equal(isSilenced({ CODE_GRAPH_QUIET_HOOKS: '0' }), false);
181
- });
182
-
183
- // ── State load / save / TTL pruning ─────────────────────────────────
184
-
185
- function tmpCwd() {
186
- // Synthesize a unique cwd path so different test runs don't share state.
187
- const id = crypto.randomBytes(8).toString('hex');
188
- return `/nonexistent-test-cwd-${id}`;
189
- }
190
-
191
- test('loadState: missing file returns empty state', () => {
192
- const cwd = tmpCwd();
193
- const s = loadState(cwd);
194
- assert.deepEqual(s, { by_dir: {} });
195
- });
196
-
197
- test('loadState + saveState: round-trip preserves by_dir', () => {
198
- const cwd = tmpCwd();
199
- const s1 = { by_dir: { 'src/foo': { reads: 3, last_read_at: 1000, last_hint_at: 0 } } };
200
- saveState(cwd, s1);
201
- const s2 = loadState(cwd, 1000);
202
- assert.equal(s2.by_dir['src/foo'].reads, 3);
203
- // Cleanup
204
- try { fs.unlinkSync(statePath(cwd)); } catch { /* ok */ }
205
- });
206
-
207
- test('loadState: entries older than STATE_TTL_MS are pruned', () => {
208
- const cwd = tmpCwd();
209
- const old = { by_dir: {
210
- 'src/fresh': { reads: 2, last_read_at: 10_000, last_hint_at: 0 },
211
- 'src/stale': { reads: 9, last_read_at: 0, last_hint_at: 0 },
212
- }};
213
- saveState(cwd, old);
214
- const now = STATE_TTL_MS + 100; // way past TTL for the stale entry
215
- const loaded = loadState(cwd, now);
216
- assert.ok(loaded.by_dir['src/fresh'], 'fresh entry kept');
217
- assert.equal(loaded.by_dir['src/stale'], undefined, 'stale entry pruned');
218
- try { fs.unlinkSync(statePath(cwd)); } catch { /* ok */ }
219
- });
220
-
221
- test('loadState: malformed JSON returns empty state', () => {
222
- const cwd = tmpCwd();
223
- const p = statePath(cwd);
224
- fs.writeFileSync(p, 'not json {{{', 'utf8');
225
- const s = loadState(cwd);
226
- assert.deepEqual(s, { by_dir: {} });
227
- try { fs.unlinkSync(p); } catch { /* ok */ }
228
- });
229
-
230
- // ── Integrated flow ─────────────────────────────────────────────────
231
-
232
- test('flow: 5 reads to same dir → hint, 6th read same dir → no hint (cooldown)', () => {
233
- const s = { by_dir: {} };
234
- // Reads 1-4: no hint
235
- for (let i = 0; i < 4; i++) {
236
- recordRead(s, 'src/foo', 1000 + i);
237
- assert.equal(shouldHint(s, 'src/foo', 1000 + i), false, `read ${i+1} should not hint`);
238
- }
239
- // Read 5: hint
240
- recordRead(s, 'src/foo', 1004);
241
- assert.equal(shouldHint(s, 'src/foo', 1004), true);
242
- markHint(s, 'src/foo', 1004);
243
- // Read 6 within cooldown: no hint
244
- recordRead(s, 'src/foo', 1005);
245
- assert.equal(shouldHint(s, 'src/foo', 1005), false);
246
- });
247
-
248
- // ── v0.49: answer-in-hint + shared tracking core ─────────────────────
249
-
250
- test('buildHintWithAnswer: embeds overview text and truncation pointer', () => {
251
- const out = buildHintWithAnswer('src/storage', { text: 'Module src/storage\n conn (57 callers)', truncated: true });
252
- assert.match(out, /^\[code-graph\] 5\+ Reads into src\/storage\//);
253
- assert.match(out, /conn \(57 callers\)/);
254
- assert.match(out, /truncated — `code-graph-mcp overview src\/storage\/`/);
255
- });
256
-
257
- test('isAnswerDisabled: CODE_GRAPH_NO_ANSWER_IN_DENY=1 → advice-only hints', () => {
258
- assert.equal(isAnswerDisabled({ CODE_GRAPH_NO_ANSWER_IN_DENY: '1' }), true);
259
- assert.equal(isAnswerDisabled({}), false);
260
- });
261
-
262
- test('trackReadAndMaybeHint: fires on 5th read with stubbed overview answer', () => {
263
- const root = fs.mkdtempSync(path.join(os.tmpdir(), 'readfan-track-'));
264
- // Stub CLI: prints a fake overview (hook resolves binary via _CG_ANSWER_BINARY).
265
- const stub = path.join(root, 'stub.js');
266
- fs.writeFileSync(stub, '#!/usr/bin/env node\nprocess.stdout.write("Module overview stub: 3 symbols\\n");');
267
- fs.chmodSync(stub, 0o755);
268
- const oldEnv = process.env._CG_ANSWER_BINARY;
269
- process.env._CG_ANSWER_BINARY = stub;
270
- // .code-graph present so recordRecommendation appends.
271
- fs.mkdirSync(path.join(root, '.code-graph'), { recursive: true });
272
-
273
- const written = [];
274
- const origWrite = process.stdout.write.bind(process.stdout);
275
- process.stdout.write = (chunk) => { written.push(String(chunk)); return true; };
276
- try {
277
- let fired = false;
278
- for (let i = 0; i < 5; i++) {
279
- fired = trackReadAndMaybeHint(root, 'src/storage/file' + i + '.rs');
280
- }
281
- assert.equal(fired, true, '5th same-dir read must fire');
282
- // Compound-grep sibling sweep: the fanout hint is now emitted as a
283
- // PreToolUse allow+additionalContext envelope (was bare stdout, which CC
284
- // routes to the debug log only and never shows the model). The overview
285
- // answer must ride inside additionalContext.
286
- const emitted = JSON.parse(written.join(''));
287
- assert.equal(emitted.hookSpecificOutput.hookEventName, 'PreToolUse');
288
- assert.equal(emitted.hookSpecificOutput.permissionDecision, 'allow');
289
- assert.match(emitted.hookSpecificOutput.additionalContext, /Module overview stub/,
290
- 'hint must EMBED the overview answer in additionalContext');
291
- const recs = fs.readFileSync(path.join(root, '.code-graph', 'recommendations.jsonl'), 'utf8');
292
- assert.match(recs, /"hook":"read"/);
293
- assert.match(recs, /"answered":true/);
294
- } finally {
295
- process.stdout.write = origWrite;
296
- if (oldEnv === undefined) delete process.env._CG_ANSWER_BINARY;
297
- else process.env._CG_ANSWER_BINARY = oldEnv;
298
- fs.rmSync(root, { recursive: true, force: true });
299
- }
300
- });
301
-
302
- test('trackReadAndMaybeHint: missing binary → hint records reason:no-binary (delivered-overview dark, sibling of pre-grep)', () => {
303
- // Sibling-hook parity: when the binary can't be found the read-fanout hint
304
- // falls back to bare advice. That must be distinguishable in the funnel from a
305
- // runtime failure, exactly like pre-grep's deny. Force findBinary() null
306
- // in-process and unset _CG_ANSWER_BINARY so the resolution actually runs.
307
- const findBinaryMod = require('./find-binary');
308
- const realFindBinary = findBinaryMod.findBinary;
309
- const root = fs.mkdtempSync(path.join(os.tmpdir(), 'readfan-nobin-'));
310
- fs.mkdirSync(path.join(root, '.code-graph'), { recursive: true });
311
- const oldEnv = process.env._CG_ANSWER_BINARY;
312
- delete process.env._CG_ANSWER_BINARY;
313
- findBinaryMod.findBinary = () => null;
314
- const origWrite = process.stdout.write.bind(process.stdout);
315
- process.stdout.write = () => true;
316
- try {
317
- let fired = false;
318
- for (let i = 0; i < 5; i++) {
319
- fired = trackReadAndMaybeHint(root, 'src/storage/file' + i + '.rs');
320
- }
321
- assert.equal(fired, true, '5th same-dir read must still fire the hint');
322
- const recs = fs.readFileSync(path.join(root, '.code-graph', 'recommendations.jsonl'), 'utf8');
323
- const last = JSON.parse(recs.trim().split('\n').pop());
324
- assert.equal(last.action, 'hint');
325
- assert.equal(last.answered, false);
326
- assert.equal(last.reason, 'no-binary',
327
- 'a dark delivered-overview hint must be distinguishable from a runtime failure');
328
- } finally {
329
- process.stdout.write = origWrite;
330
- findBinaryMod.findBinary = realFindBinary;
331
- if (oldEnv === undefined) delete process.env._CG_ANSWER_BINARY;
332
- else process.env._CG_ANSWER_BINARY = oldEnv;
333
- fs.rmSync(root, { recursive: true, force: true });
334
- }
335
- });
336
-
337
- test('trackReadAndMaybeHint: non-fanout source read records an observe event', () => {
338
- const root = fs.mkdtempSync(path.join(os.tmpdir(), 'readfan-observe-'));
339
- fs.mkdirSync(path.join(root, '.code-graph'), { recursive: true });
340
- try {
341
- // A single subdir source read is below the fanout threshold → no hint, but
342
- // it must still record an `observe` event for the search-decay metric.
343
- const fired = trackReadAndMaybeHint(root, 'src/storage/db.rs');
344
- assert.equal(fired, false, 'single read must not fire the fanout hint');
345
- const recs = fs.readFileSync(path.join(root, '.code-graph', 'recommendations.jsonl'), 'utf8');
346
- const last = JSON.parse(recs.trim().split('\n').pop());
347
- assert.equal(last.hook, 'read');
348
- assert.equal(last.action, 'observe');
349
- } finally {
350
- fs.rmSync(root, { recursive: true, force: true });
351
- }
352
- });
353
-
354
- test('trackReadAndMaybeHint: top-level and outside-root paths never fire', () => {
355
- const root = fs.mkdtempSync(path.join(os.tmpdir(), 'readfan-skip-'));
356
- try {
357
- assert.equal(trackReadAndMaybeHint(root, 'main.rs'), false);
358
- assert.equal(trackReadAndMaybeHint(root, '../other/file.rs'), false);
359
- assert.equal(trackReadAndMaybeHint(root, '/abs/file.rs'), false);
360
- } finally {
361
- fs.rmSync(root, { recursive: true, force: true });
362
- }
363
- });
@@ -1,95 +0,0 @@
1
- 'use strict';
2
- // Tests for project-detect.js — the activation gate shared by mcp-launcher.js,
3
- // session-init.js, and adopt.js. Run: node --test claude-plugin/scripts/project-detect.test.js
4
- const test = require('node:test');
5
- const assert = require('node:assert/strict');
6
- const fs = require('fs');
7
- const path = require('path');
8
- const os = require('os');
9
-
10
- const { PROJECT_MARKERS, isProjectRoot, findProjectRoot, isNonProjectCwd } = require('./project-detect');
11
-
12
- function mkTmp(t) {
13
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-pd-'));
14
- t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
15
- return dir;
16
- }
17
-
18
- test('isNonProjectCwd: bare tmp dir (no markers) → non-project', (t) => {
19
- const dir = mkTmp(t);
20
- assert.equal(isNonProjectCwd(dir), true);
21
- });
22
-
23
- test('isNonProjectCwd: /tmp root (the mem-lite headless cwd) → non-project', () => {
24
- // claude-mem-lite spawns `claude -p` with cwd=/tmp; /tmp has no project marker.
25
- assert.equal(isNonProjectCwd('/tmp'), true);
26
- });
27
-
28
- test('isNonProjectCwd: cwd with .git → project (false)', (t) => {
29
- const dir = mkTmp(t);
30
- fs.mkdirSync(path.join(dir, '.git'));
31
- assert.equal(isNonProjectCwd(dir), false);
32
- });
33
-
34
- test('isNonProjectCwd: cwd with package.json → project (false)', (t) => {
35
- const dir = mkTmp(t);
36
- fs.writeFileSync(path.join(dir, 'package.json'), '{}');
37
- assert.equal(isNonProjectCwd(dir), false);
38
- });
39
-
40
- test('isNonProjectCwd: a real git repo under /tmp is still a project (marker wins over location)', (t) => {
41
- // Deliberate: we do NOT do a literal under-tmpdir check, so a repo cloned
42
- // into /tmp/<x> with .git is correctly treated as a project.
43
- const dir = mkTmp(t);
44
- fs.mkdirSync(path.join(dir, '.git'));
45
- assert.equal(isNonProjectCwd(dir), false);
46
- });
47
-
48
- test('isNonProjectCwd: cwd with only .code-graph → non-project (self-created dir is not a marker)', (t) => {
49
- // Circularity guard: once code-graph (pre-fix) created /tmp/.code-graph, a
50
- // naive marker set counting .code-graph would self-certify /tmp as a project.
51
- const dir = mkTmp(t);
52
- fs.mkdirSync(path.join(dir, '.code-graph'));
53
- assert.equal(isProjectRoot(dir), false, '.code-graph alone must not qualify as a project');
54
- assert.equal(isNonProjectCwd(dir), true);
55
- });
56
-
57
- test('PROJECT_MARKERS excludes .code-graph and includes the standard anchors', () => {
58
- assert.ok(!PROJECT_MARKERS.includes('.code-graph'), '.code-graph must not be a project marker');
59
- for (const m of ['.git', 'package.json', 'Cargo.toml', 'pyproject.toml', 'go.mod']) {
60
- assert.ok(PROJECT_MARKERS.includes(m), `${m} should be a marker`);
61
- }
62
- });
63
-
64
- test('isProjectRoot detects each marker', (t) => {
65
- for (const marker of PROJECT_MARKERS) {
66
- const dir = mkTmp(t);
67
- assert.equal(isProjectRoot(dir), false, 'bare cwd should not be a project');
68
- const markerPath = path.join(dir, marker);
69
- if (marker.startsWith('.')) fs.mkdirSync(markerPath);
70
- else fs.writeFileSync(markerPath, '');
71
- assert.equal(isProjectRoot(dir), true, `${marker} should make cwd a project`);
72
- }
73
- });
74
-
75
- test('isNonProjectCwd: a marker-less SUBDIR of a project resolves to the project (walk-up, monorepo fix)', (t) => {
76
- // Regression (v0.79.1 audit #7): the gate checked ONLY the literal cwd, so a
77
- // monorepo subdir (`.git` only at the repo root) served the 0-tool stub even
78
- // though the Rust binary's resolver walks up and would answer queries. The
79
- // gate now walks up too.
80
- const root = mkTmp(t);
81
- fs.mkdirSync(path.join(root, '.git'));
82
- const sub = path.join(root, 'backend', 'src');
83
- fs.mkdirSync(sub, { recursive: true });
84
- assert.equal(isProjectRoot(sub), false, 'the subdir itself has no marker');
85
- assert.equal(isNonProjectCwd(sub), false, 'but it is INSIDE a project → not non-project');
86
- assert.equal(findProjectRoot(sub), root, 'walk-up returns the repo root');
87
- });
88
-
89
- test('findProjectRoot: a marker-less tree with no ancestor marker → null (tmp/headless stays gated)', (t) => {
90
- const dir = mkTmp(t);
91
- const sub = path.join(dir, 'a', 'b');
92
- fs.mkdirSync(sub, { recursive: true });
93
- assert.equal(findProjectRoot(sub), null);
94
- assert.equal(isNonProjectCwd(sub), true);
95
- });
@@ -1,79 +0,0 @@
1
- 'use strict';
2
- const { test } = require('node:test');
3
- const assert = require('node:assert');
4
- const fs = require('fs');
5
- const os = require('os');
6
- const path = require('path');
7
- const { recordRecommendation, REC_FILE } = require('./recommendation-log');
8
-
9
- function tmpProject(t, withCodeGraph) {
10
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-rec-'));
11
- t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
12
- if (withCodeGraph) fs.mkdirSync(path.join(dir, '.code-graph'));
13
- return dir;
14
- }
15
-
16
- test('recordRecommendation appends a JSON line with ts + fields', (t) => {
17
- const cwd = tmpProject(t, true);
18
- assert.equal(recordRecommendation(cwd, { hook: 'grep', action: 'deny' }), true);
19
- const content = fs.readFileSync(path.join(cwd, '.code-graph', REC_FILE), 'utf8');
20
- const lines = content.trim().split('\n');
21
- assert.equal(lines.length, 1);
22
- const rec = JSON.parse(lines[0]);
23
- assert.equal(rec.hook, 'grep');
24
- assert.equal(rec.action, 'deny');
25
- assert.ok(typeof rec.ts === 'string' && rec.ts.length > 0, 'ts should be a timestamp');
26
- });
27
-
28
- test('recordRecommendation is a no-op (no dir created) when .code-graph absent', (t) => {
29
- const cwd = tmpProject(t, false);
30
- assert.equal(recordRecommendation(cwd, { hook: 'grep', action: 'hint' }), false);
31
- // Must NOT create the dir or file — zero footprint in non-project cwd.
32
- assert.equal(fs.existsSync(path.join(cwd, '.code-graph')), false);
33
- });
34
-
35
- test('recordRecommendation is a no-op when .code-graph/.no-metrics sentinel present', (t) => {
36
- const cwd = tmpProject(t, true);
37
- // Without the sentinel it records normally...
38
- assert.equal(recordRecommendation(cwd, { hook: 'grep', action: 'deny' }), true);
39
- const before = fs.readFileSync(path.join(cwd, '.code-graph', REC_FILE), 'utf8');
40
- // ...then the project marks itself metrics-silent (a dev/dogfood checkout)...
41
- fs.writeFileSync(path.join(cwd, '.code-graph', '.no-metrics'), '');
42
- // ...and subsequent recordings are suppressed, leaving the file byte-unchanged.
43
- assert.equal(recordRecommendation(cwd, { hook: 'grep', action: 'hint' }), false);
44
- const after = fs.readFileSync(path.join(cwd, '.code-graph', REC_FILE), 'utf8');
45
- assert.equal(after, before, 'sentinel must suppress further recordings');
46
- });
47
-
48
- test('recordRecommendation appends across calls (one line each)', (t) => {
49
- const cwd = tmpProject(t, true);
50
- recordRecommendation(cwd, { hook: 'grep', action: 'hint' });
51
- recordRecommendation(cwd, { hook: 'read', action: 'hint' });
52
- recordRecommendation(cwd, { hook: 'grep', action: 'deny' });
53
- const lines = fs.readFileSync(path.join(cwd, '.code-graph', REC_FILE), 'utf8').trim().split('\n');
54
- assert.equal(lines.length, 3);
55
- const hooks = lines.map((l) => JSON.parse(l).hook);
56
- assert.deepEqual(hooks, ['grep', 'read', 'grep']);
57
- });
58
-
59
- test('recordRecommendation rotates the file when it exceeds the size cap', (t) => {
60
- const cwd = tmpProject(t, true);
61
- const file = path.join(cwd, '.code-graph', REC_FILE);
62
- // Pre-fill > 1MB of prior events.
63
- const filler = 'y'.repeat(1024);
64
- let blob = '';
65
- for (let i = 0; i < 1200; i++) blob += `{"old":${i},"pad":"${filler}"}\n`;
66
- fs.writeFileSync(file, blob);
67
- assert.ok(fs.statSync(file).size > 1048576, 'precondition: file over 1MB');
68
-
69
- // One more recorded event must trigger rotation (rotate-before-append).
70
- assert.equal(recordRecommendation(cwd, { hook: 'grep', action: 'deny' }), true);
71
-
72
- const size = fs.statSync(file).size;
73
- assert.ok(size < 600000, `rotated file should be well under 1MB, got ${size}`);
74
- const lines = fs.readFileSync(file, 'utf8').trim().split('\n');
75
- // The just-recorded line is last and intact; the first surviving line is whole JSON.
76
- const last = JSON.parse(lines[lines.length - 1]);
77
- assert.equal(last.action, 'deny');
78
- assert.doesNotThrow(() => JSON.parse(lines[0]), 'first surviving line must be a whole JSON line');
79
- });