Skip to content

Commit 2e771c6

Browse files
andreinknvclaude
andcommitted
fix: tool-surface friction sweep wave 1 — 11 bug fixes
Audit of every codegraph MCP tool + CLI command (5 parallel agents) found friction items; this wave clears the 11 BUG-severity findings. - #1 churn wiped on every sync: the re-extract evict path used deleteFile (drops the whole files row) so the follow-up upsertFile took the INSERT branch which omits churn columns. Split into deleteFileNodes (keep row) + row-drop; re-extract now hits ON CONFLICT UPDATE and commit_count survives. - #2 codegraph_sql value-PRAGMA (PRAGMA user_version = 5) bypassed the read-only gate — gate now rejects "= value" write forms. - #3 CLI find --by name exact mode diverged from MCP — now routes through runViaMCP('codegraph_find'); -j/--json replaced with --compact/--fields. - #4 changed_since --since mislabeled mtime results "Content-changed" — labels are now basis-aware. - #5 entry_points "(N total)" counted only displayed rows — true total now reported with a showing-N-of-M clause. - #6 review minDiffMagnitude summed context lines — diff-parser tallies added/removed; totalDiffMagnitude sums those. - #7 dead_code LLM judge hallucinated LIVE for confirmed orphans + truncated mid-word — JUDGE_HEADER rewritten, truncateReason added. - colbymchenry#8 CLI coverage <symbol> positional ignored — mode now inferred. - colbymchenry#9 tests_for files-mode lacked a barrel-traversal warning — added. - colbymchenry#10 CLI role no-args errored — now routes to the distribution path. - colbymchenry#12 fuzzy/ambiguous symbol resolution silently picked wrong node — resolveSymbolToNode adds a fuzzy-fallback banner; biomarkers and note surface it. (colbymchenry#11 "CLI sql exits 0 on rejected write" was an invalid audit finding — the CLI already exits 1; a regression test was added.) Two reviewer passes; APPROVE. Suite 2973 passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9f0ebba commit 2e771c6

22 files changed

Lines changed: 1017 additions & 126 deletions

__tests__/biomarkers.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1186,4 +1186,47 @@ describe('codegraph_biomarkers: F-D — minCentrality empty hint', () => {
11861186
// we're checking is that the "no symbol matched" path does NOT fire.
11871187
expect(text).not.toMatch(/No symbol matched/);
11881188
});
1189+
1190+
// audit Group 2 #6 — mode=symbol must NOT present a non-existent
1191+
// symbol as a clean "Code Health 10/10". `gnarlyTwo` is not a real
1192+
// symbol; the FTS fallback resolves it to `gnarlyOne`, so the
1193+
// response must carry a visible "Fuzzy fallback" banner.
1194+
it('mode=symbol surfaces a fuzzy-fallback banner for a non-existent name', async () => {
1195+
const result = await handler.execute('codegraph_biomarkers', {
1196+
mode: 'symbol',
1197+
symbol: 'gnarlyTwo',
1198+
});
1199+
const text = result.content[0]?.text ?? '';
1200+
// Either it didn't resolve at all (not-found) OR it resolved
1201+
// fuzzily — in which case the banner is mandatory. What it must
1202+
// NOT do is render a bare clean-health line with no signal.
1203+
const isNotFound = /not found/i.test(text);
1204+
const hasBanner = /Fuzzy fallback/.test(text);
1205+
expect(isNotFound || hasBanner).toBe(true);
1206+
if (hasBanner) expect(text).toMatch(/gnarlyTwo/);
1207+
});
1208+
1209+
it('mode=symbol does NOT add a fuzzy banner for an exact symbol', async () => {
1210+
const result = await handler.execute('codegraph_biomarkers', {
1211+
mode: 'symbol',
1212+
symbol: 'gnarlyOne',
1213+
});
1214+
const text = result.content[0]?.text ?? '';
1215+
expect(text).not.toMatch(/Fuzzy fallback/);
1216+
});
1217+
1218+
// Batched mode-symbol — a non-existent name in the batch must carry
1219+
// the banner on its own section, not a silent 10/10.
1220+
it('mode=symbol batched flags a fuzzy match without affecting real symbols', async () => {
1221+
const result = await handler.execute('codegraph_biomarkers', {
1222+
mode: 'symbol',
1223+
symbols: ['gnarlyOne', 'gnarlyTwo'],
1224+
});
1225+
const text = result.content[0]?.text ?? '';
1226+
// The real symbol's section carries no banner; the fuzzy one does
1227+
// (or it reported no match — both acceptable, never a silent 10/10).
1228+
const bannerCount = (text.match(/Fuzzy fallback/g) ?? []).length;
1229+
const noMatchCount = (text.match(/no symbol matched/g) ?? []).length;
1230+
expect(bannerCount + noMatchCount).toBeGreaterThanOrEqual(1);
1231+
});
11891232
});

