@remnic/coding-graph 9.3.759
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +130 -0
- package/dist/chunk-5I2DBHOQ.js +1042 -0
- package/dist/chunk-5I2DBHOQ.js.map +1 -0
- package/dist/chunk-CPYJACC5.js +1838 -0
- package/dist/chunk-CPYJACC5.js.map +1 -0
- package/dist/chunk-ZVCMIM4T.js +216 -0
- package/dist/chunk-ZVCMIM4T.js.map +1 -0
- package/dist/cypher/query-parser.d.ts +253 -0
- package/dist/cypher/query-parser.js +17 -0
- package/dist/cypher/query-parser.js.map +1 -0
- package/dist/graph-schema.d.ts +84 -0
- package/dist/graph-schema.js +17 -0
- package/dist/graph-schema.js.map +1 -0
- package/dist/graph-store.d.ts +938 -0
- package/dist/graph-store.js +16 -0
- package/dist/graph-store.js.map +1 -0
- package/dist/index.d.ts +1953 -0
- package/dist/index.js +3509 -0
- package/dist/index.js.map +1 -0
- package/grammars/tree-sitter-bash.wasm +0 -0
- package/grammars/tree-sitter-c.wasm +0 -0
- package/grammars/tree-sitter-c_sharp.wasm +0 -0
- package/grammars/tree-sitter-cpp.wasm +0 -0
- package/grammars/tree-sitter-go.wasm +0 -0
- package/grammars/tree-sitter-java.wasm +0 -0
- package/grammars/tree-sitter-javascript.wasm +0 -0
- package/grammars/tree-sitter-kotlin.wasm +0 -0
- package/grammars/tree-sitter-php.wasm +0 -0
- package/grammars/tree-sitter-python.wasm +0 -0
- package/grammars/tree-sitter-ruby.wasm +0 -0
- package/grammars/tree-sitter-rust.wasm +0 -0
- package/grammars/tree-sitter-swift.wasm +0 -0
- package/grammars/tree-sitter-tsx.wasm +0 -0
- package/grammars/tree-sitter-typescript.wasm +0 -0
- package/package.json +79 -0
- package/src/co-change.test.ts +175 -0
- package/src/co-change.ts +167 -0
- package/src/cypher/query-parser.test.ts +1107 -0
- package/src/cypher/query-parser.ts +1692 -0
- package/src/detect-changes.test.ts +533 -0
- package/src/detect-changes.ts +367 -0
- package/src/engine/emit.ts +556 -0
- package/src/engine/engine.test.ts +1417 -0
- package/src/engine/engine.ts +182 -0
- package/src/engine/extractors.ts +486 -0
- package/src/engine/fixtures.ts +364 -0
- package/src/engine/language-sniff.ts +56 -0
- package/src/engine/parser-backend.ts +206 -0
- package/src/engine/utf16-offsets.ts +68 -0
- package/src/git-invoker.test.ts +116 -0
- package/src/git-invoker.ts +426 -0
- package/src/graph-schema.test.ts +541 -0
- package/src/graph-schema.ts +383 -0
- package/src/graph-store-pr2.test.ts +1879 -0
- package/src/graph-store.test.ts +1420 -0
- package/src/graph-store.ts +3489 -0
- package/src/index-status.test.ts +303 -0
- package/src/index-status.ts +135 -0
- package/src/index.ts +384 -0
- package/src/lsp/byte-position.ts +173 -0
- package/src/lsp/characterization.test.ts +174 -0
- package/src/lsp/client.test.ts +275 -0
- package/src/lsp/client.ts +484 -0
- package/src/lsp/config.ts +219 -0
- package/src/lsp/degradation.ts +86 -0
- package/src/lsp/fixtures/fake-server.mjs +198 -0
- package/src/lsp/framing.test.ts +180 -0
- package/src/lsp/framing.ts +177 -0
- package/src/lsp/resolution.test.ts +497 -0
- package/src/lsp/resolution.ts +483 -0
- package/src/lsp/status.ts +140 -0
- package/src/lsp/types.ts +167 -0
- package/src/reindex.test.ts +1038 -0
- package/src/reindex.ts +908 -0
- package/src/row-types.ts +45 -0
- package/src/semantic/canonical-text.test.ts +150 -0
- package/src/semantic/canonical-text.ts +219 -0
- package/src/semantic/config.ts +235 -0
- package/src/semantic/index.ts +78 -0
- package/src/semantic/minhash.test.ts +197 -0
- package/src/semantic/minhash.ts +261 -0
- package/src/semantic/semantic-query.ts +173 -0
- package/src/semantic/semantic.test.ts +1315 -0
- package/src/semantic/similarity.ts +268 -0
- package/src/semantic/types.ts +145 -0
- package/src/semantic/vectors.ts +235 -0
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* detect_changes + blast radius tests (issue #1553).
|
|
3
|
+
*
|
|
4
|
+
* Blast-radius rubric fixtures: a fixture graph where the same change
|
|
5
|
+
* yields direct/near/transitive classifications; fan-in escalation
|
|
6
|
+
* boundary tested at the exact threshold.
|
|
7
|
+
*
|
|
8
|
+
* detect_changes output for a fixture diff is byte-stable (same diff +
|
|
9
|
+
* graph → same output, regardless of Set iteration order).
|
|
10
|
+
*/
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import test from "node:test";
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
GraphStore,
|
|
19
|
+
nodeIdFor,
|
|
20
|
+
type EdgeIR,
|
|
21
|
+
type StoreFileIR,
|
|
22
|
+
type SymbolIR,
|
|
23
|
+
} from "./graph-store.js";
|
|
24
|
+
import {
|
|
25
|
+
classifyRisk,
|
|
26
|
+
computeBlastRadius,
|
|
27
|
+
type AffectedSymbol,
|
|
28
|
+
findDirectlyAffectedSymbols,
|
|
29
|
+
byteSpanToLines,
|
|
30
|
+
rangesOverlap,
|
|
31
|
+
FAN_IN_ESCALATION_THRESHOLD,
|
|
32
|
+
} from "./detect-changes.js";
|
|
33
|
+
import type { DiffHunk } from "./git-invoker.js";
|
|
34
|
+
import type { FileIR } from "@remnic/core";
|
|
35
|
+
|
|
36
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
37
|
+
// Fixture helpers
|
|
38
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
function sym(
|
|
41
|
+
qualifiedName: string,
|
|
42
|
+
name: string,
|
|
43
|
+
startByte: number,
|
|
44
|
+
endByte: number,
|
|
45
|
+
kind: SymbolIR["kind"] = "function",
|
|
46
|
+
): SymbolIR {
|
|
47
|
+
return { qualifiedName, name, span: { startByte, endByte }, kind };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function edge(
|
|
51
|
+
srcQualifiedName: string,
|
|
52
|
+
dstQualifiedName: string,
|
|
53
|
+
type = "CALLS",
|
|
54
|
+
confidence = 0.9,
|
|
55
|
+
provenance: EdgeIR["provenance"] = "heuristic",
|
|
56
|
+
): EdgeIR {
|
|
57
|
+
return { srcQualifiedName, dstQualifiedName, type, confidence, provenance };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function tempStore(): Promise<{ store: GraphStore; dir: string }> {
|
|
61
|
+
const dir = await mkdtemp(path.join(tmpdir(), "detect-changes-test-"));
|
|
62
|
+
const store = await GraphStore.open({
|
|
63
|
+
dbPath: path.join(dir, "graph.sqlite"),
|
|
64
|
+
repoRoot: dir,
|
|
65
|
+
});
|
|
66
|
+
return { store, dir };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Unwrap computeBlastRadius for characterization tests — the store is
|
|
71
|
+
* healthy here so the result is always ok. Regression tests for the
|
|
72
|
+
* store-failure path call computeBlastRadius directly and assert the
|
|
73
|
+
* tagged shape (rule 22).
|
|
74
|
+
*/
|
|
75
|
+
function blast(
|
|
76
|
+
store: GraphStore,
|
|
77
|
+
ids: ReadonlySet<string>,
|
|
78
|
+
depth: number,
|
|
79
|
+
): readonly AffectedSymbol[] {
|
|
80
|
+
const r = computeBlastRadius(store, ids, depth);
|
|
81
|
+
if (!r.ok) throw new Error("expected blast radius ok in characterization test");
|
|
82
|
+
return r.affected;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function dispose(store: GraphStore, dir: string): Promise<void> {
|
|
86
|
+
await store.close();
|
|
87
|
+
await rm(dir, { recursive: true, force: true });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
91
|
+
// Line-range overlap tests (rule 35: half-open intervals)
|
|
92
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
test("rangesOverlap: half-open boundaries", () => {
|
|
95
|
+
// [1,5) vs [5,10) → NO overlap (boundary touch)
|
|
96
|
+
assert.equal(
|
|
97
|
+
rangesOverlap({ startLine: 1, endLine: 5 }, { startLine: 5, endLine: 10 }),
|
|
98
|
+
false,
|
|
99
|
+
);
|
|
100
|
+
// [1,5) vs [4,10) → overlap
|
|
101
|
+
assert.equal(
|
|
102
|
+
rangesOverlap({ startLine: 1, endLine: 5 }, { startLine: 4, endLine: 10 }),
|
|
103
|
+
true,
|
|
104
|
+
);
|
|
105
|
+
// [1,5) vs [1,5) → overlap (same range)
|
|
106
|
+
assert.equal(
|
|
107
|
+
rangesOverlap({ startLine: 1, endLine: 5 }, { startLine: 1, endLine: 5 }),
|
|
108
|
+
true,
|
|
109
|
+
);
|
|
110
|
+
// [1,5) vs [0,1) → NO overlap (hunk ends exactly at symbol start)
|
|
111
|
+
assert.equal(
|
|
112
|
+
rangesOverlap({ startLine: 1, endLine: 5 }, { startLine: 0, endLine: 1 }),
|
|
113
|
+
false,
|
|
114
|
+
);
|
|
115
|
+
// Single-line: [3,4) vs [3,4) → overlap
|
|
116
|
+
assert.equal(
|
|
117
|
+
rangesOverlap({ startLine: 3, endLine: 4 }, { startLine: 3, endLine: 4 }),
|
|
118
|
+
true,
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("byteSpanToLines: basic conversion", () => {
|
|
123
|
+
const content = new TextEncoder().encode("line1\nline2\nline3");
|
|
124
|
+
// Bytes 0-5 = "line1" → lines {1, 2} (exclusive end)
|
|
125
|
+
const r1 = byteSpanToLines(content, 0, 5);
|
|
126
|
+
assert.equal(r1.startLine, 1);
|
|
127
|
+
assert.equal(r1.endLine, 2);
|
|
128
|
+
// Bytes 6-11 = "line2" → lines {2, 3}
|
|
129
|
+
const r2 = byteSpanToLines(content, 6, 11);
|
|
130
|
+
assert.equal(r2.startLine, 2);
|
|
131
|
+
assert.equal(r2.endLine, 3);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
135
|
+
// Risk classification rubric
|
|
136
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
test("classifyRisk: depth → risk mapping", () => {
|
|
139
|
+
assert.equal(classifyRisk(0, 0), "direct");
|
|
140
|
+
assert.equal(classifyRisk(1, 0), "near");
|
|
141
|
+
assert.equal(classifyRisk(2, 0), "transitive");
|
|
142
|
+
assert.equal(classifyRisk(3, 0), "transitive");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("classifyRisk: fan-in escalation at exact threshold", () => {
|
|
146
|
+
// Threshold is 5. At threshold-1 (4), no escalation.
|
|
147
|
+
assert.equal(classifyRisk(1, FAN_IN_ESCALATION_THRESHOLD - 1), "near");
|
|
148
|
+
// At threshold (5), near → direct.
|
|
149
|
+
assert.equal(classifyRisk(1, FAN_IN_ESCALATION_THRESHOLD), "direct");
|
|
150
|
+
// At threshold (5), transitive → near.
|
|
151
|
+
assert.equal(classifyRisk(2, FAN_IN_ESCALATION_THRESHOLD), "near");
|
|
152
|
+
// Direct stays direct (capped).
|
|
153
|
+
assert.equal(classifyRisk(0, FAN_IN_ESCALATION_THRESHOLD), "direct");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
157
|
+
// Blast-radius fixtures — same change yields direct/near/transitive
|
|
158
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Fixture: a → b → c → d (chain). Changing `a` should classify:
|
|
162
|
+
* a → direct (depth 0)
|
|
163
|
+
* (nothing calls a, so no near/transitive from a)
|
|
164
|
+
*
|
|
165
|
+
* Changing `d` should classify via reverse BFS:
|
|
166
|
+
* d → direct (depth 0)
|
|
167
|
+
* c → near (depth 1, c calls d)
|
|
168
|
+
* b → transitive (depth 2, b calls c)
|
|
169
|
+
* a → transitive (depth 3, a calls b)
|
|
170
|
+
*/
|
|
171
|
+
const CHAIN_FILE: StoreFileIR = {
|
|
172
|
+
path: "src/chain.ts",
|
|
173
|
+
language: "typescript",
|
|
174
|
+
contentHash: "h-chain",
|
|
175
|
+
symbols: [
|
|
176
|
+
sym("chain.a", "a", 0, 10),
|
|
177
|
+
sym("chain.b", "b", 10, 20),
|
|
178
|
+
sym("chain.c", "c", 20, 30),
|
|
179
|
+
sym("chain.d", "d", 30, 40),
|
|
180
|
+
],
|
|
181
|
+
edges: [
|
|
182
|
+
edge("chain.a", "chain.b"), // a calls b
|
|
183
|
+
edge("chain.b", "chain.c"), // b calls c
|
|
184
|
+
edge("chain.c", "chain.d"), // c calls d
|
|
185
|
+
],
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
test("computeBlastRadius: chain fixture — d changed yields direct/near/transitive", async () => {
|
|
189
|
+
const { store, dir } = await tempStore();
|
|
190
|
+
try {
|
|
191
|
+
const result = await store.upsertFileBatch([CHAIN_FILE]);
|
|
192
|
+
assert.equal(result.ok, true);
|
|
193
|
+
|
|
194
|
+
// Simulate: `d` is directly affected. Reverse BFS finds who depends on d.
|
|
195
|
+
const affected = blast(store, new Set(["chain.d"]), 3);
|
|
196
|
+
assert.equal(affected.length, 4);
|
|
197
|
+
|
|
198
|
+
// d is direct (depth 0).
|
|
199
|
+
const d = affected.find((a) => a.qualifiedName === "chain.d");
|
|
200
|
+
assert.ok(d);
|
|
201
|
+
assert.equal(d!.risk, "direct");
|
|
202
|
+
assert.equal(d!.depth, 0);
|
|
203
|
+
|
|
204
|
+
// c is near (depth 1 — c calls d).
|
|
205
|
+
const c = affected.find((a) => a.qualifiedName === "chain.c");
|
|
206
|
+
assert.ok(c);
|
|
207
|
+
assert.equal(c!.risk, "near");
|
|
208
|
+
assert.equal(c!.depth, 1);
|
|
209
|
+
|
|
210
|
+
// b is transitive (depth 2).
|
|
211
|
+
const b = affected.find((a) => a.qualifiedName === "chain.b");
|
|
212
|
+
assert.ok(b);
|
|
213
|
+
assert.equal(b!.risk, "transitive");
|
|
214
|
+
assert.equal(b!.depth, 2);
|
|
215
|
+
|
|
216
|
+
// a is transitive (depth 3).
|
|
217
|
+
const a = affected.find((a) => a.qualifiedName === "chain.a");
|
|
218
|
+
assert.ok(a);
|
|
219
|
+
assert.equal(a!.risk, "transitive");
|
|
220
|
+
assert.equal(a!.depth, 3);
|
|
221
|
+
} finally {
|
|
222
|
+
await dispose(store, dir);
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("computeBlastRadius: byte-stable — same input, same output", async () => {
|
|
227
|
+
const { store, dir } = await tempStore();
|
|
228
|
+
try {
|
|
229
|
+
await store.upsertFileBatch([CHAIN_FILE]);
|
|
230
|
+
|
|
231
|
+
const affected1 = blast(store, new Set(["chain.d"]), 3);
|
|
232
|
+
const affected2 = blast(store, new Set(["chain.d"]), 3);
|
|
233
|
+
|
|
234
|
+
// Deep equal — same order, same classifications.
|
|
235
|
+
assert.deepEqual(
|
|
236
|
+
affected1.map((a) => ({ q: a.qualifiedName, r: a.risk, d: a.depth })),
|
|
237
|
+
affected2.map((a) => ({ q: a.qualifiedName, r: a.risk, d: a.depth })),
|
|
238
|
+
);
|
|
239
|
+
} finally {
|
|
240
|
+
await dispose(store, dir);
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("computeBlastRadius: empty affected set → empty result", async () => {
|
|
245
|
+
const { store, dir } = await tempStore();
|
|
246
|
+
try {
|
|
247
|
+
const affected = blast(store, new Set(), 3);
|
|
248
|
+
assert.equal(affected.length, 0);
|
|
249
|
+
} finally {
|
|
250
|
+
await dispose(store, dir);
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Fixture for fan-in escalation:
|
|
256
|
+
* caller1 → mid → target
|
|
257
|
+
* caller2 → mid
|
|
258
|
+
* caller3 → mid
|
|
259
|
+
* caller4 → mid
|
|
260
|
+
* caller5 → mid
|
|
261
|
+
*
|
|
262
|
+
* When `target` is directly affected:
|
|
263
|
+
* target → direct (depth 0)
|
|
264
|
+
* mid → near (depth 1, mid calls target). mid has fanIn=5 ≥ threshold.
|
|
265
|
+
* Fan-in escalates near → direct.
|
|
266
|
+
* caller1-5 → transitive (depth 2).
|
|
267
|
+
*/
|
|
268
|
+
const FAN_IN_FILE: StoreFileIR = {
|
|
269
|
+
path: "src/fanin.ts",
|
|
270
|
+
language: "typescript",
|
|
271
|
+
contentHash: "h-fanin",
|
|
272
|
+
symbols: [
|
|
273
|
+
sym("fanin.target", "target", 0, 10),
|
|
274
|
+
sym("fanin.mid", "mid", 10, 20),
|
|
275
|
+
sym("fanin.caller1", "caller1", 20, 30),
|
|
276
|
+
sym("fanin.caller2", "caller2", 30, 40),
|
|
277
|
+
sym("fanin.caller3", "caller3", 40, 50),
|
|
278
|
+
sym("fanin.caller4", "caller4", 50, 60),
|
|
279
|
+
sym("fanin.caller5", "caller5", 60, 70),
|
|
280
|
+
],
|
|
281
|
+
edges: [
|
|
282
|
+
edge("fanin.mid", "fanin.target"),
|
|
283
|
+
edge("fanin.caller1", "fanin.mid"),
|
|
284
|
+
edge("fanin.caller2", "fanin.mid"),
|
|
285
|
+
edge("fanin.caller3", "fanin.mid"),
|
|
286
|
+
edge("fanin.caller4", "fanin.mid"),
|
|
287
|
+
edge("fanin.caller5", "fanin.mid"),
|
|
288
|
+
],
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
test("computeBlastRadius: fan-in escalation — mid escalates near→direct", async () => {
|
|
292
|
+
const { store, dir } = await tempStore();
|
|
293
|
+
try {
|
|
294
|
+
await store.upsertFileBatch([FAN_IN_FILE]);
|
|
295
|
+
|
|
296
|
+
// Change target. mid is 1 hop away (near) but has fanIn=5 ≥ 5.
|
|
297
|
+
const affected = blast(store, new Set(["fanin.target"]), 3);
|
|
298
|
+
const mid = affected.find((a) => a.qualifiedName === "fanin.mid");
|
|
299
|
+
assert.ok(mid, "mid should be in blast radius");
|
|
300
|
+
assert.equal(mid!.depth, 1);
|
|
301
|
+
// Fan-in escalates near → direct.
|
|
302
|
+
assert.equal(mid!.risk, "direct");
|
|
303
|
+
assert.ok(mid!.fanIn >= FAN_IN_ESCALATION_THRESHOLD);
|
|
304
|
+
} finally {
|
|
305
|
+
await dispose(store, dir);
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
310
|
+
// findDirectlyAffectedSymbols — hunk → span overlap
|
|
311
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
312
|
+
|
|
313
|
+
test("findDirectlyAffectedSymbols: hunk overlapping a symbol span", () => {
|
|
314
|
+
const content = new TextEncoder().encode("function foo() {\n return 1;\n}\n");
|
|
315
|
+
// foo spans bytes 0-23 (lines 1-3).
|
|
316
|
+
const ir: FileIR = {
|
|
317
|
+
path: "src/a.ts",
|
|
318
|
+
language: "typescript",
|
|
319
|
+
contentHash: "h",
|
|
320
|
+
symbols: [
|
|
321
|
+
{
|
|
322
|
+
kind: "function",
|
|
323
|
+
name: "foo",
|
|
324
|
+
qualifiedName: "a::foo",
|
|
325
|
+
span: { startByte: 0, endByte: 23 },
|
|
326
|
+
},
|
|
327
|
+
],
|
|
328
|
+
imports: [],
|
|
329
|
+
exports: [],
|
|
330
|
+
callSites: [],
|
|
331
|
+
routes: [],
|
|
332
|
+
};
|
|
333
|
+
const hunks = new Map<string, readonly DiffHunk[]>([
|
|
334
|
+
["src/a.ts", [{ path: "src/a.ts", newRange: { startLine: 2, endLine: 3 } }]],
|
|
335
|
+
]);
|
|
336
|
+
const irs = new Map([["src/a.ts", ir]]);
|
|
337
|
+
const contents = new Map([["src/a.ts", content]]);
|
|
338
|
+
const affected = findDirectlyAffectedSymbols(hunks, irs, contents);
|
|
339
|
+
// Returns node ids (qualifiedName + filePath + label identity).
|
|
340
|
+
assert.ok(
|
|
341
|
+
affected.has(nodeIdFor({ qualifiedName: "a::foo", filePath: "src/a.ts", label: "function" })),
|
|
342
|
+
"foo's node id is affected",
|
|
343
|
+
);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test("findDirectlyAffectedSymbols: hunk NOT overlapping → not affected", () => {
|
|
347
|
+
// Content with clear line boundaries:
|
|
348
|
+
// Line 1: "function foo() {"
|
|
349
|
+
// Line 2: " return 1;"
|
|
350
|
+
// Line 3: "}"
|
|
351
|
+
// Line 4: "function bar() {}"
|
|
352
|
+
// Byte layout: "function foo() {\n return 1;\n}\nfunction bar() {}\n"
|
|
353
|
+
// foo = bytes [0, 31) = lines [1, 4)
|
|
354
|
+
// bar = bytes [31, 49) = line [4, 5)
|
|
355
|
+
const content = new TextEncoder().encode("function foo() {\n return 1;\n}\nfunction bar() {}\n");
|
|
356
|
+
const fooEnd = "function foo() {\n return 1;\n}\n".length; // 31
|
|
357
|
+
const ir: FileIR = {
|
|
358
|
+
path: "src/a.ts",
|
|
359
|
+
language: "typescript",
|
|
360
|
+
contentHash: "h",
|
|
361
|
+
symbols: [
|
|
362
|
+
{
|
|
363
|
+
kind: "function",
|
|
364
|
+
name: "foo",
|
|
365
|
+
qualifiedName: "a::foo",
|
|
366
|
+
span: { startByte: 0, endByte: fooEnd },
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
kind: "function",
|
|
370
|
+
name: "bar",
|
|
371
|
+
qualifiedName: "a::bar",
|
|
372
|
+
span: { startByte: fooEnd, endByte: content.length },
|
|
373
|
+
},
|
|
374
|
+
],
|
|
375
|
+
imports: [],
|
|
376
|
+
exports: [],
|
|
377
|
+
callSites: [],
|
|
378
|
+
routes: [],
|
|
379
|
+
};
|
|
380
|
+
// Hunk on line 4 (bar's line), not foo (lines 1-3).
|
|
381
|
+
const hunks = new Map<string, readonly DiffHunk[]>([
|
|
382
|
+
["src/a.ts", [{ path: "src/a.ts", newRange: { startLine: 4, endLine: 5 } }]],
|
|
383
|
+
]);
|
|
384
|
+
const irs = new Map([["src/a.ts", ir]]);
|
|
385
|
+
const contents = new Map([["src/a.ts", content]]);
|
|
386
|
+
const affected = findDirectlyAffectedSymbols(hunks, irs, contents);
|
|
387
|
+
assert.ok(
|
|
388
|
+
!affected.has(nodeIdFor({ qualifiedName: "a::foo", filePath: "src/a.ts", label: "function" })),
|
|
389
|
+
"foo should NOT be affected",
|
|
390
|
+
);
|
|
391
|
+
assert.ok(
|
|
392
|
+
affected.has(nodeIdFor({ qualifiedName: "a::bar", filePath: "src/a.ts", label: "function" })),
|
|
393
|
+
"bar should be affected",
|
|
394
|
+
);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
398
|
+
// cursor Bugbot fix — filePath resolution for duplicate simple names
|
|
399
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Fixture: two files both declaring a symbol named `foo` (same simple
|
|
403
|
+
* name, different qualified names). a.ts::foo CALLS b.ts::foo. When b's
|
|
404
|
+
* foo changes, the blast radius must report a.ts::foo with filePath
|
|
405
|
+
* `src/a.ts` — NOT the wrong file resolved by a name-only search.
|
|
406
|
+
*
|
|
407
|
+
* Before the fix, computeBlastRadius resolved filePath via searchGraph
|
|
408
|
+
* ({ namePattern: "foo", limit: 1 }), which could attach either file's
|
|
409
|
+
* path to the affected symbol (cursor Bugbot: 'Blast radius wrong file
|
|
410
|
+
* path'). The fix reads filePath straight from the traverse hit.
|
|
411
|
+
*/
|
|
412
|
+
const DUP_A: StoreFileIR = {
|
|
413
|
+
path: "src/a.ts",
|
|
414
|
+
language: "typescript",
|
|
415
|
+
contentHash: "h-dup-a",
|
|
416
|
+
symbols: [sym("a.foo", "foo", 0, 30)],
|
|
417
|
+
edges: [edge("a.foo", "b.foo")], // a.ts::foo calls b.ts::foo
|
|
418
|
+
exports: [],
|
|
419
|
+
};
|
|
420
|
+
const DUP_B: StoreFileIR = {
|
|
421
|
+
path: "src/b.ts",
|
|
422
|
+
language: "typescript",
|
|
423
|
+
contentHash: "h-dup-b",
|
|
424
|
+
symbols: [sym("b.foo", "foo", 0, 30)],
|
|
425
|
+
edges: [],
|
|
426
|
+
exports: [],
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
test("computeBlastRadius: duplicate simple names keep the correct filePath (cursor Bugbot: 'Blast radius wrong file path')", async () => {
|
|
430
|
+
const { store, dir } = await tempStore();
|
|
431
|
+
try {
|
|
432
|
+
await store.upsertFileBatch([DUP_A, DUP_B]);
|
|
433
|
+
|
|
434
|
+
// b.foo changed directly. Reverse BFS finds a.foo (a calls b).
|
|
435
|
+
const affected = blast(store, new Set(["b.foo"]), 3);
|
|
436
|
+
// Both symbols appear (b.foo direct, a.foo near).
|
|
437
|
+
assert.equal(affected.length, 2);
|
|
438
|
+
|
|
439
|
+
const aHit = affected.find((x) => x.qualifiedName === "a.foo");
|
|
440
|
+
assert.ok(aHit, "a.foo is in the blast radius (it calls the changed b.foo)");
|
|
441
|
+
// The load-bearing assertion: a.foo's filePath is src/a.ts, not the
|
|
442
|
+
// wrong src/b.ts that a name-only search could return.
|
|
443
|
+
assert.equal(aHit!.filePath, "src/a.ts");
|
|
444
|
+
|
|
445
|
+
const bHit = affected.find((x) => x.qualifiedName === "b.foo");
|
|
446
|
+
assert.ok(bHit);
|
|
447
|
+
assert.equal(bHit!.filePath, "src/b.ts");
|
|
448
|
+
assert.equal(bHit!.risk, "direct");
|
|
449
|
+
} finally {
|
|
450
|
+
await dispose(store, dir);
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
test("findDirectlyAffectedSymbols: duplicate qualifiedName across files yields distinct node ids (chatgpt-codex-connector: 'Preserve node identity')", () => {
|
|
456
|
+
// Two files both declaring a symbol with the SAME qualifiedName "dup::fn".
|
|
457
|
+
// findDirectlyAffectedSymbols must return BOTH node ids (distinct because
|
|
458
|
+
// identity includes filePath), not collapse them into one.
|
|
459
|
+
const content = new TextEncoder().encode("function fn() {}\n");
|
|
460
|
+
const irA: FileIR = {
|
|
461
|
+
path: "src/a.ts", language: "typescript", contentHash: "h",
|
|
462
|
+
symbols: [{ kind: "function", name: "fn", qualifiedName: "dup::fn", span: { startByte: 0, endByte: 16 } }],
|
|
463
|
+
imports: [], exports: [], callSites: [], routes: [],
|
|
464
|
+
};
|
|
465
|
+
const irB: FileIR = {
|
|
466
|
+
path: "src/b.ts", language: "typescript", contentHash: "h",
|
|
467
|
+
symbols: [{ kind: "function", name: "fn", qualifiedName: "dup::fn", span: { startByte: 0, endByte: 16 } }],
|
|
468
|
+
imports: [], exports: [], callSites: [], routes: [],
|
|
469
|
+
};
|
|
470
|
+
const hunks = new Map<string, readonly DiffHunk[]>([
|
|
471
|
+
["src/a.ts", [{ path: "src/a.ts", newRange: { startLine: 1, endLine: 2 } }]],
|
|
472
|
+
["src/b.ts", [{ path: "src/b.ts", newRange: { startLine: 1, endLine: 2 } }]],
|
|
473
|
+
]);
|
|
474
|
+
const irs = new Map([["src/a.ts", irA], ["src/b.ts", irB]]);
|
|
475
|
+
const contents = new Map([["src/a.ts", content], ["src/b.ts", content]]);
|
|
476
|
+
const affected = findDirectlyAffectedSymbols(hunks, irs, contents);
|
|
477
|
+
const idA = nodeIdFor({ qualifiedName: "dup::fn", filePath: "src/a.ts", label: "function" });
|
|
478
|
+
const idB = nodeIdFor({ qualifiedName: "dup::fn", filePath: "src/b.ts", label: "function" });
|
|
479
|
+
assert.notEqual(idA, idB, "node ids must differ by filePath");
|
|
480
|
+
assert.ok(affected.has(idA), "a.ts node affected");
|
|
481
|
+
assert.ok(affected.has(idB), "b.ts node affected");
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
test("computeBlastRadius: byte-stable sort with duplicate qualifiedNames (cursor Bugbot: 'Blast radius sort not byte-stable')", async () => {
|
|
485
|
+
const { store, dir } = await tempStore();
|
|
486
|
+
try {
|
|
487
|
+
await store.upsertFileBatch([DUP_A, DUP_B]);
|
|
488
|
+
const r1 = blast(store, new Set(["b.foo"]), 3);
|
|
489
|
+
const r2 = blast(store, new Set(["b.foo"]), 3);
|
|
490
|
+
// Same risk/qualifiedName for a.foo across runs — order must be
|
|
491
|
+
// identical (filePath + nodeId tiebreaker makes it total).
|
|
492
|
+
assert.deepEqual(
|
|
493
|
+
r1.map((a) => ({ q: a.qualifiedName, f: a.filePath, r: a.risk })),
|
|
494
|
+
r2.map((a) => ({ q: a.qualifiedName, f: a.filePath, r: a.risk })),
|
|
495
|
+
);
|
|
496
|
+
// Both files' foo are present and distinguishable.
|
|
497
|
+
assert.equal(r1.length, 2);
|
|
498
|
+
assert.equal(new Set(r1.map((a) => a.filePath)).size, 2);
|
|
499
|
+
} finally {
|
|
500
|
+
await dispose(store, dir);
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
506
|
+
// Rule 22 — computeBlastRadius surfaces store failures instead of masking
|
|
507
|
+
// them as an empty affected set (cursor Bugbot: 'computeBlastRadius masks
|
|
508
|
+
// traverse failures'). Before the fix, `if (!result.ok) continue` conflated
|
|
509
|
+
// a backend failure with "no inbound edges".
|
|
510
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
511
|
+
|
|
512
|
+
test("computeBlastRadius: store failure surfaces as store_error, not masked as empty (rule 22)", async () => {
|
|
513
|
+
const { store, dir } = await tempStore();
|
|
514
|
+
try {
|
|
515
|
+
await store.upsertFileBatch([CHAIN_FILE]);
|
|
516
|
+
const ids = new Set(["chain.d"]);
|
|
517
|
+
// Sanity: a healthy store resolves the blast radius.
|
|
518
|
+
const ok = computeBlastRadius(store, ids, 3);
|
|
519
|
+
assert.equal(ok.ok, true);
|
|
520
|
+
|
|
521
|
+
// Close the store — traverse now returns store_closed. Before the fix
|
|
522
|
+
// this was silently skipped (continue), yielding an empty affected set
|
|
523
|
+
// indistinguishable from "no dependents". Now it surfaces a tagged
|
|
524
|
+
// store_error so the caller knows the computation is unreliable.
|
|
525
|
+
await store.close();
|
|
526
|
+
const failed = computeBlastRadius(store, ids, 3);
|
|
527
|
+
assert.equal(failed.ok, false);
|
|
528
|
+
if (failed.ok) throw new Error("expected store_error");
|
|
529
|
+
assert.equal(failed.code, "store_error");
|
|
530
|
+
} finally {
|
|
531
|
+
await rm(dir, { recursive: true, force: true });
|
|
532
|
+
}
|
|
533
|
+
});
|