@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,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* detect_changes + blast radius for the coding-graph (issue #1553).
|
|
3
|
+
*
|
|
4
|
+
* Maps the current diff (staged + unstaged + committed-since-last-index)
|
|
5
|
+
* hunks to symbols whose spans overlap the changed line ranges — against
|
|
6
|
+
* a FRESH parse of the changed files, never against stale stored spans.
|
|
7
|
+
*
|
|
8
|
+
* Blast radius: reverse BFS from affected symbols over inbound
|
|
9
|
+
* CALLS / IMPORTS / USES_TYPE edges with a depth cap. Risk classification
|
|
10
|
+
* is a deterministic rubric (no LLM in the loop):
|
|
11
|
+
*
|
|
12
|
+
* ┌──────────────┬──────────────────────────────────────────────────┐
|
|
13
|
+
* │ Risk │ Criterion │
|
|
14
|
+
* ├──────────────┼──────────────────────────────────────────────────┤
|
|
15
|
+
* │ "direct" │ The symbol itself is in a changed hunk (depth 0).│
|
|
16
|
+
* │ "near" │ 1 hop away from a directly-changed symbol. │
|
|
17
|
+
* │ "transitive" │ 2+ hops away. │
|
|
18
|
+
* └──────────────┴──────────────────────────────────────────────────┘
|
|
19
|
+
*
|
|
20
|
+
* Fan-in escalation: when an affected symbol has ≥ FAN_IN_ESCALATION
|
|
21
|
+
* inbound edges, its risk is escalated one level (near→direct,
|
|
22
|
+
* transitive→near) because high-fan-in symbols concentrate blast radius.
|
|
23
|
+
*
|
|
24
|
+
* Half-open interval semantics (rule 35): a hunk range `[startLine, endLine)`
|
|
25
|
+
* overlaps a symbol span `[symStartLine, symEndLine)` iff
|
|
26
|
+
* `startLine < symEndLine && symStartLine < endLine`.
|
|
27
|
+
* Boundary lines (exact hit on startLine or endLine-1) are tested.
|
|
28
|
+
*/
|
|
29
|
+
import { nodeIdFor, type GraphStore, type TraverseHit } from "./graph-store.js";
|
|
30
|
+
import type { DiffHunk } from "./git-invoker.js";
|
|
31
|
+
import type { SymbolIR, FileIR } from "@remnic/core";
|
|
32
|
+
|
|
33
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
34
|
+
// Public types
|
|
35
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
/** Risk classification rubric — deterministic, no LLM. */
|
|
38
|
+
export type RiskLevel = "direct" | "near" | "transitive";
|
|
39
|
+
|
|
40
|
+
/** A symbol affected by the current diff, with its blast-radius classification. */
|
|
41
|
+
export interface AffectedSymbol {
|
|
42
|
+
/** Qualified name of the symbol. */
|
|
43
|
+
readonly qualifiedName: string;
|
|
44
|
+
/** Simple name of the symbol. */
|
|
45
|
+
readonly name: string;
|
|
46
|
+
/** Symbol kind (function, class, method, ...). */
|
|
47
|
+
readonly label: string;
|
|
48
|
+
/** Repo-relative file path. */
|
|
49
|
+
readonly filePath: string;
|
|
50
|
+
/** Risk level from the deterministic rubric. */
|
|
51
|
+
readonly risk: RiskLevel;
|
|
52
|
+
/** BFS depth from the nearest directly-changed symbol (0 = direct). */
|
|
53
|
+
readonly depth: number;
|
|
54
|
+
/** Total inbound edge count (fan-in) at the time of classification. */
|
|
55
|
+
readonly fanIn: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Result of detect_changes. */
|
|
59
|
+
export type DetectChangesResult =
|
|
60
|
+
| { readonly ok: true; readonly affected: readonly AffectedSymbol[] }
|
|
61
|
+
| { readonly ok: false; readonly code: "git_error" | "store_error" };
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Result of computeBlastRadius. A backend/store failure during traversal is
|
|
65
|
+
* surfaced as `{ ok: false; code: "store_error" }` rather than masked as an
|
|
66
|
+
* empty result — the blast-radius computation is unreliable when the store
|
|
67
|
+
* cannot be read (rule 22; cursor Bugbot: 'computeBlastRadius masks traverse
|
|
68
|
+
* failures'). `{ ok: true; affected: [] }` is a genuinely empty blast radius.
|
|
69
|
+
*/
|
|
70
|
+
export type BlastRadiusResult =
|
|
71
|
+
| { ok: true; affected: readonly AffectedSymbol[] }
|
|
72
|
+
| { ok: false; code: "store_error" };
|
|
73
|
+
|
|
74
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
75
|
+
// Constants — the risk rubric
|
|
76
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Edge types traversed for blast-radius computation. These represent
|
|
80
|
+
* "depends on" relationships — an inbound edge of any of these types
|
|
81
|
+
* means the source symbol is affected when the destination changes.
|
|
82
|
+
*/
|
|
83
|
+
export const BLAST_RADIUS_EDGE_TYPES = [
|
|
84
|
+
"CALLS",
|
|
85
|
+
"IMPORTS",
|
|
86
|
+
"USES_TYPE",
|
|
87
|
+
] as const;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Fan-in threshold for risk escalation. When an affected symbol has this
|
|
91
|
+
* many or more inbound edges, its risk is escalated one level because
|
|
92
|
+
* high-fan-in symbols concentrate blast radius.
|
|
93
|
+
*/
|
|
94
|
+
export const FAN_IN_ESCALATION_THRESHOLD = 5;
|
|
95
|
+
|
|
96
|
+
/** Maximum BFS depth for blast-radius traversal. */
|
|
97
|
+
export const DEFAULT_BLAST_RADIUS_DEPTH = 3;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Failure codes that indicate a backend/store problem (rule 22), as opposed
|
|
101
|
+
* to a logical no-result (unknown_start / ambiguous_start / invalid_query).
|
|
102
|
+
* Used by computeBlastRadius to decide whether to surface a traverse failure
|
|
103
|
+
* or merely skip the symbol.
|
|
104
|
+
*/
|
|
105
|
+
const STORE_FAILURE_CODES: ReadonlySet<string> = new Set([
|
|
106
|
+
"db_locked",
|
|
107
|
+
"db_corrupt",
|
|
108
|
+
"db_error",
|
|
109
|
+
"store_closed",
|
|
110
|
+
]);
|
|
111
|
+
function isStoreFailureCode(code: string): boolean {
|
|
112
|
+
return STORE_FAILURE_CODES.has(code);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
116
|
+
// Line-range overlap (rule 35: half-open intervals on both sides)
|
|
117
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Byte offset → 1-based line number. Walks the content counting newlines
|
|
121
|
+
* up to (but not including) the byte offset. Used to convert symbol spans
|
|
122
|
+
* (byte offsets) to line ranges for hunk-overlap comparison.
|
|
123
|
+
*
|
|
124
|
+
* Returns `{ startLine, endLine }` where both are 1-based and `endLine`
|
|
125
|
+
* is exclusive (half-open — rule 35). A symbol that starts at byte 0 on
|
|
126
|
+
* line 1 and ends at byte 10 (still line 1) has `{ 1, 2 }`.
|
|
127
|
+
*/
|
|
128
|
+
export function byteSpanToLines(
|
|
129
|
+
content: Uint8Array,
|
|
130
|
+
startByte: number,
|
|
131
|
+
endByte: number,
|
|
132
|
+
): { startLine: number; endLine: number } {
|
|
133
|
+
let line = 1;
|
|
134
|
+
let startLine = 1;
|
|
135
|
+
let endLine = 1;
|
|
136
|
+
for (let i = 0; i < content.length; i += 1) {
|
|
137
|
+
if (i === startByte) startLine = line;
|
|
138
|
+
if (content[i] === 0x0a) line += 1;
|
|
139
|
+
if (i === endByte - 1 && content[i] === 0x0a) {
|
|
140
|
+
// endByte is exclusive; if the last byte is a newline, the symbol
|
|
141
|
+
// ends at the start of the next line.
|
|
142
|
+
endLine = line;
|
|
143
|
+
} else if (i + 1 === endByte) {
|
|
144
|
+
endLine = line + (content[i] === 0x0a ? 0 : 1);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// Handle edge case: startByte or endByte at content boundary.
|
|
148
|
+
if (startByte >= content.length) startLine = line;
|
|
149
|
+
if (endByte > content.length) endLine = line + 1;
|
|
150
|
+
return { startLine, endLine };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Half-open overlap test (rule 35): two ranges `[aStart, aEnd)` and
|
|
155
|
+
* `[bStart, bEnd)` overlap iff `aStart < bEnd && bStart < aEnd`.
|
|
156
|
+
*
|
|
157
|
+
* Boundary case: a hunk starting exactly at `symEndLine` does NOT overlap
|
|
158
|
+
* (the symbol ends before the hunk starts). A hunk ending exactly at
|
|
159
|
+
* `symStartLine` does NOT overlap (the hunk ends before the symbol starts).
|
|
160
|
+
*/
|
|
161
|
+
export function rangesOverlap(
|
|
162
|
+
a: { startLine: number; endLine: number },
|
|
163
|
+
b: { startLine: number; endLine: number },
|
|
164
|
+
): boolean {
|
|
165
|
+
return a.startLine < b.endLine && b.startLine < a.endLine;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
169
|
+
// detect_changes — map hunks → symbols → blast radius
|
|
170
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Classify risk from BFS depth + fan-in. Deterministic rubric:
|
|
174
|
+
* - depth 0 → "direct"
|
|
175
|
+
* - depth 1 → "near"
|
|
176
|
+
* - depth 2+ → "transitive"
|
|
177
|
+
* - fanIn ≥ threshold escalates one level (capped at "direct")
|
|
178
|
+
*/
|
|
179
|
+
export function classifyRisk(depth: number, fanIn: number): RiskLevel {
|
|
180
|
+
let base: RiskLevel;
|
|
181
|
+
if (depth === 0) base = "direct";
|
|
182
|
+
else if (depth === 1) base = "near";
|
|
183
|
+
else base = "transitive";
|
|
184
|
+
// Fan-in escalation: high-fan-in symbols are more dangerous.
|
|
185
|
+
if (fanIn >= FAN_IN_ESCALATION_THRESHOLD) {
|
|
186
|
+
if (base === "near") base = "direct";
|
|
187
|
+
else if (base === "transitive") base = "near";
|
|
188
|
+
}
|
|
189
|
+
return base;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Find symbols whose line ranges overlap any hunk in the given file.
|
|
194
|
+
* Uses a FRESH parse (never stale stored spans). Returns the set of
|
|
195
|
+
* directly-affected qualified names.
|
|
196
|
+
*/
|
|
197
|
+
export function findDirectlyAffectedSymbols(
|
|
198
|
+
hunksByPath: ReadonlyMap<string, readonly DiffHunk[]>,
|
|
199
|
+
freshIRs: ReadonlyMap<string, FileIR>,
|
|
200
|
+
contentsByPath: ReadonlyMap<string, Uint8Array>,
|
|
201
|
+
): Set<string> {
|
|
202
|
+
// Returns NODE IDS (not qualified names). Node identity is the full
|
|
203
|
+
// (qualifiedName, filePath, label) triple, so two files that emit the
|
|
204
|
+
// same qualifiedName produce distinct ids and downstream traversal
|
|
205
|
+
// resolves unambiguously by id instead of returning 'ambiguous_start'
|
|
206
|
+
// (chatgpt-codex-connector: 'Preserve node identity for direct
|
|
207
|
+
// blast-radius hits').
|
|
208
|
+
const affected = new Set<string>();
|
|
209
|
+
for (const [filePath, hunks] of hunksByPath) {
|
|
210
|
+
const ir = freshIRs.get(filePath);
|
|
211
|
+
if (!ir) continue;
|
|
212
|
+
const content = contentsByPath.get(filePath);
|
|
213
|
+
if (!content) continue;
|
|
214
|
+
for (const sym of ir.symbols) {
|
|
215
|
+
const symLines = byteSpanToLines(
|
|
216
|
+
content,
|
|
217
|
+
sym.span.startByte,
|
|
218
|
+
sym.span.endByte,
|
|
219
|
+
);
|
|
220
|
+
for (const hunk of hunks) {
|
|
221
|
+
if (rangesOverlap(symLines, hunk.newRange)) {
|
|
222
|
+
affected.add(nodeIdFor({ qualifiedName: sym.qualifiedName, filePath, label: sym.kind }));
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return affected;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Compute blast radius from a set of directly-affected symbols using the
|
|
233
|
+
* store's `traverse` primitive (direction: incoming). Reuses the existing
|
|
234
|
+
* BFS — does NOT write a second traversal (rule 22 spirit).
|
|
235
|
+
*
|
|
236
|
+
* Returns affected symbols with their risk classification. Byte-stable:
|
|
237
|
+
* the same diff + graph always produces the same output.
|
|
238
|
+
*/
|
|
239
|
+
export function computeBlastRadius(
|
|
240
|
+
store: GraphStore,
|
|
241
|
+
directlyAffected: ReadonlySet<string>,
|
|
242
|
+
maxDepth: number = DEFAULT_BLAST_RADIUS_DEPTH,
|
|
243
|
+
): BlastRadiusResult {
|
|
244
|
+
if (directlyAffected.size === 0) return { ok: true, affected: [] };
|
|
245
|
+
|
|
246
|
+
// Collect all reachable symbols via inbound traversal from each
|
|
247
|
+
// directly-affected symbol. We traverse from the affected symbol
|
|
248
|
+
// OUTWARD via incoming edges — meaning "who depends on me".
|
|
249
|
+
const hitByDepth = new Map<string, number>();
|
|
250
|
+
const hitMeta = new Map<
|
|
251
|
+
string,
|
|
252
|
+
{ qualifiedName: string; name: string; label: string; filePath: string }
|
|
253
|
+
>();
|
|
254
|
+
|
|
255
|
+
for (const qname of directlyAffected) {
|
|
256
|
+
const result = store.traverse({
|
|
257
|
+
start: qname,
|
|
258
|
+
direction: "incoming",
|
|
259
|
+
edgeTypes: [...BLAST_RADIUS_EDGE_TYPES],
|
|
260
|
+
maxDepth,
|
|
261
|
+
});
|
|
262
|
+
if (!result.ok) {
|
|
263
|
+
// A backend failure (db_locked/corrupt/error/store_closed) means the
|
|
264
|
+
// blast-radius computation is unreliable — surface it instead of
|
|
265
|
+
// conflating it with "no inbound edges" (rule 22). A logical failure
|
|
266
|
+
// (unknown_start/ambiguous_start/invalid_query) means this symbol
|
|
267
|
+
// contributes no traversal, so skip it without aborting the set.
|
|
268
|
+
if (isStoreFailureCode(result.code)) {
|
|
269
|
+
return { ok: false, code: "store_error" };
|
|
270
|
+
}
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
for (const hit of result.hits) {
|
|
274
|
+
const existing = hitByDepth.get(hit.nodeId);
|
|
275
|
+
// Keep the minimum depth (closest to a directly-affected symbol).
|
|
276
|
+
if (existing === undefined || hit.depth < existing) {
|
|
277
|
+
hitByDepth.set(hit.nodeId, hit.depth);
|
|
278
|
+
hitMeta.set(hit.nodeId, {
|
|
279
|
+
qualifiedName: hit.qualifiedName,
|
|
280
|
+
name: hit.name,
|
|
281
|
+
label: hit.label,
|
|
282
|
+
filePath: hit.filePath,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Build the result with risk classification.
|
|
289
|
+
const out: AffectedSymbol[] = [];
|
|
290
|
+
for (const [nodeId, depth] of hitByDepth) {
|
|
291
|
+
const meta = hitMeta.get(nodeId);
|
|
292
|
+
if (!meta) continue;
|
|
293
|
+
// Compute fan-in: count inbound edges of the blast-radius types.
|
|
294
|
+
// A separate traverse at depth 1 gives us the inbound neighbors.
|
|
295
|
+
const inboundResult = store.traverse({
|
|
296
|
+
start: nodeId,
|
|
297
|
+
direction: "incoming",
|
|
298
|
+
edgeTypes: [...BLAST_RADIUS_EDGE_TYPES],
|
|
299
|
+
maxDepth: 1,
|
|
300
|
+
});
|
|
301
|
+
let fanIn: number;
|
|
302
|
+
if (inboundResult.ok) {
|
|
303
|
+
fanIn = inboundResult.hits.filter((h) => h.depth > 0).length;
|
|
304
|
+
} else if (isStoreFailureCode(inboundResult.code)) {
|
|
305
|
+
// Same discipline as the main loop: a store failure on the fan-in
|
|
306
|
+
// read makes the classification unreliable — surface it (rule 22).
|
|
307
|
+
return { ok: false, code: "store_error" };
|
|
308
|
+
} else {
|
|
309
|
+
// Logical failure (unknown_start/...): this node has no resolvable
|
|
310
|
+
// inbound neighbors — fan-in is 0 for risk classification only.
|
|
311
|
+
fanIn = 0;
|
|
312
|
+
}
|
|
313
|
+
const risk = classifyRisk(depth, fanIn);
|
|
314
|
+
// filePath comes straight from the traverse hit (joined from
|
|
315
|
+
// files.path in the store) — resolving via searchGraph by short
|
|
316
|
+
// name would attach the wrong path when a simple name is declared
|
|
317
|
+
// in more than one file (cursor Bugbot: 'Blast radius wrong file
|
|
318
|
+
// path').
|
|
319
|
+
out.push({
|
|
320
|
+
qualifiedName: meta.qualifiedName,
|
|
321
|
+
name: meta.name,
|
|
322
|
+
label: meta.label,
|
|
323
|
+
filePath: meta.filePath,
|
|
324
|
+
risk,
|
|
325
|
+
depth,
|
|
326
|
+
fanIn,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Sort for byte-stability: by risk, then qualifiedName, then filePath,
|
|
331
|
+
// then nodeId. A total order is required so two symbols that share a
|
|
332
|
+
// qualifiedName across files (different filePaths / node ids) still
|
|
333
|
+
// sort deterministically (cursor Bugbot: 'Blast radius sort not
|
|
334
|
+
// byte-stable').
|
|
335
|
+
const riskOrder: Record<RiskLevel, number> = {
|
|
336
|
+
direct: 0,
|
|
337
|
+
near: 1,
|
|
338
|
+
transitive: 2,
|
|
339
|
+
};
|
|
340
|
+
// Build a (qualifiedName filePath) -> nodeId lookup so the comparator
|
|
341
|
+
// can resolve each AffectedSymbol back to its node id for the final
|
|
342
|
+
// tiebreaker.
|
|
343
|
+
const nodeIdByQFile = new Map<string, string>();
|
|
344
|
+
for (const [nodeId, depth] of hitByDepth) {
|
|
345
|
+
const m = hitMeta.get(nodeId);
|
|
346
|
+
if (m) nodeIdByQFile.set(`${m.qualifiedName}\0${m.filePath}\0${m.label}`, nodeId);
|
|
347
|
+
void depth;
|
|
348
|
+
}
|
|
349
|
+
out.sort((a, b) => {
|
|
350
|
+
const riskDiff = riskOrder[a.risk] - riskOrder[b.risk];
|
|
351
|
+
if (riskDiff !== 0) return riskDiff;
|
|
352
|
+
const qCmp = a.qualifiedName.localeCompare(b.qualifiedName);
|
|
353
|
+
if (qCmp !== 0) return qCmp;
|
|
354
|
+
const fCmp = a.filePath.localeCompare(b.filePath);
|
|
355
|
+
if (fCmp !== 0) return fCmp;
|
|
356
|
+
// Node identity is (qualifiedName, filePath, label); include label so two
|
|
357
|
+
// distinct nodes sharing qualifiedName+filePath do not collapse to one key
|
|
358
|
+
// (chatgpt-codex-connector: 'Include label in blast-radius tiebreaker').
|
|
359
|
+
const lCmp = a.label.localeCompare(b.label);
|
|
360
|
+
if (lCmp !== 0) return lCmp;
|
|
361
|
+
const aId = nodeIdByQFile.get(`${a.qualifiedName}\0${a.filePath}\0${a.label}`) ?? "";
|
|
362
|
+
const bId = nodeIdByQFile.get(`${b.qualifiedName}\0${b.filePath}\0${b.label}`) ?? "";
|
|
363
|
+
return aId.localeCompare(bId);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
return { ok: true, affected: out };
|
|
367
|
+
}
|