__tests__/churn.test.ts

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,14 @@ import {
1313
} from '../src/churn/index.js';
1414
import { getCurrentHeadSha as getGitHead } from '../src/git-utils.js';
1515
import { DatabaseConnection } from '../src/db/index.js';
16-
import { QueryBuilder } from '../src/db/queries.js';
16+
import { QueryBuilder, qbTransaction } from '../src/db/queries.js';
1717
import { applyChurnDeltas, clearChurn } from '../src/db/queries-history.js';
18+
import {
19+
upsertFile,
20+
getFileByPath,
21+
removeFileFromIndex,
22+
removeFileFromIndexInTx,
23+
} from '../src/db/queries-files.js';
1824
import { getMetadata, setMetadata } from '../src/db/queries-metadata.js';
1925
import { HOOK as ChurnHook } from '../src/index-hooks/churn.js';
2026

@@ -369,3 +375,95 @@ describe.skipIf(!HAS_GIT)('churn hook self-heal on algo-version mismatch', () =>
369375
}
370376
});
371377
});
378+
379+
/**
380+
* Regression: re-extracting a file during `sync` must NOT wipe its
381+
* mined churn columns.
382+
*
383+
* The sync re-extract path (`extraction-phases.ts`) evicts a changed
384+
* file's prior extraction then re-`upsertFile`s the same path. Pre-fix
385+
* the evict (`removeFileFromIndexInTx`) deleted the whole `files` row,
386+
* so the follow-up upsert took its INSERT branch — where the
387+
* churn-managed columns (`commit_count` / `first_seen_ts` /
388+
* `last_touched_ts`) fall back to their schema defaults. Every sync
389+
* that touched a file therefore reset that file's churn to 0; over a
390+
* long-lived index every file decayed to 0 and `hotspots` went
391+
* permanently empty. The fix: the re-extract evict deletes only the
392+
* file's nodes, leaving the row for the upsert's ON CONFLICT UPDATE
393+
* branch (which deliberately preserves churn). A genuine removal
394+
* (`removeFileFromIndex`) still drops the row.
395+
*/
396+
describe('re-extract evict preserves mined churn columns', () => {
397+
function setupDb(): { db: DatabaseConnection; q: QueryBuilder } {
398+
const dbDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-churn-reextract-'));
399+
const db = DatabaseConnection.initialize(path.join(dbDir, 'test.db'));
400+
const q = new QueryBuilder(db.getDb());
401+
return { db, q };
402+
}
403+
404+
function seedFile(q: QueryBuilder, p: string): void {
405+
upsertFile(q, {
406+
path: p,
407+
contentHash: 'hash-v1',
408+
language: 'typescript',
409+
size: 10,
410+
modifiedAt: 1,
411+
indexedAt: 1,
412+
nodeCount: 0,
413+
});
414+
}
415+
416+
it('keeps commit_count / first_seen_ts / last_touched_ts across removeFileFromIndexInTx + upsertFile', () => {
417+
const { db, q } = setupDb();
418+
try {
419+
seedFile(q, 'a.ts');
420+
// Mining persisted real churn for the file.
421+
applyChurnDeltas(q, [
422+
{ path: 'a.ts', commitCountDelta: 7, lastTouchedTs: 1764547200, firstSeenTs: 1735689600 },
423+
]);
424+
425+
// Simulate the sync re-extract path: evict the prior extraction,
426+
// then re-upsert the same path with a fresh (post-edit) record.
427+
qbTransaction(q, () => {
428+
removeFileFromIndexInTx(q, 'a.ts');
429+
upsertFile(q, {
430+
path: 'a.ts',
431+
contentHash: 'hash-v2',
432+
language: 'typescript',
433+
size: 20,
434+
modifiedAt: 2,
435+
indexedAt: 2,
436+
nodeCount: 3,
437+
});
438+
});
439+
440+
const after = getFileByPath(q, 'a.ts');
441+
expect(after).not.toBeNull();
442+
// Non-churn columns reflect the re-extraction.
443+
expect(after!.contentHash).toBe('hash-v2');
444+
expect(after!.nodeCount).toBe(3);
445+
// Churn columns survive untouched.
446+
expect(after!.commitCount).toBe(7);
447+
expect(after!.firstSeenTs).toBe(1735689600);
448+
expect(after!.lastTouchedTs).toBe(1764547200);
449+
} finally {
450+
db.close();
451+
}
452+
});
453+
454+
it('removeFileFromIndex still fully drops the files row (genuine removal)', () => {
455+
const { db, q } = setupDb();
456+
try {
457+
seedFile(q, 'gone.ts');
458+
applyChurnDeltas(q, [
459+
{ path: 'gone.ts', commitCountDelta: 3, lastTouchedTs: 1764547200, firstSeenTs: 1735689600 },
460+
]);
461+
462+
removeFileFromIndex(q, 'gone.ts');
463+
464+
expect(getFileByPath(q, 'gone.ts')).toBeNull();
465+
} finally {
466+
db.close();
467+
}
468+
});
469+
});

