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.
Files changed (40) hide show
  1. package/dist/cli/analyze.js +37 -8
  2. package/dist/cli/cli-message.d.ts +1 -1
  3. package/dist/cli/group.js +20 -1
  4. package/dist/core/augmentation/engine.js +86 -71
  5. package/dist/core/group/cross-impact.d.ts +21 -0
  6. package/dist/core/group/cross-impact.js +77 -8
  7. package/dist/core/group/cross-trace.js +27 -16
  8. package/dist/core/group/extractors/http-route-extractor.js +8 -0
  9. package/dist/core/group/extractors/manifest-extractor.d.ts +1 -1
  10. package/dist/core/group/extractors/manifest-extractor.js +7 -7
  11. package/dist/core/group/types.d.ts +12 -0
  12. package/dist/core/ingestion/scope-resolution/graph-bridge/ids.d.ts +20 -0
  13. package/dist/core/ingestion/scope-resolution/graph-bridge/ids.js +11 -8
  14. package/dist/core/ingestion/scope-resolution/graph-bridge/node-lookup.d.ts +14 -0
  15. package/dist/core/ingestion/scope-resolution/graph-bridge/node-lookup.js +47 -31
  16. package/dist/core/lbug/csv-generator.js +4 -4
  17. package/dist/core/lbug/graph-emit-sink.d.ts +0 -1
  18. package/dist/core/lbug/graph-emit-sink.js +14 -14
  19. package/dist/core/lbug/lbug-adapter.d.ts +9 -0
  20. package/dist/core/lbug/lbug-adapter.js +21 -6
  21. package/dist/core/lbug/pdg-emit-sink.d.ts +2 -3
  22. package/dist/core/lbug/pdg-emit-sink.js +12 -13
  23. package/dist/core/lbug/pool-adapter.js +3 -0
  24. package/dist/core/lbug/rel-pair-routing.d.ts +143 -10
  25. package/dist/core/lbug/rel-pair-routing.js +202 -20
  26. package/dist/core/lbug/schema.d.ts +38 -1
  27. package/dist/core/lbug/schema.js +236 -161
  28. package/dist/core/run-analyze.js +15 -3
  29. package/dist/core/search/bm25-index.js +5 -8
  30. package/dist/core/wiki/graph-queries.js +11 -2
  31. package/dist/lib/utils.d.ts +50 -0
  32. package/dist/lib/utils.js +62 -0
  33. package/dist/mcp/local/bean-metadata.js +7 -0
  34. package/dist/mcp/local/local-backend.js +430 -88
  35. package/dist/mcp/local/pdg-impact.js +14 -2
  36. package/dist/mcp/tools.js +10 -6
  37. package/dist/server/api.js +4 -0
  38. package/dist/storage/repo-manager.d.ts +21 -1
  39. package/dist/storage/repo-manager.js +21 -1
  40. package/package.json +1 -1
@@ -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. Depth-bounded so a cyclic `cause` can't loop
58
- // (the phase runner wraps one level; the bound leaves headroom for future
59
- // nesting); uses realStderrWrite so the redirected console.error's ANSI
60
- // clear-line wrapping can't erase it (#1169).
61
- const MAX_CAUSE_DEPTH = 5;
62
- let cause = isErr ? err.cause : undefined;
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
- // Batch fetch callers
157
- const callersMap = new Map();
158
- try {
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 (caller)-[:CodeRelation {type: 'CALLS'}]->(n)
161
- WHERE n.id IN [${idList}]
162
- RETURN n.id AS targetId, caller.name AS name
163
- LIMIT 15
164
- `);
165
- for (const r of rows) {
166
- const tid = r.targetId || r[0];
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 sid = r.sourceId || r[0];
189
- const name = r.name || r[1];
190
- if (sid && name) {
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
- catch {
198
- /* skip */
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 processesMap = new Map();
202
- try {
203
- const rows = await executeQuery(repoId, `
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
- for (const r of rows) {
209
- const nid = r.nodeId || r[0];
210
- const label = r.label || r[1];
211
- const step = r.step || r[2];
212
- const stepCount = r.stepCount || r[3];
213
- if (nid && label) {
214
- if (!processesMap.has(nid))
215
- processesMap.set(nid, []);
216
- processesMap.get(nid).push(`${label} (step ${step}/${stepCount})`);
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
- catch {
221
- /* skip */
222
- }
218
+ catch {
219
+ /* skip */
220
+ }
221
+ return byNode;
222
+ };
223
223
  // Batch fetch cohesion
224
- const cohesionMap = new Map();
225
- try {
226
- const rows = await executeQuery(repoId, `
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
- for (const r of rows) {
232
- const nid = r.nodeId || r[0];
233
- const coh = r.cohesion ?? r[1] ?? 0;
234
- if (nid)
235
- cohesionMap.set(nid, coh);
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
- catch {
239
- /* skip */
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: (callersMap.get(sym.nodeId) || []).slice(0, 3),
248
- callees: (calleesMap.get(sym.nodeId) || []).slice(0, 3),
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
- neighbors.sort((a, b) => b.confidence - a.confidence);
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
- truncated: true,
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
- truncated: false,
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
- truncated: Boolean(local.partial),
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
- truncated,
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
- // The query already orders by confidence DESC; re-sort defensively so the cap
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.sort((a, b) => b.confidence - a.confidence);
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)