Skip to content

Commit 09d0539

Browse files
committed
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.
1 parent 54e6115 commit 09d0539

1 file changed

Lines changed: 223 additions & 0 deletions

File tree

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

0 commit comments

Comments
 (0)