__tests__/cli-mcp-alignment.test.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,12 @@
2222
*/
2323

2424
import { describe, it, expect } from 'vitest';
25+
import { execFileSync } from 'child_process';
2526
import * as fs from 'fs';
2627
import * as path from 'path';
2728
import type { Command } from 'commander';
2829
import { getToolModules } from '../src/mcp/tools/registry.js';
30+
import { isReadOnlySql } from '../src/mcp/tools/sql.js';
2931

3032
/**
3133
* Identifiers known to be intentionally one-sided — each entry
@@ -275,3 +277,101 @@ describe('CLI ↔ MCP surface alignment', () => {
275277
).toEqual([]);
276278
});
277279
});
280+
281+
/**
282+
* Read-only gate on `codegraph_sql` — a value-setting PRAGMA
283+
* (`PRAGMA user_version = 5`) is a write and must be rejected, while
284+
* bare introspection PRAGMAs stay allowed. The original gate matched
285+
* only the pragma name against the allowlist and ignored the trailing
286+
* `= value` assignment, so allowlisted value-form pragmas
287+
* (`user_version` / `schema_version` / `page_size`) were writable.
288+
*/
289+
describe('codegraph_sql read-only gate — value-PRAGMA rejection', () => {
290+
it('rejects value-setting PRAGMAs even when the pragma name is allowlisted', () => {
291+
expect(isReadOnlySql('PRAGMA user_version = 5')).toBe(false);
292+
expect(isReadOnlySql('PRAGMA user_version=5')).toBe(false);
293+
expect(isReadOnlySql(' pragma schema_version = 9 ')).toBe(false);
294+
expect(isReadOnlySql('PRAGMA page_size = 4096')).toBe(false);
295+
expect(isReadOnlySql('PRAGMA user_version = 5;')).toBe(false);
296+
});
297+
298+
it('still allows bare introspection PRAGMAs and single quoted/identifier args', () => {
299+
expect(isReadOnlySql('PRAGMA user_version')).toBe(true);
300+
expect(isReadOnlySql('PRAGMA user_version;')).toBe(true);
301+
expect(isReadOnlySql('PRAGMA table_info(nodes)')).toBe(true);
302+
expect(isReadOnlySql("PRAGMA table_info('nodes')")).toBe(true);
303+
expect(isReadOnlySql('PRAGMA integrity_check(20)')).toBe(true);
304+
});
305+
306+
it('still rejects non-allowlisted pragma names and plain writes', () => {
307+
expect(isReadOnlySql('PRAGMA cache_size = 99999')).toBe(false);
308+
expect(isReadOnlySql('PRAGMA journal_mode = WAL')).toBe(false);
309+
expect(isReadOnlySql('DELETE FROM nodes')).toBe(false);
310+
expect(isReadOnlySql('SELECT * FROM nodes')).toBe(true);
311+
});
312+
});
313+
314+
/**
315+
* CLI behaviour parity — spawns the real CLI (`tsx src/bin/codegraph.ts`)
316+
* against this repo's own index. These guard four audited
317+
* CLI-vs-MCP divergences:
318+
* - `sql` exits non-zero on a rejected / invalid query (scripts can
319+
* detect failure).
320+
* - `find --by name` exact mode returns the container + members set
321+
* the MCP tool returns, not a fuzzy relevance rank.
322+
* - `coverage <symbol>` positional selects symbol mode.
323+
* - `role` with no args produces the project-wide distribution table.
324+
*/
325+
describe('CLI behaviour parity (spawned)', () => {
326+
const repoRoot = path.join(__dirname, '..');
327+
const cliEntry = path.join(repoRoot, 'src', 'bin', 'codegraph.ts');
328+
const indexed = fs.existsSync(path.join(repoRoot, '.codegraph'));
329+
330+
/** Run the CLI, returning stdout+stderr and the exit code. */
331+
function runCli(cliArgs: string[]): { out: string; code: number } {
332+
try {
333+
const out = execFileSync('npx', ['tsx', cliEntry, ...cliArgs], {
334+
cwd: repoRoot,
335+
encoding: 'utf-8',
336+
stdio: ['ignore', 'pipe', 'pipe'],
337+
});
338+
return { out, code: 0 };
339+
} catch (err) {
340+
const e = err as { status?: number; stdout?: string; stderr?: string };
341+
return { out: (e.stdout ?? '') + (e.stderr ?? ''), code: e.status ?? 1 };
342+
}
343+
}
344+
345+
it.skipIf(!indexed)('sql exits non-zero on a rejected write query', () => {
346+
const { code } = runCli(['sql', 'DELETE FROM nodes']);
347+
expect(code).not.toBe(0);
348+
}, 60_000);
349+
350+
it.skipIf(!indexed)('sql exits non-zero on an invalid (no such table) query', () => {
351+
const { code } = runCli(['sql', 'SELECT * FROM no_such_table_xyz']);
352+
expect(code).not.toBe(0);
353+
}, 60_000);
354+
355+
it.skipIf(!indexed)('find --by name exact returns the container + its members', () => {
356+
const { out, code } = runCli(['find', '--by', 'name', 'GraphTraverser']);
357+
expect(code).toBe(0);
358+
// MCP exact mode lists the class then its member methods, not a
359+
// fuzzy relevance rank with `(NN%)` scores and unrelated imports.
360+
expect(out).toContain('GraphTraverser (class)');
361+
expect(out).toContain('traverseBFS (method)');
362+
expect(out).not.toMatch(/\(\d+%\)/);
363+
}, 60_000);
364+
365+
it.skipIf(!indexed)('coverage with a bare positional symbol selects symbol mode', () => {
366+
const { out, code } = runCli(['coverage', 'computeMetrics']);
367+
expect(code).toBe(0);
368+
expect(out).toContain('Coverage for `computeMetrics`');
369+
expect(out).not.toContain('lowest first');
370+
}, 60_000);
371+
372+
it.skipIf(!indexed)('role with no args produces the project-wide distribution table', () => {
373+
const { out, code } = runCli(['role']);
374+
expect(code).toBe(0);
375+
expect(out).toContain('Role distribution (project-wide)');
376+
}, 60_000);
377+
});

