diff --git a/src/features/check.ts b/src/features/check.ts index dadc2cad8..3b03e7907 100644 --- a/src/features/check.ts +++ b/src/features/check.ts @@ -27,6 +27,14 @@ interface ParsedDiff { * `checkMaxBlastRadius`'s call-graph-shape exemption — see issue #1740. */ changedEdits: Map; + /** + * Files removed in their entirety (a `--- a/` header followed by a + * `+++ /dev/null` target, git's marker for a full-file deletion) — distinct + * from `changedRanges`/`oldRanges`, which never gain an entry for these + * files since there is no new-side content to track. Powers + * `checkNoDeletedExportsInUse` — see issue #1806. + */ + deletedFiles: Set; } /** An added-line run paired with whatever it replaced, for shape comparison. */ @@ -37,6 +45,7 @@ interface DiffTextEdit extends DiffRange { const HUNK_RE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/; const NEW_FILE_RE = /^\+\+\+ b\/(.+)/; +const OLD_FILE_RE = /^--- a\/(.+)/; /** Returns true if the diff line marks the old file as /dev/null (new-file creation). */ function isDevNullSourceLine(line: string): boolean { @@ -54,6 +63,12 @@ function extractNewFileName(line: string): string | null { return m ? m[1]! : null; } +/** Extracts the old filename from a `--- a/` diff header, or null. */ +function extractOldFileName(line: string): string | null { + const m = line.match(OLD_FILE_RE); + return m ? m[1]! : null; +} + /** Returns true if the diff line marks the new file as /dev/null (file deletion). */ function isDevNullTargetLine(line: string): boolean { return line.startsWith('+++ /dev/null'); @@ -226,8 +241,14 @@ export function parseDiffOutput(diffOutput: string): ParsedDiff { const oldRanges = new Map(); const changedEdits = new Map(); const newFiles = new Set(); + const deletedFiles = new Set(); let currentFile: string | null = null; let prevIsDevNull = false; + // Old-side filename staged by a `--- a/` header, in case the very + // next header turns out to be `+++ /dev/null` (this file was deleted in + // its entirety). Cleared whenever the following header resolves to + // anything else, so it never leaks across an unrelated file's headers. + let pendingOldFile: string | null = null; const tracker = new DiffLineTracker(); for (const line of diffOutput.split('\n')) { @@ -241,10 +262,12 @@ export function parseDiffOutput(diffOutput: string): ParsedDiff { if (!tracker.insideHunk()) { if (isDevNullSourceLine(line)) { prevIsDevNull = true; + pendingOldFile = null; continue; } if (isSourceFileHeaderLine(line)) { prevIsDevNull = false; + pendingOldFile = extractOldFileName(line); continue; } const newFile = extractNewFileName(line); @@ -256,6 +279,7 @@ export function parseDiffOutput(diffOutput: string): ParsedDiff { if (!changedEdits.has(currentFile)) changedEdits.set(currentFile, []); if (prevIsDevNull) newFiles.add(currentFile); prevIsDevNull = false; + pendingOldFile = null; continue; } if (isDevNullTargetLine(line)) { @@ -263,12 +287,16 @@ export function parseDiffOutput(diffOutput: string): ParsedDiff { // extractNewFileName returned null above and this line would otherwise // fall through to tracker.consume and be misread as an added source // line under whichever file preceded this one in the diff. Flush and - // clear the file context instead — the deleted file's hunk body that - // follows has no corresponding DB entry to check against anyway (its - // nodes are purged from the graph), so there is nothing to track here. + // clear the file context instead — a deleted file never accumulates + // changedRanges/oldRanges entries (there is no new-side content, and + // the old-side body is about to disappear along with the file), but + // its pre-purge DB rows are still worth checking for lingering + // external consumers — see `checkNoDeletedExportsInUse` (#1806). if (currentFile) tracker.flush(currentFile, oldRanges, changedRanges, changedEdits); + if (pendingOldFile) deletedFiles.add(pendingOldFile); currentFile = null; prevIsDevNull = false; + pendingOldFile = null; continue; } } @@ -292,7 +320,7 @@ export function parseDiffOutput(diffOutput: string): ParsedDiff { } if (currentFile) tracker.flush(currentFile, oldRanges, changedRanges, changedEdits); - return { changedRanges, oldRanges, newFiles, changedEdits }; + return { changedRanges, oldRanges, newFiles, changedEdits, deletedFiles }; } // ─── Predicates ─────────────────────────────────────────────────────── @@ -524,11 +552,21 @@ export function checkMaxBlastRadius( return result; } +interface ConsumerRef { + name: string; + file: string; + line: number; +} + interface SignatureViolation { name: string; kind: string; file: string; line: number; + /** Present only for violations from `checkNoDeletedExportsInUse` — see #1806. */ + reason?: 'file-deleted'; + /** External (cross-file) consumers found for a deleted export — only set when `reason === 'file-deleted'`. */ + consumers?: ConsumerRef[]; } interface SignatureResult { @@ -586,6 +624,105 @@ export function checkNoSignatureChanges( return { passed: violations.length === 0, violations }; } +type DeletedDefRow = { + id: number; + name: string; + kind: string; + file: string; + line: number; +}; + +/** + * Detects exported functions/methods/classes lost when a file is deleted in + * its entirety, for deletions whose exports still have consumers elsewhere + * in the codebase. + * + * `checkNoSignatureChanges` can never see this case: a fully deleted file + * never gets a `changedRanges` entry (there is no new-side content to track + * — see `parseDiffOutput`'s `+++ /dev/null` handling), and its `nodes` rows + * are purged by the very next `codegraph build`/incremental rebuild + * (`DELETE FROM nodes WHERE file = ?`, run unconditionally for any file + * `detectChanges` no longer finds on disk — see + * `domain/graph/builder/stages/detect-changes.ts`). `checkData` itself never + * triggers a rebuild — it only opens the DB read-only — so whether this + * predicate can see a deleted file's exports depends entirely on whether + * some *other*, separate `codegraph build` invocation has already purged it + * by the time `check` runs. + * + * This closes that gap for the common case: `db` still reflects pre-purge + * state whenever `codegraph check --staged` runs before any rebuild has + * observed the deletion (e.g. this repo's own pre-commit hook, which checks + * staged changes without rebuilding first). Once some rebuild purges the + * deleted file's rows, this predicate has nothing left to find for it — the + * violation silently goes undetected, same as before this predicate + * existed. See issue #1806 for the follow-up tracking a durable, + * purge-order-independent fix (e.g. capturing this at purge time inside the + * build pipeline itself, so it survives regardless of invocation order). + * + * Unlike `checkNoSignatureChanges` (which flags any touched exported + * declaration regardless of caller count, since editing an exported line is + * inherently risky), this predicate only flags a deleted export when it has + * a real consumer OUTSIDE the deleted file — reusing the same + * cross-file-consumer shape as `domain/analysis/exports.ts` / + * `features/structure.ts` (`calls`/`imports-type` edges whose source file + * differs from the target's file). Deleting a file whose exports are never + * imported elsewhere is a legitimate, safe cleanup and must not be flagged. + * + * Known limitation: a same-commit rename (delete `old.js` + add `new.js` + * with equivalent exports, with callers updated to import from `new.js` in + * the same diff) can false-positive here, because `db` only reflects + * pre-change edges — it has no visibility into the staged content of the + * files that will import from the new location. This mirrors an existing, + * accepted trade-off in this predicate family (e.g. `checkMaxBlastRadius`'s + * paren-less-call blind spot) rather than a new class of problem. + */ +export function checkNoDeletedExportsInUse( + db: BetterSqlite3Database, + deletedFiles: Set, + noTests: boolean, +): SignatureResult { + const violations: SignatureViolation[] = []; + if (deletedFiles.size === 0) return { passed: true, violations }; + + const defsStmt = db.prepare( + `SELECT id, name, kind, file, line FROM nodes WHERE file = ? AND kind IN ('function', 'method', 'class') AND exported = 1 ORDER BY line`, + ); + const consumersStmt = db.prepare( + `SELECT DISTINCT caller.name, caller.file, caller.line + FROM edges e + JOIN nodes caller ON e.source_id = caller.id + WHERE e.target_id = ? AND e.kind IN ('calls', 'imports-type') AND caller.file != ?`, + ); + + for (const file of deletedFiles) { + if (noTests && isTestFile(file)) continue; + + const defs = defsStmt.all(file) as DeletedDefRow[]; + for (const def of defs) { + let consumers = consumersStmt.all(def.id, def.file) as ConsumerRef[]; + // A caller that is itself among the files this same diff deletes isn't + // an external caller left dangling by the diff — it's being removed + // too. Mirrors checkNoSignatureChanges's exported-only filter: only a + // caller reachable from outside the set of files this diff removes can + // be a caller the diff doesn't already account for. + consumers = consumers.filter((c) => !deletedFiles.has(c.file)); + if (noTests) consumers = consumers.filter((c) => !isTestFile(c.file)); + if (consumers.length === 0) continue; + + violations.push({ + name: def.name, + kind: def.kind, + file: def.file, + line: def.line, + reason: 'file-deleted', + consumers, + }); + } + } + + return { passed: violations.length === 0, violations }; +} + interface BoundaryViolation { from: string; to: string; @@ -654,6 +791,7 @@ interface CheckSummary { failed: number; changedFiles: number; newFiles: number; + deletedFiles: number; } interface CheckResult { @@ -744,9 +882,21 @@ function runPredicates( }); } if (flags.enableSignatures) { + // Both predicates report under the single 'signatures' name: they are + // two detection strategies for the same class of risk (an exported + // declaration this diff makes unreachable to its existing callers) — + // checkNoSignatureChanges for declarations edited in place, + // checkNoDeletedExportsInUse for declarations lost via full-file + // deletion (#1806). Merging keeps both gated by the same --signatures + // flag/config and lets existing consumers of the 'signatures' predicate + // (the pre-commit hook, `codegraph check --json`) pick up the new + // violations with no wiring changes. + const editedResult = checkNoSignatureChanges(db, diff.changedRanges, noTests); + const deletedResult = checkNoDeletedExportsInUse(db, diff.deletedFiles, noTests); predicates.push({ name: 'signatures', - ...checkNoSignatureChanges(db, diff.changedRanges, noTests), + passed: editedResult.passed && deletedResult.passed, + violations: [...editedResult.violations, ...deletedResult.violations], }); } if (flags.enableBoundaries) { @@ -762,7 +912,7 @@ function runPredicates( function makeEmptyCheck(): CheckResult { return { predicates: [], - summary: { total: 0, passed: 0, failed: 0, changedFiles: 0, newFiles: 0 }, + summary: { total: 0, passed: 0, failed: 0, changedFiles: 0, newFiles: 0, deletedFiles: 0 }, passed: true, }; } @@ -802,7 +952,12 @@ export function checkData(customDbPath: string | undefined, opts: CheckOpts = {} if (!diffOutput.trim()) return makeEmptyCheck(); const diff = parseDiffOutput(diffOutput); - if (diff.changedRanges.size === 0) return makeEmptyCheck(); + // A delete-only diff (e.g. `git rm` with no other staged changes) never + // populates changedRanges — see parseDiffOutput's `+++ /dev/null` + // handling — but must still run the predicates below (specifically + // checkNoDeletedExportsInUse) rather than short-circuiting to "no + // changes" (#1806). + if (diff.changedRanges.size === 0 && diff.deletedFiles.size === 0) return makeEmptyCheck(); const predicates = runPredicates(db, diff, flags, repoRoot, noTests, maxDepth); @@ -817,6 +972,7 @@ export function checkData(customDbPath: string | undefined, opts: CheckOpts = {} failed: failedCount, changedFiles: diff.changedRanges.size, newFiles: diff.newFiles.size, + deletedFiles: diff.deletedFiles.size, }, passed: failedCount === 0, }; diff --git a/src/presentation/check.ts b/src/presentation/check.ts index a9137a433..6a374ae79 100644 --- a/src/presentation/check.ts +++ b/src/presentation/check.ts @@ -28,6 +28,9 @@ interface CheckViolation { from?: string; to?: string; edgeKind?: string; + /** Set when this violation comes from `checkNoDeletedExportsInUse` (#1806). */ + reason?: string; + consumers?: Array<{ name: string; file: string; line: number }>; } interface CheckPredicate { @@ -46,6 +49,7 @@ interface CheckDataResult { summary: { changedFiles: number; newFiles: number; + deletedFiles: number; total: number; passed: number; failed: number; @@ -74,6 +78,14 @@ function formatPredicateViolations(pred: CheckPredicate): void { if (pred.name === 'boundaries') { return `${v.from} -> ${v.to} (${v.edgeKind})`; } + if (v.reason === 'file-deleted' && v.consumers) { + const sample = v.consumers + .slice(0, 3) + .map((c) => `${c.file}:${c.line}`) + .join(', '); + const more = v.consumers.length > 3 ? `, ... and ${v.consumers.length - 3} more` : ''; + return `${v.name} (${v.kind}) — file ${v.file} deleted but still used by ${v.consumers.length} external consumer(s): ${sample}${more}`; + } return `${v.name} (${v.kind}) at ${v.file}:${v.line}`; }; @@ -114,7 +126,7 @@ export function check(customDbPath: string | undefined, opts: CheckCliOpts = {}) } console.log( - ` Changed files: ${data.summary.changedFiles} New files: ${data.summary.newFiles}\n`, + ` Changed files: ${data.summary.changedFiles} New files: ${data.summary.newFiles} Deleted files: ${data.summary.deletedFiles}\n`, ); for (const pred of data.predicates) { diff --git a/tests/integration/check.test.ts b/tests/integration/check.test.ts index 3deb37b2a..9903fa59e 100644 --- a/tests/integration/check.test.ts +++ b/tests/integration/check.test.ts @@ -15,6 +15,7 @@ import { checkData, checkMaxBlastRadius, checkNoBoundaryViolations, + checkNoDeletedExportsInUse, checkNoNewCycles, checkNoSignatureChanges, parseDiffOutput, @@ -210,16 +211,74 @@ describe('parseDiffOutput', () => { '-deleted line 2', ].join('\n'); - const { changedRanges, oldRanges } = parseDiffOutput(diff); + const { changedRanges, oldRanges, deletedFiles } = parseDiffOutput(diff); // kept.js's ranges must reflect only its own hunk, not the deleted // file's `+++ /dev/null` header or removed body lines. expect(changedRanges.get('src/kept.js')).toEqual([{ start: 1, end: 1 }]); expect(oldRanges.get('src/kept.js')).toEqual([{ start: 1, end: 1 }]); - // The deleted file itself is not tracked — its nodes are purged from the - // DB during the rebuild, so there is nothing for the check predicates to - // match against. + // The deleted file never gets a changedRanges/oldRanges entry — there is + // no new-side content to compare post-purge line numbers against — but + // it IS recorded in `deletedFiles` so checkNoDeletedExportsInUse can + // still check its pre-purge exports for lingering external callers + // (#1806). expect(changedRanges.has('src/removed.js')).toBe(false); expect(oldRanges.has('src/removed.js')).toBe(false); + expect(deletedFiles.has('src/removed.js')).toBe(true); + }); + + // ─── deletedFiles (issue #1806) ───────────────────────────────────── + + test('detects a fully deleted file via the /dev/null target marker', () => { + const diff = [ + '--- a/src/old-file.js', + '+++ /dev/null', + '@@ -1,2 +0,0 @@', + '-export function foo() {}', + '-export function bar() {}', + ].join('\n'); + + const { deletedFiles } = parseDiffOutput(diff); + expect(deletedFiles.has('src/old-file.js')).toBe(true); + expect(deletedFiles.size).toBe(1); + }); + + test('does not mark a modified file as deleted', () => { + const diff = ['--- a/src/kept.js', '+++ b/src/kept.js', '@@ -1,1 +1,1 @@', '-old', '+new'].join( + '\n', + ); + + const { deletedFiles } = parseDiffOutput(diff); + expect(deletedFiles.size).toBe(0); + }); + + test('a new file is not also recorded as deleted (/dev/null on the source side)', () => { + // `--- /dev/null` (new-file creation) must not be confused with + // `+++ /dev/null` (deletion) — isDevNullSourceLine clears pendingOldFile + // so a subsequent unrelated `+++ /dev/null` elsewhere can't attribute a + // deletion to this file. + const diff = ['--- /dev/null', '+++ b/src/new-file.js', '@@ -0,0 +1,1 @@', '+line1'].join('\n'); + + const { deletedFiles, newFiles } = parseDiffOutput(diff); + expect(newFiles.has('src/new-file.js')).toBe(true); + expect(deletedFiles.size).toBe(0); + }); + + test('tracks multiple deleted files in the same diff', () => { + const diff = [ + '--- a/src/first.js', + '+++ /dev/null', + '@@ -1,1 +0,0 @@', + '-export function first() {}', + '--- a/src/second.js', + '+++ /dev/null', + '@@ -1,1 +0,0 @@', + '-export function second() {}', + ].join('\n'); + + const { deletedFiles } = parseDiffOutput(diff); + expect(deletedFiles.has('src/first.js')).toBe(true); + expect(deletedFiles.has('src/second.js')).toBe(true); + expect(deletedFiles.size).toBe(2); }); test('handles multiple files', () => { @@ -764,6 +823,98 @@ describe('checkNoSignatureChanges', () => { }); }); +// ─── checkNoDeletedExportsInUse (issue #1806) ────────────────────────── + +describe('checkNoDeletedExportsInUse', () => { + test('flags an exported symbol whose file is deleted when it still has external callers', () => { + // Fixture: add (exported, src/math.js) is called by handleRequest + // (src/handler.js) and formatResult (src/utils.js) — both external. + const result = checkNoDeletedExportsInUse(db, new Set(['src/math.js']), false); + expect(result.passed).toBe(false); + const violation = result.violations.find((v) => v.name === 'add'); + expect(violation).toBeDefined(); + expect(violation.reason).toBe('file-deleted'); + expect(violation.consumers.map((c) => c.file).sort()).toEqual([ + 'src/handler.js', + 'src/utils.js', + ]); + }); + + test('does not flag an exported symbol whose only caller lives in the same deleted file', () => { + // multiply (exported, src/math.js) is only called by `add`, which lives + // in the same file being deleted — not an external consumer left + // dangling by this diff. + const result = checkNoDeletedExportsInUse(db, new Set(['src/math.js']), false); + expect(result.violations.map((v) => v.name)).not.toContain('multiply'); + }); + + test('passes for a deleted file with no exported symbols', () => { + const result = checkNoDeletedExportsInUse(db, new Set(['src/processor.js']), false); + expect(result.passed).toBe(true); + expect(result.violations).toEqual([]); + }); + + test('passes when deletedFiles is empty', () => { + const result = checkNoDeletedExportsInUse(db, new Set(), false); + expect(result.passed).toBe(true); + expect(result.violations).toEqual([]); + }); + + test('excludes a caller that is itself among the files this diff also deletes', () => { + // handler.js (home of handleRequest, one of add's two external callers) + // is being deleted in the same diff — its call to `add` must not count + // as a dangling external consumer. formatResult (utils.js) is untouched + // and must still be reported. + const result = checkNoDeletedExportsInUse( + db, + new Set(['src/math.js', 'src/handler.js']), + false, + ); + const violation = result.violations.find((v) => v.name === 'add'); + expect(violation).toBeDefined(); + expect(violation.consumers.map((c) => c.file)).toEqual(['src/utils.js']); + }); + + test('skips a deleted file when noTests is true and it is a test file', () => { + const result = checkNoDeletedExportsInUse(db, new Set(['tests/math.test.js']), true); + expect(result.passed).toBe(true); + }); + + test('suppresses a violation when noTests is true and every consumer of a deleted non-test file is a test file', () => { + // onlyTestConsumer (exported, src/only-test-consumer.js) is called only + // from a test file — a real violation with noTests=false, but the + // consumer-side filter (`consumers.filter((c) => !isTestFile(c.file))`) + // must drop that lone consumer when noTests=true, leaving zero + // consumers and suppressing the violation entirely. Distinct from the + // test above, which skips a *deleted test file*, not a deleted + // non-test file whose consumers are all tests. + const onlyTestConsumer = insertNode( + db, + 'onlyTestConsumer', + 'function', + 'src/only-test-consumer.js', + 1, + 3, + 1, + ); + const callerTest = insertNode( + db, + 'callsOnlyTestConsumer', + 'function', + 'tests/only-test-consumer.test.js', + 1, + 3, + ); + insertEdge(db, callerTest, onlyTestConsumer, 'calls'); + + const withTests = checkNoDeletedExportsInUse(db, new Set(['src/only-test-consumer.js']), false); + expect(withTests.violations.map((v) => v.name)).toContain('onlyTestConsumer'); + + const noTests = checkNoDeletedExportsInUse(db, new Set(['src/only-test-consumer.js']), true); + expect(noTests.violations.map((v) => v.name)).not.toContain('onlyTestConsumer'); + }); +}); + // ─── checkNoBoundaryViolations ──────────────────────────────────────── describe('checkNoBoundaryViolations', () => { @@ -992,3 +1143,135 @@ describe('checkData', () => { } }); }); + +// ─── checkData: full file deletion (issue #1806) ─────────────────────── +// +// End-to-end repro of the scenario the issue describes: file A exports a +// symbol used by file B; staging A's deletion (with B untouched) must be +// flagged, while deleting a file with no external callers must not be. +// checkData is exercised directly (not via a rebuild) so `db` reflects the +// pre-purge state checkNoDeletedExportsInUse depends on — see that +// function's docstring for why purge ordering matters here. + +describe('checkData: full file deletion (issue #1806)', () => { + test('flags an exported function whose entire file is deleted while a real external caller remains', () => { + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-check-delfile-')); + fs.mkdirSync(path.join(projectDir, '.codegraph')); + fs.mkdirSync(path.join(projectDir, 'src'), { recursive: true }); + const projectDbPath = path.join(projectDir, '.codegraph', 'graph.db'); + + const projectDb = new Database(projectDbPath); + projectDb.pragma('journal_mode = WAL'); + initSchema(projectDb); + // src/shared.js exports sharedHelper, called by src/consumer.js. + const sharedHelperId = insertNode( + projectDb, + 'sharedHelper', + 'function', + 'src/shared.js', + 1, + 3, + 1, + ); + const callerId = insertNode(projectDb, 'useShared', 'function', 'src/consumer.js', 1, 3, 0); + insertEdge(projectDb, callerId, sharedHelperId, 'calls'); + projectDb.close(); + + const { execFileSync } = require('node:child_process'); + try { + execFileSync('git', ['init'], { cwd: projectDir, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.email', 'test@test.com'], { + cwd: projectDir, + stdio: 'pipe', + }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: projectDir, stdio: 'pipe' }); + + fs.writeFileSync( + path.join(projectDir, 'src', 'shared.js'), + 'export function sharedHelper() {\n return 1;\n}\n', + ); + fs.writeFileSync( + path.join(projectDir, 'src', 'consumer.js'), + "import { sharedHelper } from './shared.js';\nfunction useShared() {\n return sharedHelper();\n}\n", + ); + execFileSync('git', ['add', '.'], { cwd: projectDir, stdio: 'pipe' }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: projectDir, stdio: 'pipe' }); + + // Stage ONLY the deletion of shared.js — consumer.js is left + // untouched, still importing/calling it. A real diff-parsing bug + // would let this slip through silently. + execFileSync('git', ['rm', 'src/shared.js'], { cwd: projectDir, stdio: 'pipe' }); + + const data = checkData(projectDbPath, { + staged: true, + signatures: true, + cycles: false, + boundaries: false, + }); + + expect(data.error).toBeUndefined(); + expect(data.summary.deletedFiles).toBe(1); + expect(data.passed).toBe(false); + + const sigPred = data.predicates.find((p) => p.name === 'signatures'); + expect(sigPred).toBeDefined(); + expect(sigPred.passed).toBe(false); + const violation = sigPred.violations.find((v) => v.name === 'sharedHelper'); + expect(violation).toBeDefined(); + expect(violation.reason).toBe('file-deleted'); + expect(violation.consumers.map((c) => c.file)).toContain('src/consumer.js'); + } finally { + fs.rmSync(projectDir, { recursive: true, force: true }); + } + }); + + test('does not flag deleting a file whose exports have no external callers', () => { + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-check-delfile-safe-')); + fs.mkdirSync(path.join(projectDir, '.codegraph')); + fs.mkdirSync(path.join(projectDir, 'src'), { recursive: true }); + const projectDbPath = path.join(projectDir, '.codegraph', 'graph.db'); + + const projectDb = new Database(projectDbPath); + projectDb.pragma('journal_mode = WAL'); + initSchema(projectDb); + // src/orphan.js exports unusedHelper — zero callers anywhere. + insertNode(projectDb, 'unusedHelper', 'function', 'src/orphan.js', 1, 3, 1); + projectDb.close(); + + const { execFileSync } = require('node:child_process'); + try { + execFileSync('git', ['init'], { cwd: projectDir, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.email', 'test@test.com'], { + cwd: projectDir, + stdio: 'pipe', + }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: projectDir, stdio: 'pipe' }); + + fs.writeFileSync( + path.join(projectDir, 'src', 'orphan.js'), + 'export function unusedHelper() {\n return 1;\n}\n', + ); + execFileSync('git', ['add', '.'], { cwd: projectDir, stdio: 'pipe' }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: projectDir, stdio: 'pipe' }); + + execFileSync('git', ['rm', 'src/orphan.js'], { cwd: projectDir, stdio: 'pipe' }); + + const data = checkData(projectDbPath, { + staged: true, + signatures: true, + cycles: false, + boundaries: false, + }); + + expect(data.error).toBeUndefined(); + expect(data.summary.deletedFiles).toBe(1); + expect(data.passed).toBe(true); + const sigPred = data.predicates.find((p) => p.name === 'signatures'); + expect(sigPred).toBeDefined(); + expect(sigPred.passed).toBe(true); + expect(sigPred.violations).toEqual([]); + } finally { + fs.rmSync(projectDir, { recursive: true, force: true }); + } + }); +});