Skip to content

Commit 089bfdb

Browse files
authored
test: incremental edge parity CI check (#539)
* test: add incremental edge parity CI check Four mutation scenarios verify that incremental rebuilds produce identical edges to clean full builds: comment-only touch, body edit, new export addition, and file deletion. Each scenario compares node and edge identity between incremental and full builds. * fix: add missing Scenario 4 to JSDoc header (#539) * fix: wrap readEdges/readNodes in try/finally to prevent SQLite handle leaks (#539) * fix: add post-replace assertions to catch silent mutation failures (#539) * test: use per-replacement assertions in Scenario 4 mutation guard
1 parent 9853c35 commit 089bfdb

1 file changed

Lines changed: 250 additions & 0 deletions

File tree

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
/**
2+
* Incremental edge parity CI check.
3+
*
4+
* Verifies that incremental rebuilds produce exactly the same edges as a
5+
* clean full build, across multiple mutation scenarios:
6+
* 1. Comment-only touch (no semantic change)
7+
* 2. Body edit (change implementation, keep exports)
8+
* 3. New export added (structural change)
9+
* 4. File deletion (stale edges must be purged)
10+
*
11+
* Uses the sample-project fixture (CJS, classes, cross-file calls) for
12+
* broader edge coverage than the barrel-project fixture.
13+
*/
14+
15+
import fs from 'node:fs';
16+
import os from 'node:os';
17+
import path from 'node:path';
18+
import Database from 'better-sqlite3';
19+
import { beforeAll, describe, expect, it } from 'vitest';
20+
import { buildGraph } from '../../src/domain/graph/builder.js';
21+
22+
const FIXTURE_DIR = path.join(import.meta.dirname, '..', 'fixtures', 'sample-project');
23+
24+
function copyDirSync(src, dest) {
25+
fs.mkdirSync(dest, { recursive: true });
26+
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
27+
const s = path.join(src, entry.name);
28+
const d = path.join(dest, entry.name);
29+
if (entry.isDirectory()) copyDirSync(s, d);
30+
else fs.copyFileSync(s, d);
31+
}
32+
}
33+
34+
function readEdges(dbPath) {
35+
const db = new Database(dbPath, { readonly: true });
36+
try {
37+
const edges = db
38+
.prepare(
39+
`SELECT n1.name AS source_name, n2.name AS target_name, e.kind
40+
FROM edges e
41+
JOIN nodes n1 ON e.source_id = n1.id
42+
JOIN nodes n2 ON e.target_id = n2.id
43+
ORDER BY n1.name, n2.name, e.kind`,
44+
)
45+
.all();
46+
return edges;
47+
} finally {
48+
db.close();
49+
}
50+
}
51+
52+
function readNodes(dbPath) {
53+
const db = new Database(dbPath, { readonly: true });
54+
try {
55+
const nodes = db.prepare('SELECT name, kind, file FROM nodes ORDER BY name, kind, file').all();
56+
return nodes;
57+
} finally {
58+
db.close();
59+
}
60+
}
61+
62+
function edgeKey(e) {
63+
return `${e.source_name} -[${e.kind}]-> ${e.target_name}`;
64+
}
65+
66+
/**
67+
* Build a full-build copy and an incremental-build copy after applying
68+
* the same mutation to both, then compare edges.
69+
*/
70+
async function buildAndCompare(fixtureDir, mutate) {
71+
const tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-edge-parity-'));
72+
const fullDir = path.join(tmpBase, 'full');
73+
const incrDir = path.join(tmpBase, 'incr');
74+
75+
try {
76+
copyDirSync(fixtureDir, fullDir);
77+
copyDirSync(fixtureDir, incrDir);
78+
79+
// Initial full build on the incr copy (establishes baseline hashes)
80+
await buildGraph(incrDir, { incremental: false, skipRegistry: true });
81+
82+
// Apply the mutation to both copies
83+
mutate(fullDir);
84+
mutate(incrDir);
85+
86+
// Full build on the full copy (clean, from scratch)
87+
await buildGraph(fullDir, { incremental: false, skipRegistry: true });
88+
// Incremental rebuild on the incr copy
89+
await buildGraph(incrDir, { incremental: true, skipRegistry: true });
90+
91+
const fullEdges = readEdges(path.join(fullDir, '.codegraph', 'graph.db'));
92+
const incrEdges = readEdges(path.join(incrDir, '.codegraph', 'graph.db'));
93+
const fullNodes = readNodes(path.join(fullDir, '.codegraph', 'graph.db'));
94+
const incrNodes = readNodes(path.join(incrDir, '.codegraph', 'graph.db'));
95+
96+
return { fullEdges, incrEdges, fullNodes, incrNodes };
97+
} finally {
98+
fs.rmSync(tmpBase, { recursive: true, force: true });
99+
}
100+
}
101+
102+
describe('Incremental edge parity (CI gate)', () => {
103+
// Scenario 1: Comment-only touch — edges must be identical
104+
describe('comment-only touch', () => {
105+
let result;
106+
107+
beforeAll(async () => {
108+
result = await buildAndCompare(FIXTURE_DIR, (dir) => {
109+
const p = path.join(dir, 'math.js');
110+
fs.appendFileSync(p, '\n// comment touch\n');
111+
});
112+
}, 60_000);
113+
114+
it('edge count matches', () => {
115+
expect(result.incrEdges.length).toBe(result.fullEdges.length);
116+
});
117+
118+
it('edges are identical', () => {
119+
expect(result.incrEdges).toEqual(result.fullEdges);
120+
});
121+
});
122+
123+
// Scenario 2: Body edit — change function implementation, keep exports
124+
describe('body edit (same exports)', () => {
125+
let result;
126+
127+
beforeAll(async () => {
128+
result = await buildAndCompare(FIXTURE_DIR, (dir) => {
129+
const p = path.join(dir, 'math.js');
130+
let src = fs.readFileSync(p, 'utf-8');
131+
// Change add implementation but keep the same signature and exports
132+
src = src.replace('return a + b;', 'return b + a;');
133+
if (!src.includes('return b + a;'))
134+
throw new Error('Mutation failed: target string not found in math.js');
135+
fs.writeFileSync(p, src);
136+
});
137+
}, 60_000);
138+
139+
it('edge count matches', () => {
140+
expect(result.incrEdges.length).toBe(result.fullEdges.length);
141+
});
142+
143+
it('edges are identical', () => {
144+
expect(result.incrEdges).toEqual(result.fullEdges);
145+
});
146+
});
147+
148+
// Scenario 3: New export added — edges from consumers should resolve
149+
describe('new export added', () => {
150+
let result;
151+
152+
beforeAll(async () => {
153+
result = await buildAndCompare(FIXTURE_DIR, (dir) => {
154+
const mathPath = path.join(dir, 'math.js');
155+
let src = fs.readFileSync(mathPath, 'utf-8');
156+
// Add a new function before the module.exports line
157+
src = src.replace(
158+
'module.exports = { add, multiply, square };',
159+
`function subtract(a, b) {\n return a - b;\n}\n\nmodule.exports = { add, multiply, square, subtract };`,
160+
);
161+
if (!src.includes('subtract'))
162+
throw new Error('Mutation failed: module.exports replacement not applied in math.js');
163+
fs.writeFileSync(mathPath, src);
164+
165+
// Have index.js import and call the new function
166+
const indexPath = path.join(dir, 'index.js');
167+
let indexSrc = fs.readFileSync(indexPath, 'utf-8');
168+
indexSrc = indexSrc.replace(
169+
"const { add } = require('./math');",
170+
"const { add, subtract } = require('./math');",
171+
);
172+
if (!indexSrc.includes('subtract'))
173+
throw new Error('Mutation failed: require replacement not applied in index.js');
174+
indexSrc = indexSrc.replace(
175+
'console.log(add(1, 2));',
176+
'console.log(add(1, 2));\n console.log(subtract(5, 3));',
177+
);
178+
if (!indexSrc.includes('subtract(5, 3)'))
179+
throw new Error('Mutation failed: console.log replacement not applied in index.js');
180+
fs.writeFileSync(indexPath, indexSrc);
181+
});
182+
}, 60_000);
183+
184+
it('node count matches', () => {
185+
expect(result.incrNodes.length).toBe(result.fullNodes.length);
186+
});
187+
188+
it('edge count matches', () => {
189+
expect(result.incrEdges.length).toBe(result.fullEdges.length);
190+
});
191+
192+
it('edges are identical', () => {
193+
if (result.incrEdges.length !== result.fullEdges.length) {
194+
// Diagnostic: show which edges differ
195+
const fullSet = new Set(result.fullEdges.map(edgeKey));
196+
const incrSet = new Set(result.incrEdges.map(edgeKey));
197+
const missingInIncr = [...fullSet].filter((k) => !incrSet.has(k));
198+
const extraInIncr = [...incrSet].filter((k) => !fullSet.has(k));
199+
expect.fail(
200+
`Edge mismatch:\n Missing in incremental: ${missingInIncr.join(', ') || 'none'}\n Extra in incremental: ${extraInIncr.join(', ') || 'none'}`,
201+
);
202+
}
203+
expect(result.incrEdges).toEqual(result.fullEdges);
204+
});
205+
});
206+
207+
// Scenario 4: File deletion — stale edges must be purged
208+
describe('file deletion', () => {
209+
let result;
210+
211+
beforeAll(async () => {
212+
result = await buildAndCompare(FIXTURE_DIR, (dir) => {
213+
// Delete utils.js — edges involving sumOfSquares/Calculator should disappear
214+
fs.unlinkSync(path.join(dir, 'utils.js'));
215+
// Update index.js to remove the require
216+
const indexPath = path.join(dir, 'index.js');
217+
let src = fs.readFileSync(indexPath, 'utf-8');
218+
let prev = src;
219+
src = src.replace("const { sumOfSquares, Calculator } = require('./utils');\n", '');
220+
if (src === prev)
221+
throw new Error('Mutation failed: require(./utils) not found in index.js');
222+
prev = src;
223+
src = src.replace(' console.log(sumOfSquares(3, 4));\n', '');
224+
if (src === prev)
225+
throw new Error('Mutation failed: sumOfSquares call not found in index.js');
226+
prev = src;
227+
src = src.replace(' const calc = new Calculator();\n', '');
228+
if (src === prev)
229+
throw new Error('Mutation failed: Calculator instantiation not found in index.js');
230+
prev = src;
231+
src = src.replace(' console.log(calc.compute(5, 6));\n', '');
232+
if (src === prev)
233+
throw new Error('Mutation failed: calc.compute call not found in index.js');
234+
fs.writeFileSync(indexPath, src);
235+
});
236+
}, 60_000);
237+
238+
it('node count matches', () => {
239+
expect(result.incrNodes.length).toBe(result.fullNodes.length);
240+
});
241+
242+
it('edge count matches', () => {
243+
expect(result.incrEdges.length).toBe(result.fullEdges.length);
244+
});
245+
246+
it('edges are identical', () => {
247+
expect(result.incrEdges).toEqual(result.fullEdges);
248+
});
249+
});
250+
});

0 commit comments

Comments
 (0)