__tests__/dead-code-parser.test.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
*/
1414

1515
import { describe, it, expect } from 'vitest';
16-
import { parseBatchJudges } from '../src/llm/dead-code.js';
16+
import { parseBatchJudges, truncateReason } from '../src/llm/dead-code.js';
1717

1818
describe('parseBatchJudges', () => {
1919
it('parses a clean results document into a position→verdict map', () => {
@@ -46,7 +46,9 @@ describe('parseBatchJudges', () => {
4646
it('truncates a very long reason string', () => {
4747
const longReason = 'x'.repeat(500);
4848
const m = parseBatchJudges(`{"results":[{"i":0,"verdict":"dead","confidence":0.5,"reason":"${longReason}"}]}`, 1);
49-
expect(m.get(0)!.reason.length).toBeLessThanOrEqual(200);
49+
// Cap is 280 + a 1-char ellipsis; a single unbroken token is hard-sliced.
50+
expect(m.get(0)!.reason.length).toBeLessThanOrEqual(281);
51+
expect(m.get(0)!.reason.endsWith('…')).toBe(true);
5052
});
5153

5254
it('drops an entry whose index is out of range', () => {
@@ -60,3 +62,41 @@ describe('parseBatchJudges', () => {
6062
expect(parseBatchJudges('[{"i":0,"verdict":"dead","confidence":0.5,"reason":"x"}]', 1).size).toBe(0); // bare array, not object-rooted
6163
});
6264
});
65+
66+
describe('truncateReason', () => {
67+
it('leaves a reason within the cap untouched', () => {
68+
expect(truncateReason('no callers, not a framework hook')).toBe('no callers, not a framework hook');
69+
});
70+
71+
it('trims on a word boundary, never mid-word', () => {
72+
// A realistic over-cap rationale built from whole words.
73+
const reason = ('not reachable through any dynamic dispatch or framework hook '
74+
+ 'and not part of the public API surface ').repeat(4).trim();
75+
const out = truncateReason(reason);
76+
expect(out.length).toBeLessThanOrEqual(281);
77+
expect(out.endsWith('…')).toBe(true);
78+
// The character before the ellipsis is a whole word — not a slice
79+
// through one. Reconstruct the head and confirm it is a prefix of
80+
// the original at a space boundary.
81+
const head = out.slice(0, -1);
82+
expect(reason.startsWith(head)).toBe(true);
83+
expect(reason[head.length]).toBe(' '); // cut landed on whitespace
84+
});
85+
86+
it('does not produce the mid-word truncation from the audit repro', () => {
87+
// The pre-fix bug sliced "...used internally by other parts of the
88+
// codebase" to "...the codebas". The word-boundary trim must never
89+
// leave a partial trailing word. This reason is long enough (>280
90+
// chars) to actually trip the cap.
91+
const reason = 'The function lineHasBuildContextHint is used within the project, '
92+
+ 'as indicated by its presence in the static reference graph. It is not marked '
93+
+ 'as exported, but it is likely used internally by other parts of the codebase, '
94+
+ 'and removing it would probably break something somewhere down the line too.';
95+
const out = truncateReason(reason);
96+
expect(out.endsWith('…')).toBe(true);
97+
expect(out).not.toMatch(/codebas$/); // not sliced mid-word
98+
const head = out.slice(0, -1);
99+
expect(reason.startsWith(head)).toBe(true);
100+
expect(reason[head.length]).toBe(' ');
101+
});
102+
});

__tests__/mcp-changed-since.test.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -105,11 +105,27 @@ describe('codegraph_changed_since (#11a)', () => {
105105
const result = await handler.execute('codegraph_changed_since', { since: old });
106106
const text = result.content[0]?.text ?? '';
107107
expect(text).toMatch(/Comparing on-disk file mtime against `since` threshold/);
108-
// Bucket header renamed "Modified" → "Content-changed" across both
109-
// the index-comparison path AND the explicit-`since` path so the
110-
// section semantic is consistent regardless of which threshold
111-
// mode the caller used.
112-
expect(text).toMatch(/### Content-changed \(\d+\)/);
108+
// audit Group 2 #3: the `since` path compares wall-clock mtime, NOT
109+
// content-hash — an mtime bump after a checkout is not a content
110+
// change. The bucket must be labelled by what it actually measures,
111+
// so the explicit-`since` section is "Modified after threshold (by
112+
// mtime)", distinct from the content-hash "Content-changed" bucket
113+
// of the no-arg path. The two paths must NOT share a label.
114+
expect(text).toMatch(/### Modified after threshold \(by mtime\) \(\d+\)/);
115+
expect(text).not.toMatch(/### Content-changed/);
116+
});
117+
118+
it("no-arg path keeps the content-hash 'Content-changed' bucket label", async () => {
119+
// Guard the other side of the audit Group 2 #3 fix: the no-arg path
120+
// genuinely compares SHA256 against `files.content_hash`, so its
121+
// bucket correctly says "Content-changed".
122+
fs.writeFileSync(path.join(dir, 'src', 'a.ts'), 'export function a() { return 999; }\n');
123+
const future = Math.floor(Date.now() / 1000) + 60;
124+
fs.utimesSync(path.join(dir, 'src', 'a.ts'), future, future);
125+
const result = await handler.execute('codegraph_changed_since', {});
126+
const text = result.content[0]?.text ?? '';
127+
expect(text).toMatch(/### Content-changed \(1\)/);
128+
expect(text).not.toMatch(/Modified after threshold/);
113129
});
114130

115131
it("explicit `since` (numeric-string unix ms) works the same", async () => {

0 commit comments

Comments
 (0)