gitnexus 1.6.10-rc.147 → 1.6.10-rc.149
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/dist/cli/analyze.js +37 -8
- package/dist/cli/cli-message.d.ts +1 -1
- package/dist/cli/group.js +20 -1
- package/dist/core/augmentation/engine.js +86 -71
- package/dist/core/group/cross-impact.d.ts +21 -0
- package/dist/core/group/cross-impact.js +77 -8
- package/dist/core/group/cross-trace.js +27 -16
- package/dist/core/group/extractors/http-route-extractor.js +8 -0
- package/dist/core/group/extractors/manifest-extractor.d.ts +1 -1
- package/dist/core/group/extractors/manifest-extractor.js +7 -7
- package/dist/core/group/types.d.ts +12 -0
- package/dist/core/ingestion/scope-resolution/graph-bridge/ids.d.ts +20 -0
- package/dist/core/ingestion/scope-resolution/graph-bridge/ids.js +11 -8
- package/dist/core/ingestion/scope-resolution/graph-bridge/node-lookup.d.ts +14 -0
- package/dist/core/ingestion/scope-resolution/graph-bridge/node-lookup.js +47 -31
- package/dist/core/lbug/csv-generator.js +4 -4
- package/dist/core/lbug/graph-emit-sink.d.ts +0 -1
- package/dist/core/lbug/graph-emit-sink.js +14 -14
- package/dist/core/lbug/lbug-adapter.d.ts +9 -0
- package/dist/core/lbug/lbug-adapter.js +21 -6
- package/dist/core/lbug/pdg-emit-sink.d.ts +2 -3
- package/dist/core/lbug/pdg-emit-sink.js +12 -13
- package/dist/core/lbug/pool-adapter.js +3 -0
- package/dist/core/lbug/rel-pair-routing.d.ts +143 -10
- package/dist/core/lbug/rel-pair-routing.js +202 -20
- package/dist/core/lbug/schema.d.ts +38 -1
- package/dist/core/lbug/schema.js +236 -161
- package/dist/core/run-analyze.js +15 -3
- package/dist/core/search/bm25-index.js +5 -8
- package/dist/core/wiki/graph-queries.js +11 -2
- package/dist/lib/utils.d.ts +50 -0
- package/dist/lib/utils.js +62 -0
- package/dist/mcp/local/bean-metadata.js +7 -0
- package/dist/mcp/local/local-backend.js +430 -88
- package/dist/mcp/local/pdg-impact.js +14 -2
- package/dist/mcp/tools.js +10 -6
- package/dist/server/api.js +4 -0
- package/dist/storage/repo-manager.d.ts +21 -1
- package/dist/storage/repo-manager.js +21 -1
- package/package.json +1 -1
package/dist/cli/analyze.js
CHANGED
|
@@ -14,6 +14,8 @@ import v8 from 'v8';
|
|
|
14
14
|
import cliProgress from 'cli-progress';
|
|
15
15
|
import { isLbugReady, LbugWipeError } from '../core/lbug/lbug-adapter.js';
|
|
16
16
|
import { boundedCheckpointBeforeExit } from '../core/lbug/shutdown-helpers.js';
|
|
17
|
+
import { findUndeclaredRelationPairError } from '../core/lbug/rel-pair-routing.js';
|
|
18
|
+
import { causeChain } from '../lib/utils.js';
|
|
17
19
|
import { getOsPageSize, isLbugCheckpointIoError, isLbugCheckpointBusyError, isLbugPageSizeFrameError, isPageSizeAwareLadybug, isWalCorruptionError, parseWalCheckpointThreshold, WAL_RECOVERY_SUGGESTION, } from '../core/lbug/lbug-config.js';
|
|
18
20
|
import { getStoragePaths, getGlobalRegistryPath, RegistryNameCollisionError, AnalysisNotFinalizedError, assertAnalysisFinalized, } from '../storage/repo-manager.js';
|
|
19
21
|
import { getGitRoot, hasGitDir, getDefaultBranch, selfCommitContextFiles, snapshotSelfCommitSafety, } from '../storage/git.js';
|
|
@@ -54,15 +56,13 @@ const writeFatalToStderr = (label, err) => {
|
|
|
54
56
|
// #2068) is only reachable via `.cause`. Without this the user sees the
|
|
55
57
|
// wrapper's main-thread stack and never the real frame. `cause.stack` already
|
|
56
58
|
// begins with the cause's message, so we print the stack alone (not message +
|
|
57
|
-
// stack) to avoid repeating it.
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
for (let depth = 0; depth < MAX_CAUSE_DEPTH && cause instanceof Error; depth++) {
|
|
59
|
+
// stack) to avoid repeating it. `causeChain` owns the traversal and the depth
|
|
60
|
+
// bound that stops a cyclic `cause` looping — this used to be one of four
|
|
61
|
+
// hand-rolled copies that had already drifted apart on both. Uses
|
|
62
|
+
// realStderrWrite so the redirected console.error's ANSI clear-line wrapping
|
|
63
|
+
// can't erase it (#1169). The head is skipped: it was just printed above.
|
|
64
|
+
for (const cause of causeChain(isErr ? err.cause : undefined)) {
|
|
64
65
|
realStderrWrite(`\n Caused by: ${cause.stack ?? cause.message}\n`);
|
|
65
|
-
cause = cause.cause;
|
|
66
66
|
}
|
|
67
67
|
};
|
|
68
68
|
let fatalHandlersInstalled = false;
|
|
@@ -1314,6 +1314,35 @@ const analyzeCommandImpl = async (inputPath, cliOptions, runnerIdentityAtBootstr
|
|
|
1314
1314
|
process.exitCode = 1;
|
|
1315
1315
|
return;
|
|
1316
1316
|
}
|
|
1317
|
+
// An extracted edge whose FROM→TO label pair is missing from GitNexus's own
|
|
1318
|
+
// relation DDL (#2789). `assertDeclaredPair` aborts the run rather than let
|
|
1319
|
+
// the bulk COPY fail late and silently drop the edge, so the user sees a
|
|
1320
|
+
// mid-run crash inside GitNexus internals with nothing to act on. Name the
|
|
1321
|
+
// pair, the relationship and the file that produced it, and say plainly that
|
|
1322
|
+
// a re-run cannot help — this is deterministic for the same input.
|
|
1323
|
+
// Checked by TYPE (repo norm, #2385) BEFORE the message-text heuristics
|
|
1324
|
+
// below, and through the `cause` chain because the ingestion phase runner
|
|
1325
|
+
// rewraps every phase failure as `Phase 'X' failed: …`.
|
|
1326
|
+
const undeclaredPair = findUndeclaredRelationPairError(err);
|
|
1327
|
+
if (undeclaredPair !== undefined) {
|
|
1328
|
+
// Render the error's OWN message indented — same idiom as the
|
|
1329
|
+
// `LbugWipeError` and page-size branches below. `UndeclaredRelationPairError`
|
|
1330
|
+
// builds a fully self-contained message (pair, relationship type, both node
|
|
1331
|
+
// ids, source file, issue URL, `.gitnexusignore` workaround) precisely
|
|
1332
|
+
// because `gitnexus serve` forwards only `err.message` over worker IPC.
|
|
1333
|
+
// Re-rendering those fields here would be a second copy of one string, free
|
|
1334
|
+
// to drift from the first — and the actionable half would reach CLI users
|
|
1335
|
+
// only. `undeclaredPair.message`, not the outer `msg`: the real error may be
|
|
1336
|
+
// several `cause` levels below the phase wrapper `msg` came from.
|
|
1337
|
+
cliError(` ${undeclaredPair.message.replace(/\n/g, '\n ')}\n`, {
|
|
1338
|
+
recoveryHint: 'undeclared-relation-pair',
|
|
1339
|
+
labelPair: undeclaredPair.pairKey,
|
|
1340
|
+
relationType: undeclaredPair.relationType,
|
|
1341
|
+
sourceFile: undeclaredPair.sourceFile,
|
|
1342
|
+
});
|
|
1343
|
+
process.exitCode = 1;
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
1317
1346
|
// WAL corruption — the index file is unreadable. Give a clear recovery
|
|
1318
1347
|
// path without a confusing stack trace (the native error message alone
|
|
1319
1348
|
// is enough signal).
|
|
@@ -12,7 +12,7 @@ import { type CliMessageKey, type CliMessageVars } from './i18n/index.js';
|
|
|
12
12
|
* Consumers can import this type to narrow log-record `recoveryHint`
|
|
13
13
|
* fields without restating the literal list.
|
|
14
14
|
*/
|
|
15
|
-
export type RecoveryHint = 'wal-corruption' | 'wal-checkpoint-threshold' | 'lbug-wipe-failed' | 'lbug-page-size' | 'heap-oom-respawn' | 'native-worker-abort' | 'hf-endpoint-unreachable' | 'http-embedding-endpoint-error' | 'embedding-dims-invalid' | 'local-embedding-unsupported' | 'local-embedding-stack-missing' | 'large-repo' | 'npm-resolution' | 'module-not-found' | 'gitnexusrc-invalid' | 'default-branch-invalid' | 'index-lock-timeout';
|
|
15
|
+
export type RecoveryHint = 'wal-corruption' | 'wal-checkpoint-threshold' | 'lbug-wipe-failed' | 'lbug-page-size' | 'heap-oom-respawn' | 'native-worker-abort' | 'hf-endpoint-unreachable' | 'http-embedding-endpoint-error' | 'embedding-dims-invalid' | 'local-embedding-unsupported' | 'local-embedding-stack-missing' | 'large-repo' | 'npm-resolution' | 'module-not-found' | 'gitnexusrc-invalid' | 'default-branch-invalid' | 'index-lock-timeout' | 'undeclared-relation-pair';
|
|
16
16
|
/**
|
|
17
17
|
* Common shape for the optional structured-field bag passed to
|
|
18
18
|
* `cliError`/`cliWarn`/`cliInfo`. Typed so the `recoveryHint` slot is
|
package/dist/cli/group.js
CHANGED
|
@@ -213,12 +213,31 @@ export function registerGroupCommands(program) {
|
|
|
213
213
|
else {
|
|
214
214
|
const summary = raw?.summary;
|
|
215
215
|
const risk = raw?.risk;
|
|
216
|
+
// A truncated fan-out under-reports risk (mergeRisk only grows with
|
|
217
|
+
// traversed crossings), and the default human output used to print a
|
|
218
|
+
// bare `risk=` indistinguishable from a complete run — the JSON
|
|
219
|
+
// already carried `truncated`, but nobody reading the terminal saw it.
|
|
220
|
+
const riskFloor = raw?.riskEpistemic === 'lower-bound' ? '+' : '';
|
|
216
221
|
const boundaryOnly = raw?.cross?.filter((entry) => entry.fanout_status === 'not_attempted').length ?? 0;
|
|
217
|
-
console.log(`Group impact for "${name}" (${String(opts.repo)}): risk=${risk ?? '?'}`);
|
|
222
|
+
console.log(`Group impact for "${name}" (${String(opts.repo)}): risk=${risk ?? '?'}${riskFloor}`);
|
|
218
223
|
if (summary) {
|
|
219
224
|
const boundaryNote = boundaryOnly > 0 ? ` (${boundaryOnly} boundary-only)` : '';
|
|
220
225
|
console.log(` direct=${summary.direct ?? 0} processes=${summary.processes_affected ?? 0} cross=${summary.cross_repo_hits ?? 0}${boundaryNote}`);
|
|
221
226
|
}
|
|
227
|
+
if (riskFloor) {
|
|
228
|
+
// `truncated` has two independent causes that point at different
|
|
229
|
+
// subsystems, so the note must name the one that actually fired:
|
|
230
|
+
// dropped crossings, or a local walk that never finished (most
|
|
231
|
+
// often the impact chunk cap, which any symbol with more than a
|
|
232
|
+
// thousand locally-impacted nodes hits on every run). `dropped` is
|
|
233
|
+
// deduped to distinct repos before it reaches here, so it counts
|
|
234
|
+
// repos — reporting it as crossings understates a fan-out cap the
|
|
235
|
+
// same way #2787's totals did.
|
|
236
|
+
const dropped = raw?.truncatedRepos ?? [];
|
|
237
|
+
console.log(dropped.length > 0
|
|
238
|
+
? ` risk is a LOWER BOUND — fan-out stopped early; crossings to ${dropped.length} repo(s) not traversed: ${dropped.join(', ')}`
|
|
239
|
+
: ' risk is a LOWER BOUND — the local impact walk did not complete (every bridge crossing was traversed)');
|
|
240
|
+
}
|
|
222
241
|
}
|
|
223
242
|
}
|
|
224
243
|
finally {
|
|
@@ -108,6 +108,7 @@ export async function augment(pattern, cwd) {
|
|
|
108
108
|
MATCH (n) WHERE n.filePath = '${escaped}'
|
|
109
109
|
AND n.name CONTAINS '${patternFirstWord}'
|
|
110
110
|
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath
|
|
111
|
+
ORDER BY id
|
|
111
112
|
LIMIT 3
|
|
112
113
|
`);
|
|
113
114
|
for (const sym of symbols) {
|
|
@@ -131,6 +132,7 @@ export async function augment(pattern, cwd) {
|
|
|
131
132
|
MATCH (n)
|
|
132
133
|
WHERE n.name CONTAINS '${patternFirstWord}'
|
|
133
134
|
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath
|
|
135
|
+
ORDER BY id
|
|
134
136
|
LIMIT 5
|
|
135
137
|
`).catch(() => []);
|
|
136
138
|
for (const sym of fallbackRows) {
|
|
@@ -153,99 +155,112 @@ export async function augment(pattern, cwd) {
|
|
|
153
155
|
if (uniqueSymbols.length === 0)
|
|
154
156
|
return '';
|
|
155
157
|
const idList = uniqueSymbols.map((s) => `'${escapeCypherString(s.nodeId)}'`).join(', ');
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
158
|
+
// Callers/callees are windowed PER SYMBOL, not out of one shared budget.
|
|
159
|
+
// A single `n.id IN [...] ... ORDER BY targetId LIMIT 15` sorts by the very
|
|
160
|
+
// column the rows are grouped by, which turns the cap into a single-bucket
|
|
161
|
+
// prefix: the alphabetically-first target takes every row (the hottest
|
|
162
|
+
// symbol in this repo's own index has 2163 callers) and the other four
|
|
163
|
+
// render with no callers at all. Raising the cap does not bound that, and
|
|
164
|
+
// ordering caller-major only moves the bucket. A per-target window is not
|
|
165
|
+
// expressible in one statement either — LadybugDB does not preserve a
|
|
166
|
+
// per-branch ORDER BY through UNION ALL — so fan out one bounded query per
|
|
167
|
+
// symbol (#2787).
|
|
168
|
+
//
|
|
169
|
+
// `id STARTS WITH 'File:'` sorts container nodes last: node ids are
|
|
170
|
+
// `Label:path:name`, so a bare `ORDER BY id` puts `File:` ahead of
|
|
171
|
+
// `Function:`/`Method:` and the "Called by" line renders bare filenames
|
|
172
|
+
// instead of the calling symbols the hint exists to name.
|
|
173
|
+
const NEIGHBOUR_CAP = 3;
|
|
174
|
+
const neighbourNames = async (nodeId, incoming) => {
|
|
175
|
+
const edge = incoming
|
|
176
|
+
? `(other)-[:CodeRelation {type: 'CALLS'}]->(n)`
|
|
177
|
+
: `(n)-[:CodeRelation {type: 'CALLS'}]->(other)`;
|
|
159
178
|
const rows = await executeQuery(repoId, `
|
|
160
|
-
MATCH
|
|
161
|
-
WHERE n.id
|
|
162
|
-
RETURN
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
const name = r.name || r[1];
|
|
168
|
-
if (tid && name) {
|
|
169
|
-
if (!callersMap.has(tid))
|
|
170
|
-
callersMap.set(tid, []);
|
|
171
|
-
callersMap.get(tid).push(name);
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
catch {
|
|
176
|
-
/* skip */
|
|
177
|
-
}
|
|
178
|
-
// Batch fetch callees
|
|
179
|
-
const calleesMap = new Map();
|
|
180
|
-
try {
|
|
181
|
-
const rows = await executeQuery(repoId, `
|
|
182
|
-
MATCH (n)-[:CodeRelation {type: 'CALLS'}]->(callee)
|
|
183
|
-
WHERE n.id IN [${idList}]
|
|
184
|
-
RETURN n.id AS sourceId, callee.name AS name
|
|
185
|
-
LIMIT 15
|
|
186
|
-
`);
|
|
179
|
+
MATCH ${edge}
|
|
180
|
+
WHERE n.id = '${escapeCypherString(nodeId)}'
|
|
181
|
+
RETURN other.name AS name
|
|
182
|
+
ORDER BY other.id STARTS WITH 'File:', other.id
|
|
183
|
+
LIMIT ${NEIGHBOUR_CAP}
|
|
184
|
+
`).catch(() => []);
|
|
185
|
+
const names = [];
|
|
187
186
|
for (const r of rows) {
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
if (!calleesMap.has(sid))
|
|
192
|
-
calleesMap.set(sid, []);
|
|
193
|
-
calleesMap.get(sid).push(name);
|
|
194
|
-
}
|
|
187
|
+
const name = r.name || r[0];
|
|
188
|
+
if (name)
|
|
189
|
+
names.push(name);
|
|
195
190
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
191
|
+
return names;
|
|
192
|
+
};
|
|
193
|
+
// Keyed by nodeId, like the process/cohesion enrichments below — positional
|
|
194
|
+
// arrays would put two lookup disciplines in one object literal and make
|
|
195
|
+
// "stays index-aligned with uniqueSymbols" an unenforced invariant.
|
|
196
|
+
const neighbourMap = async (incoming) => new Map(await Promise.all(uniqueSymbols.map(async (s) => [s.nodeId, await neighbourNames(s.nodeId, incoming)])));
|
|
200
197
|
// Batch fetch processes
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
198
|
+
const fetchProcesses = async () => {
|
|
199
|
+
const byNode = new Map();
|
|
200
|
+
try {
|
|
201
|
+
const rows = await executeQuery(repoId, `
|
|
204
202
|
MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
|
|
205
203
|
WHERE n.id IN [${idList}]
|
|
206
204
|
RETURN n.id AS nodeId, p.heuristicLabel AS label, r.step AS step, p.stepCount AS stepCount
|
|
207
205
|
`);
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
206
|
+
for (const r of rows) {
|
|
207
|
+
const nid = r.nodeId || r[0];
|
|
208
|
+
const label = r.label || r[1];
|
|
209
|
+
const step = r.step || r[2];
|
|
210
|
+
const stepCount = r.stepCount || r[3];
|
|
211
|
+
if (nid && label) {
|
|
212
|
+
if (!byNode.has(nid))
|
|
213
|
+
byNode.set(nid, []);
|
|
214
|
+
byNode.get(nid).push(`${label} (step ${step}/${stepCount})`);
|
|
215
|
+
}
|
|
217
216
|
}
|
|
218
217
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
218
|
+
catch {
|
|
219
|
+
/* skip */
|
|
220
|
+
}
|
|
221
|
+
return byNode;
|
|
222
|
+
};
|
|
223
223
|
// Batch fetch cohesion
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
224
|
+
const fetchCohesion = async () => {
|
|
225
|
+
const byNode = new Map();
|
|
226
|
+
try {
|
|
227
|
+
const rows = await executeQuery(repoId, `
|
|
227
228
|
MATCH (n)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
|
|
228
229
|
WHERE n.id IN [${idList}]
|
|
229
230
|
RETURN n.id AS nodeId, c.cohesion AS cohesion
|
|
230
231
|
`);
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
232
|
+
for (const r of rows) {
|
|
233
|
+
const nid = r.nodeId || r[0];
|
|
234
|
+
const coh = r.cohesion ?? r[1] ?? 0;
|
|
235
|
+
if (nid)
|
|
236
|
+
byNode.set(nid, coh);
|
|
237
|
+
}
|
|
236
238
|
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
239
|
+
catch {
|
|
240
|
+
/* skip */
|
|
241
|
+
}
|
|
242
|
+
return byNode;
|
|
243
|
+
};
|
|
244
|
+
// One wave, not three. `augment` runs from the Claude Code PreToolUse hook
|
|
245
|
+
// in a cold process against a <500ms budget, so nothing amortizes — and the
|
|
246
|
+
// process/cohesion queries depend only on `idList`, never on the neighbour
|
|
247
|
+
// results, so serializing them behind the fan-out bought nothing. Each query
|
|
248
|
+
// keeps its own try/catch, so one failing still degrades to an empty map
|
|
249
|
+
// instead of taking the others down.
|
|
250
|
+
const [callerMap, calleeMap, processesMap, cohesionMap] = await Promise.all([
|
|
251
|
+
neighbourMap(true),
|
|
252
|
+
neighbourMap(false),
|
|
253
|
+
fetchProcesses(),
|
|
254
|
+
fetchCohesion(),
|
|
255
|
+
]);
|
|
241
256
|
// Assemble enriched results
|
|
242
257
|
const enriched = [];
|
|
243
258
|
for (const sym of uniqueSymbols) {
|
|
244
259
|
enriched.push({
|
|
245
260
|
name: sym.name,
|
|
246
261
|
filePath: sym.filePath,
|
|
247
|
-
callers:
|
|
248
|
-
callees:
|
|
262
|
+
callers: callerMap.get(sym.nodeId) || [],
|
|
263
|
+
callees: calleeMap.get(sym.nodeId) || [],
|
|
249
264
|
processes: processesMap.get(sym.nodeId) || [],
|
|
250
265
|
cohesion: cohesionMap.get(sym.nodeId) || 0,
|
|
251
266
|
});
|
|
@@ -4,10 +4,30 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import type { BridgeHandle, CrossRepoImpact, GroupImpactResult } from './types.js';
|
|
6
6
|
import type { GroupToolPort } from './service.js';
|
|
7
|
+
import { compareCodeUnits } from '../../lib/utils.js';
|
|
7
8
|
/** Cross-boundary hops beyond this value are clamped (multi-hop reserved for future work). */
|
|
8
9
|
export declare const MAX_SUPPORTED_CROSS_DEPTH = 1;
|
|
9
10
|
/** Default wall-clock budget for the Phase 1 `impact` leg when callers omit `timeoutMs`. */
|
|
10
11
|
export declare const DEFAULT_LOCAL_IMPACT_TIMEOUT_MS = 30000;
|
|
12
|
+
/**
|
|
13
|
+
* Cap on neighbour fan-outs attempted per group-impact request.
|
|
14
|
+
*
|
|
15
|
+
* The bound used to be the wall clock alone, which made the cutoff a function
|
|
16
|
+
* of machine load: an idle host traversed more crossings and `mergeRisk`
|
|
17
|
+
* escalated to CRITICAL at three, while a loaded host stopped at two and
|
|
18
|
+
* reported HIGH or lower — same graph, same arguments, different verdict
|
|
19
|
+
* (#2787). A count is deterministic, and the neighbour list carries a total
|
|
20
|
+
* order over the full crossing identity (confidence DESC, then repo, uid,
|
|
21
|
+
* contract), so the cap keeps the strongest crossings rather than an arbitrary
|
|
22
|
+
* prefix.
|
|
23
|
+
*
|
|
24
|
+
* 50 is borrowed from `MAX_CROSSINGS_TO_TRY` (cross-trace.ts) on cost, not on
|
|
25
|
+
* scope — that one caps ContractLinks per repo pair inside a trace, this one
|
|
26
|
+
* caps the total across all neighbour repos in one impact call, and group impact
|
|
27
|
+
* had no numeric cap at all before #2787. The wall clock stays as a hang
|
|
28
|
+
* backstop below; this is the bound that normally binds.
|
|
29
|
+
*/
|
|
30
|
+
export declare const MAX_NEIGHBOR_FANOUT = 50;
|
|
11
31
|
export type BridgeNeighborRow = {
|
|
12
32
|
neighborRepo: string;
|
|
13
33
|
neighborUid: string;
|
|
@@ -117,3 +137,4 @@ export declare function runGroupImpact(deps: RunGroupImpactDeps, params: Record<
|
|
|
117
137
|
error: string;
|
|
118
138
|
}>;
|
|
119
139
|
export { normalizeServicePrefix, fileMatchesServicePrefix } from './group-path-utils.js';
|
|
140
|
+
export { compareCodeUnits };
|
|
@@ -9,6 +9,7 @@ import { fileMatchesServicePrefix, normalizeServicePrefix, repoInSubgroup, } fro
|
|
|
9
9
|
import { getGroupDir } from './storage.js';
|
|
10
10
|
import { closeBridgeDb, getCachedBridgeReadOnly, queryBridge, readBridgeMeta, } from './bridge-db.js';
|
|
11
11
|
import { BRIDGE_SCHEMA_VERSION } from './bridge-schema.js';
|
|
12
|
+
import { compareCodeUnits } from '../../lib/utils.js';
|
|
12
13
|
// High limit for the local phase of group impact so collectImpactSymbolUids
|
|
13
14
|
// sees (nearly) all symbols. Bypasses the MCP-facing default of 100.
|
|
14
15
|
const GROUP_LOCAL_PHASE_LIMIT = 10000;
|
|
@@ -16,6 +17,25 @@ const GROUP_LOCAL_PHASE_LIMIT = 10000;
|
|
|
16
17
|
export const MAX_SUPPORTED_CROSS_DEPTH = 1;
|
|
17
18
|
/** Default wall-clock budget for the Phase 1 `impact` leg when callers omit `timeoutMs`. */
|
|
18
19
|
export const DEFAULT_LOCAL_IMPACT_TIMEOUT_MS = 30_000;
|
|
20
|
+
/**
|
|
21
|
+
* Cap on neighbour fan-outs attempted per group-impact request.
|
|
22
|
+
*
|
|
23
|
+
* The bound used to be the wall clock alone, which made the cutoff a function
|
|
24
|
+
* of machine load: an idle host traversed more crossings and `mergeRisk`
|
|
25
|
+
* escalated to CRITICAL at three, while a loaded host stopped at two and
|
|
26
|
+
* reported HIGH or lower — same graph, same arguments, different verdict
|
|
27
|
+
* (#2787). A count is deterministic, and the neighbour list carries a total
|
|
28
|
+
* order over the full crossing identity (confidence DESC, then repo, uid,
|
|
29
|
+
* contract), so the cap keeps the strongest crossings rather than an arbitrary
|
|
30
|
+
* prefix.
|
|
31
|
+
*
|
|
32
|
+
* 50 is borrowed from `MAX_CROSSINGS_TO_TRY` (cross-trace.ts) on cost, not on
|
|
33
|
+
* scope — that one caps ContractLinks per repo pair inside a trace, this one
|
|
34
|
+
* caps the total across all neighbour repos in one impact call, and group impact
|
|
35
|
+
* had no numeric cap at all before #2787. The wall clock stays as a hang
|
|
36
|
+
* backstop below; this is the bound that normally binds.
|
|
37
|
+
*/
|
|
38
|
+
export const MAX_NEIGHBOR_FANOUT = 50;
|
|
19
39
|
const CY_NEIGHBORS_UPSTREAM = `
|
|
20
40
|
MATCH (consumer:Contract)-[l:ContractLink]->(provider:Contract)
|
|
21
41
|
WHERE provider.repo = $localRepo
|
|
@@ -268,6 +288,24 @@ export function mergeRisk(localRisk, cross) {
|
|
|
268
288
|
return 'MEDIUM';
|
|
269
289
|
return localRisk;
|
|
270
290
|
}
|
|
291
|
+
/**
|
|
292
|
+
* Build the truncation fields every `runGroupImpact` return path shares.
|
|
293
|
+
*
|
|
294
|
+
* `riskEpistemic` must follow `truncated` mechanically: it is the marker that
|
|
295
|
+
* tells a caller the `risk` value is a floor rather than a verdict, and
|
|
296
|
+
* `mergeRisk` can only under-report once a crossing is dropped. Attaching it at
|
|
297
|
+
* each return let two of the four paths set `truncated` without it, so a
|
|
298
|
+
* truncated result read as complete — deriving it in one place is what keeps
|
|
299
|
+
* the invariant from drifting again (#2787).
|
|
300
|
+
*/
|
|
301
|
+
function truncationFields(truncated,
|
|
302
|
+
// Only read on the truncated branch, so the not-truncated call sites omit it
|
|
303
|
+
// rather than passing a reason that is thrown away.
|
|
304
|
+
reasonIfTruncated = 'partial') {
|
|
305
|
+
if (!truncated)
|
|
306
|
+
return { truncated: false };
|
|
307
|
+
return { truncated: true, truncationReason: reasonIfTruncated, riskEpistemic: 'lower-bound' };
|
|
308
|
+
}
|
|
271
309
|
function addCrossImpact(cross, candidate) {
|
|
272
310
|
const sameBoundary = (entry) => entry.repo_path === candidate.repo_path && entry.contract.id === candidate.contract.id;
|
|
273
311
|
if (candidate.fanout_status === 'not_attempted') {
|
|
@@ -351,7 +389,15 @@ export async function resolveBridgeNeighbors(handle, opts) {
|
|
|
351
389
|
if (n)
|
|
352
390
|
neighbors.push(n);
|
|
353
391
|
}
|
|
354
|
-
|
|
392
|
+
// Sort on the FULL crossing identity — the same triple the fan-out dedups on
|
|
393
|
+
// below (`repo\0uid\0contractId`). Two links that share (confidence, repo,
|
|
394
|
+
// uid) but differ in contract both survive that dedup, so leaving contractId
|
|
395
|
+
// out of the comparator makes them compare 0 and fall back to raw bridge row
|
|
396
|
+
// order, which is what decides who lands past MAX_NEIGHBOR_FANOUT (#2787).
|
|
397
|
+
neighbors.sort((a, b) => b.confidence - a.confidence ||
|
|
398
|
+
compareCodeUnits(a.neighborRepo, b.neighborRepo) ||
|
|
399
|
+
compareCodeUnits(a.neighborUid, b.neighborUid) ||
|
|
400
|
+
compareCodeUnits(a.contractId, b.contractId));
|
|
355
401
|
return neighbors;
|
|
356
402
|
}
|
|
357
403
|
export async function runGroupImpact(deps, params) {
|
|
@@ -390,7 +436,7 @@ export async function runGroupImpact(deps, params) {
|
|
|
390
436
|
group: name,
|
|
391
437
|
cross: [],
|
|
392
438
|
outOfScope: [],
|
|
393
|
-
|
|
439
|
+
...truncationFields(true, 'timeout'),
|
|
394
440
|
truncatedRepos: [],
|
|
395
441
|
summary: {
|
|
396
442
|
direct: 0,
|
|
@@ -400,7 +446,6 @@ export async function runGroupImpact(deps, params) {
|
|
|
400
446
|
},
|
|
401
447
|
risk: 'UNKNOWN',
|
|
402
448
|
timeoutMs,
|
|
403
|
-
truncationReason: 'timeout',
|
|
404
449
|
crossDepthWarning,
|
|
405
450
|
};
|
|
406
451
|
}
|
|
@@ -422,7 +467,7 @@ export async function runGroupImpact(deps, params) {
|
|
|
422
467
|
group: name,
|
|
423
468
|
cross: [],
|
|
424
469
|
outOfScope: [],
|
|
425
|
-
|
|
470
|
+
...truncationFields(false),
|
|
426
471
|
truncatedRepos: [],
|
|
427
472
|
summary: {
|
|
428
473
|
direct: 0,
|
|
@@ -444,7 +489,7 @@ export async function runGroupImpact(deps, params) {
|
|
|
444
489
|
group: name,
|
|
445
490
|
cross: [],
|
|
446
491
|
outOfScope: [],
|
|
447
|
-
|
|
492
|
+
...truncationFields(Boolean(local.partial), 'partial'),
|
|
448
493
|
truncatedRepos: [],
|
|
449
494
|
summary: {
|
|
450
495
|
direct: s.direct ?? 0,
|
|
@@ -454,7 +499,6 @@ export async function runGroupImpact(deps, params) {
|
|
|
454
499
|
},
|
|
455
500
|
risk: String(local.risk ?? 'LOW'),
|
|
456
501
|
timeoutMs,
|
|
457
|
-
truncationReason: local.partial ? 'partial' : undefined,
|
|
458
502
|
crossDepthWarning,
|
|
459
503
|
};
|
|
460
504
|
}
|
|
@@ -465,6 +509,10 @@ export async function runGroupImpact(deps, params) {
|
|
|
465
509
|
const cross = [];
|
|
466
510
|
const outOfScope = [];
|
|
467
511
|
const truncatedRepos = [];
|
|
512
|
+
/** Real `impactByUid` fan-outs issued — what MAX_NEIGHBOR_FANOUT bounds. */
|
|
513
|
+
let attemptedFanouts = 0;
|
|
514
|
+
/** True when the wall-clock backstop, not the count cap, cut the fan-out. */
|
|
515
|
+
let fanoutTimedOut = false;
|
|
468
516
|
try {
|
|
469
517
|
const neighbors = await resolveBridgeNeighbors(handle, {
|
|
470
518
|
localRepo: repoPath,
|
|
@@ -492,7 +540,18 @@ export async function runGroupImpact(deps, params) {
|
|
|
492
540
|
if (seen.has(key))
|
|
493
541
|
continue;
|
|
494
542
|
seen.add(key);
|
|
543
|
+
// Deterministic bound first: the count decides the cutoff on every host.
|
|
544
|
+
// Manifest-only crossings are exempt because they cost no `impactByUid`
|
|
545
|
+
// and dropping them regresses #2784.
|
|
546
|
+
if (!manifestOnly && attemptedFanouts >= MAX_NEIGHBOR_FANOUT) {
|
|
547
|
+
truncatedRepos.push(n.neighborRepo);
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
550
|
+
// Wall clock second, as a hang backstop only. It is load-dependent by
|
|
551
|
+
// construction, so when it is what fired the response must say `timeout`,
|
|
552
|
+
// not the generic `partial` it used to report.
|
|
495
553
|
if (!manifestOnly && deadline - Date.now() <= 0) {
|
|
554
|
+
fanoutTimedOut = true;
|
|
496
555
|
truncatedRepos.push(n.neighborRepo);
|
|
497
556
|
continue;
|
|
498
557
|
}
|
|
@@ -540,6 +599,7 @@ export async function runGroupImpact(deps, params) {
|
|
|
540
599
|
// single hung neighbor would pin the request past the clamped
|
|
541
600
|
// timeout, which Codex's adversarial review on PR #1331 flagged
|
|
542
601
|
// as the still-open half of CodeQL #184 / js/resource-exhaustion.
|
|
602
|
+
attemptedFanouts += 1;
|
|
543
603
|
const { value: fan, timedOut: neighborTimedOut } = await safeNeighborImpact(deps.port, neighborHandle.id, n.neighborUid, direction, {
|
|
544
604
|
maxDepth,
|
|
545
605
|
relationTypes: relationTypes ?? [],
|
|
@@ -547,6 +607,8 @@ export async function runGroupImpact(deps, params) {
|
|
|
547
607
|
includeTests,
|
|
548
608
|
}, remainingMs);
|
|
549
609
|
if (neighborTimedOut || fan == null) {
|
|
610
|
+
if (neighborTimedOut)
|
|
611
|
+
fanoutTimedOut = true;
|
|
550
612
|
truncatedRepos.push(n.neighborRepo);
|
|
551
613
|
continue;
|
|
552
614
|
}
|
|
@@ -576,7 +638,12 @@ export async function runGroupImpact(deps, params) {
|
|
|
576
638
|
group: name,
|
|
577
639
|
cross,
|
|
578
640
|
outOfScope,
|
|
579
|
-
|
|
641
|
+
// The risk VALUE is never clamped down. `mergeRisk` is monotone increasing
|
|
642
|
+
// in the traversed-crossing count, so truncation can only under-report —
|
|
643
|
+
// and under-reporting a blast radius is the unsafe direction (an agent told
|
|
644
|
+
// LOW proceeds; told CRITICAL it stops). Marking the floor keeps the
|
|
645
|
+
// warning intact while making the incompleteness legible.
|
|
646
|
+
...truncationFields(truncated, fanoutTimedOut ? 'timeout' : 'partial'),
|
|
580
647
|
truncatedRepos: [...new Set(truncatedRepos)],
|
|
581
648
|
summary: {
|
|
582
649
|
direct: localSum.direct ?? 0,
|
|
@@ -586,9 +653,11 @@ export async function runGroupImpact(deps, params) {
|
|
|
586
653
|
},
|
|
587
654
|
risk: mergeRisk(localRisk, cross),
|
|
588
655
|
timeoutMs,
|
|
589
|
-
truncationReason: truncated ? 'partial' : undefined,
|
|
590
656
|
crossDepthWarning,
|
|
591
657
|
};
|
|
592
658
|
return result;
|
|
593
659
|
}
|
|
594
660
|
export { normalizeServicePrefix, fileMatchesServicePrefix } from './group-path-utils.js';
|
|
661
|
+
// Re-exported, not redefined: the single definition lives in lib/utils.ts, but
|
|
662
|
+
// this module's own surface is what the #2787 regression test imports it from.
|
|
663
|
+
export { compareCodeUnits };
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
import { GroupNotFoundError, loadGroupConfig } from './config-parser.js';
|
|
26
26
|
import { getGroupDir } from './storage.js';
|
|
27
27
|
import { ensureBridgeReady, MAX_SUPPORTED_CROSS_DEPTH } from './cross-impact.js';
|
|
28
|
+
import { compareCodeUnits } from '../../lib/utils.js';
|
|
28
29
|
import { closeBridgeDb, queryBridge } from './bridge-db.js';
|
|
29
30
|
/**
|
|
30
31
|
* Centralized degraded-state messages so wording stays consistent and
|
|
@@ -115,7 +116,7 @@ RETURN consumer.symbolUid AS consumerUid,
|
|
|
115
116
|
l.confidence AS confidence,
|
|
116
117
|
l.contractId AS contractId,
|
|
117
118
|
consumer.type AS contractType
|
|
118
|
-
ORDER BY l.confidence DESC
|
|
119
|
+
ORDER BY l.confidence DESC, contractId, consumerUid, providerUid
|
|
119
120
|
LIMIT ${MAX_CROSSINGS_TO_TRY + 1}
|
|
120
121
|
`;
|
|
121
122
|
// Destination trace: every ContractLink leaving a consumer repo, to ANY provider
|
|
@@ -136,7 +137,7 @@ RETURN consumer.symbolUid AS consumerUid,
|
|
|
136
137
|
l.confidence AS confidence,
|
|
137
138
|
l.contractId AS contractId,
|
|
138
139
|
consumer.type AS contractType
|
|
139
|
-
ORDER BY l.confidence DESC
|
|
140
|
+
ORDER BY l.confidence DESC, contractId, consumerUid, providerUid
|
|
140
141
|
LIMIT ${MAX_CROSSINGS_TO_TRY + 1}
|
|
141
142
|
`;
|
|
142
143
|
function rowToCrossing(r) {
|
|
@@ -164,6 +165,28 @@ function rowToCrossing(r) {
|
|
|
164
165
|
contractType: String(r.contractType ?? r[7] ?? 'custom'),
|
|
165
166
|
};
|
|
166
167
|
}
|
|
168
|
+
/**
|
|
169
|
+
* Sort a crossing list on the full crossing identity, then apply
|
|
170
|
+
* `MAX_CROSSINGS_TO_TRY`.
|
|
171
|
+
*
|
|
172
|
+
* Both bridge queries already order by confidence DESC; this re-sorts
|
|
173
|
+
* defensively so the cap keeps the strongest candidates even if a tuple-mode
|
|
174
|
+
* driver reorders rows. The comparator mirrors BOTH queries' `ORDER BY`
|
|
175
|
+
* key-for-key — a shorter comparator reproduces less than the order it is
|
|
176
|
+
* defending, leaving ties on raw row order (#2787) — so it lives in one place
|
|
177
|
+
* rather than being hand-synced across the two callers and two Cypher clauses.
|
|
178
|
+
*/
|
|
179
|
+
function capCrossings(all) {
|
|
180
|
+
all.sort((a, b) => b.confidence - a.confidence ||
|
|
181
|
+
compareCodeUnits(a.contractId, b.contractId) ||
|
|
182
|
+
compareCodeUnits(a.consumerUid, b.consumerUid) ||
|
|
183
|
+
compareCodeUnits(a.providerUid, b.providerUid));
|
|
184
|
+
const truncated = all.length > MAX_CROSSINGS_TO_TRY;
|
|
185
|
+
return {
|
|
186
|
+
crossings: truncated ? all.slice(0, MAX_CROSSINGS_TO_TRY) : all,
|
|
187
|
+
truncated,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
167
190
|
async function listCrossingsBetween(handle, fromRepo, toRepo) {
|
|
168
191
|
const rows = await queryBridge(handle, CY_CROSSINGS_BETWEEN, {
|
|
169
192
|
fromRepo,
|
|
@@ -175,14 +198,7 @@ async function listCrossingsBetween(handle, fromRepo, toRepo) {
|
|
|
175
198
|
if (c)
|
|
176
199
|
all.push(c);
|
|
177
200
|
}
|
|
178
|
-
|
|
179
|
-
// keeps the strongest candidates even if a tuple-mode driver reorders rows.
|
|
180
|
-
all.sort((a, b) => b.confidence - a.confidence);
|
|
181
|
-
const truncated = all.length > MAX_CROSSINGS_TO_TRY;
|
|
182
|
-
return {
|
|
183
|
-
crossings: truncated ? all.slice(0, MAX_CROSSINGS_TO_TRY) : all,
|
|
184
|
-
truncated,
|
|
185
|
-
};
|
|
201
|
+
return capCrossings(all);
|
|
186
202
|
}
|
|
187
203
|
function destRowToCrossing(r) {
|
|
188
204
|
const consumerUid = String(r.consumerUid ?? r[0] ?? '');
|
|
@@ -212,12 +228,7 @@ async function listCrossingsFrom(handle, fromRepo) {
|
|
|
212
228
|
if (c)
|
|
213
229
|
all.push(c);
|
|
214
230
|
}
|
|
215
|
-
all
|
|
216
|
-
const truncated = all.length > MAX_CROSSINGS_TO_TRY;
|
|
217
|
-
return {
|
|
218
|
-
crossings: truncated ? all.slice(0, MAX_CROSSINGS_TO_TRY) : all,
|
|
219
|
-
truncated,
|
|
220
|
-
};
|
|
231
|
+
return capCrossings(all);
|
|
221
232
|
}
|
|
222
233
|
/**
|
|
223
234
|
* Resolve a symbol query across every member repo. Exactly one `ok` match and
|
|
@@ -89,6 +89,10 @@ RETURN sym.id AS uid, sym.name AS name, sym.filePath AS filePath,
|
|
|
89
89
|
// degenerate edge-less node NOR inflates the uniqueness count and masks the real
|
|
90
90
|
// handler. `LIMIT 2` bounds materialization: distinguishing unique (1) from
|
|
91
91
|
// ambiguous (>=2) never needs more than two rows (the count guard stays exact).
|
|
92
|
+
//
|
|
93
|
+
// determinism: probe — uniqueness discriminator, not a window. `toResolvedSymbol`
|
|
94
|
+
// reads row 0 only when `rows.length === 1`; a 2-row result is discarded whole,
|
|
95
|
+
// so WHICH two rows came back can never reach a caller.
|
|
92
96
|
export const RESOLVE_BY_NAME_QUERY = `
|
|
93
97
|
MATCH (n) WHERE labels(n) IN ['Function','Method','CodeElement']
|
|
94
98
|
AND n.name = $name AND n.filePath <> ''
|
|
@@ -100,6 +104,10 @@ LIMIT 2`;
|
|
|
100
104
|
// the precise rung — it survives aliases and local same-name collisions that a
|
|
101
105
|
// repo-wide name lookup cannot, and only resolves on a unique match within that
|
|
102
106
|
// module. `LIMIT 2` keeps the uniqueness count exact (see RESOLVE_BY_NAME_QUERY).
|
|
107
|
+
//
|
|
108
|
+
// determinism: probe — uniqueness discriminator, not a window. Same consumer as
|
|
109
|
+
// RESOLVE_BY_NAME_QUERY: `toResolvedSymbol` reads row 0 only when exactly one row
|
|
110
|
+
// came back, and discards a 2-row result whole.
|
|
103
111
|
export const RESOLVE_IN_MODULE_QUERY = `
|
|
104
112
|
MATCH (n) WHERE labels(n) IN ['Function','Method','CodeElement']
|
|
105
113
|
AND n.name = $name AND (n.filePath STARTS WITH $fileDot OR n.filePath STARTS WITH $fileSlash)
|