|
| 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