gitnexus 1.6.8-rc.6 → 1.6.8-rc.7
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-config.js +39 -0
- package/dist/cli/analyze.d.ts +8 -0
- package/dist/cli/analyze.js +3 -0
- package/dist/cli/eval-server.js +43 -0
- package/dist/core/ingestion/pipeline-phases/routes.js +64 -14
- package/dist/core/ingestion/pipeline.d.ts +9 -0
- package/dist/core/run-analyze.d.ts +7 -0
- package/dist/core/run-analyze.js +1 -0
- package/dist/mcp/local/local-backend.d.ts +31 -0
- package/dist/mcp/local/local-backend.js +259 -10
- package/package.json +1 -1
|
@@ -78,6 +78,12 @@ const KEY_SPECS = {
|
|
|
78
78
|
embeddingBatchSize: { target: 'embeddingBatchSize', kind: 'numeric-string' },
|
|
79
79
|
embeddingSubBatchSize: { target: 'embeddingSubBatchSize', kind: 'numeric-string' },
|
|
80
80
|
embeddingDevice: { target: 'embeddingDevice', kind: 'string' },
|
|
81
|
+
// #1589/#1852 residual — extra fetch-wrapper function names to treat as HTTP
|
|
82
|
+
// consumers. The auto-detector only flags functions that call the bare global
|
|
83
|
+
// `fetch()`; a wrapper built on axios / a custom client, or named outside the
|
|
84
|
+
// built-in convention set, is otherwise invisible to route_map consumers.
|
|
85
|
+
// Listing it here adds it to the cross-file consumer scan.
|
|
86
|
+
fetchWrappers: { target: 'fetchWrappers', kind: 'string-array' },
|
|
81
87
|
};
|
|
82
88
|
/** Top-level container key for the nested form; not itself an `AnalyzeOptions` field. */
|
|
83
89
|
const NESTED_KEY = 'analyze';
|
|
@@ -195,6 +201,39 @@ const normalizeValue = (kind, value, key) => {
|
|
|
195
201
|
}
|
|
196
202
|
return trimmed;
|
|
197
203
|
}
|
|
204
|
+
case 'string-array': {
|
|
205
|
+
// Generic shared validator — `source` already names the config key, so
|
|
206
|
+
// messages here stay key-agnostic (no fetch-wrapper coupling in the
|
|
207
|
+
// shared normalizer; #1589/#1852 review F7).
|
|
208
|
+
if (!Array.isArray(value)) {
|
|
209
|
+
throw new GitNexusRcError(`${source} must be an array of strings.`);
|
|
210
|
+
}
|
|
211
|
+
const names = [];
|
|
212
|
+
for (const item of value) {
|
|
213
|
+
if (typeof item !== 'string') {
|
|
214
|
+
throw new GitNexusRcError(`${source} entries must all be strings.`);
|
|
215
|
+
}
|
|
216
|
+
const trimmed = item.trim();
|
|
217
|
+
if (!trimmed) {
|
|
218
|
+
throw new GitNexusRcError(`${source} entries must not be empty.`);
|
|
219
|
+
}
|
|
220
|
+
assertNoHiddenChars(trimmed, source);
|
|
221
|
+
// Values may be interpolated into a RegExp downstream. Restrict to
|
|
222
|
+
// identifier / member-access shapes so a config value can never smuggle
|
|
223
|
+
// regex metacharacters into a consumer.
|
|
224
|
+
if (!/^[A-Za-z_$][A-Za-z0-9_$.]*$/.test(trimmed)) {
|
|
225
|
+
throw new GitNexusRcError(`${source} entry "${trimmed}" must be an identifier or member name ` +
|
|
226
|
+
`(letters, digits, _, $, . — e.g. "client.get").`);
|
|
227
|
+
}
|
|
228
|
+
names.push(trimmed);
|
|
229
|
+
}
|
|
230
|
+
if (names.length === 0) {
|
|
231
|
+
throw new GitNexusRcError(`${source} must list at least one string.`);
|
|
232
|
+
}
|
|
233
|
+
// De-duplicate and cap to a sane bound so a pathological config cannot
|
|
234
|
+
// blow up the consumer scan's alternation.
|
|
235
|
+
return Array.from(new Set(names)).slice(0, 100);
|
|
236
|
+
}
|
|
198
237
|
case 'numeric-string': {
|
|
199
238
|
// Mirror Commander's contract: these options reach the existing CLI
|
|
200
239
|
// validation as strings. Accept a JSON number or a string; normalize to a
|
package/dist/cli/analyze.d.ts
CHANGED
|
@@ -101,6 +101,14 @@ export interface AnalyzeOptions {
|
|
|
101
101
|
embeddingBatchSize?: string;
|
|
102
102
|
embeddingSubBatchSize?: string;
|
|
103
103
|
embeddingDevice?: string;
|
|
104
|
+
/**
|
|
105
|
+
* Extra fetch-wrapper function names to treat as HTTP consumers (#1589/#1852
|
|
106
|
+
* residual). Supplied via `.gitnexusrc` `fetchWrappers: [...]`. Threaded into
|
|
107
|
+
* the routes phase, where the cross-file consumer scan unions them with the
|
|
108
|
+
* auto-detected `fetch()` wrappers so a custom/axios-based wrapper named
|
|
109
|
+
* outside the built-in convention still produces `route_map` consumers.
|
|
110
|
+
*/
|
|
111
|
+
fetchWrappers?: string[];
|
|
104
112
|
}
|
|
105
113
|
/**
|
|
106
114
|
* Whether the post-index skill step should run.
|
package/dist/cli/analyze.js
CHANGED
|
@@ -864,6 +864,9 @@ const analyzeCommandImpl = async (inputPath, cliOptions) => {
|
|
|
864
864
|
// GITNEXUS_WORKER_POOL_SIZE env mutation. `undefined` defers to the
|
|
865
865
|
// env / auto-formula fallback inside the pipeline.
|
|
866
866
|
workerPoolSize,
|
|
867
|
+
// Extra fetch-wrapper names from `.gitnexusrc` (#1589/#1852 residual);
|
|
868
|
+
// forwarded to the routes phase consumer scan.
|
|
869
|
+
fetchWrappers: options.fetchWrappers,
|
|
867
870
|
}, {
|
|
868
871
|
onProgress: (_phase, percent, message) => {
|
|
869
872
|
updateBar(percent, message);
|
package/dist/cli/eval-server.js
CHANGED
|
@@ -155,7 +155,43 @@ export function formatImpactResult(result) {
|
|
|
155
155
|
const direction = result.direction;
|
|
156
156
|
const byDepth = result.byDepth || {};
|
|
157
157
|
const total = result.impactedCount || 0;
|
|
158
|
+
// #2129 — an ambiguous bare name must not print the "isolated / safe to
|
|
159
|
+
// refactor" headline. Surface the per-candidate blast radius + the maximum,
|
|
160
|
+
// mirroring formatContextResult, so the real impact under whichever symbol the
|
|
161
|
+
// caller meant is visible on the text surface, not just in the JSON.
|
|
162
|
+
if (result.status === 'ambiguous') {
|
|
163
|
+
// #2129 review F11 — report the FULL match count (`totalCandidates`), not the
|
|
164
|
+
// truncated `candidates[]` length; note when the candidate list is capped.
|
|
165
|
+
const shown = result.candidates?.length ?? 0;
|
|
166
|
+
const total = result.totalCandidates ?? shown;
|
|
167
|
+
const countPhrase = total > shown ? `${total} symbols (showing ${shown})` : `${total} symbols`;
|
|
168
|
+
const lines = [
|
|
169
|
+
`${target?.name || '?'}: AMBIGUOUS — ${countPhrase} share this name. ` +
|
|
170
|
+
`Max blast radius ${result.maxImpactedCount ?? 0} (${result.maxRisk ?? 'UNKNOWN'} risk). ` +
|
|
171
|
+
`Disambiguate with --uid for one authoritative result:`,
|
|
172
|
+
];
|
|
173
|
+
for (const c of result.candidates || []) {
|
|
174
|
+
lines.push(` ${c.kind} ${c.name} → ${c.filePath}:${c.line || '?'} ` +
|
|
175
|
+
`[${c.impactedCount ?? 0} ${direction}, risk ${c.risk ?? 'UNKNOWN'}] (uid: ${c.uid})`);
|
|
176
|
+
}
|
|
177
|
+
// #2129 review F1 — a failed per-candidate probe makes the max a lower bound.
|
|
178
|
+
if (result.partialProbe) {
|
|
179
|
+
lines.push(' ⚠️ One or more candidate probes failed — max blast radius / risk are lower bounds.');
|
|
180
|
+
}
|
|
181
|
+
return lines.join('\n');
|
|
182
|
+
}
|
|
158
183
|
if (total === 0) {
|
|
184
|
+
// #1858 — "isolated" is a confident claim. If an interface / indirection
|
|
185
|
+
// boundary is on the path, the true count is a lower bound, not zero;
|
|
186
|
+
// callers binding via DI / dynamic dispatch were not traced. Say so instead.
|
|
187
|
+
if (result.epistemic === 'lower-bound') {
|
|
188
|
+
const lines = [
|
|
189
|
+
`${target?.name || '?'}: no direct ${direction} dependencies traced, but this is a LOWER BOUND — unresolved indirection on the path (actual impact may be higher):`,
|
|
190
|
+
];
|
|
191
|
+
for (const b of result.boundaries || [])
|
|
192
|
+
lines.push(` • ${b}`);
|
|
193
|
+
return lines.join('\n');
|
|
194
|
+
}
|
|
159
195
|
return `${target?.name || '?'}: No ${direction} dependencies found. This symbol appears isolated.`;
|
|
160
196
|
}
|
|
161
197
|
const lines = [];
|
|
@@ -164,6 +200,13 @@ export function formatImpactResult(result) {
|
|
|
164
200
|
if (result.partial) {
|
|
165
201
|
lines.push('⚠️ Partial results — graph traversal was interrupted. Deeper impacts may exist.');
|
|
166
202
|
}
|
|
203
|
+
// #1858 — an interface / indirection boundary on the path makes this a lower
|
|
204
|
+
// bound; surface it so the count is not read as exhaustive.
|
|
205
|
+
if (result.epistemic === 'lower-bound') {
|
|
206
|
+
lines.push('⚠️ Lower bound — unresolved indirection on the path (callers binding via DI / dynamic dispatch are not traced; actual impact may be higher):');
|
|
207
|
+
for (const b of result.boundaries || [])
|
|
208
|
+
lines.push(` • ${b}`);
|
|
209
|
+
}
|
|
167
210
|
lines.push('');
|
|
168
211
|
const depthLabels = {
|
|
169
212
|
1: 'WILL BREAK (direct)',
|
|
@@ -297,22 +297,72 @@ export const routesPhase = {
|
|
|
297
297
|
// scan JS/TS consumer files for calls to those wrapper functions with
|
|
298
298
|
// URL-like string arguments and add them to allFetchCalls so
|
|
299
299
|
// processNextjsFetchRoutes can create FETCHES edges.
|
|
300
|
-
|
|
301
|
-
|
|
300
|
+
// Wrapper names come from two sources: functions the parse phase
|
|
301
|
+
// auto-detected as calling the bare global `fetch()`, plus any names the
|
|
302
|
+
// user declared in `.gitnexusrc` `fetchWrappers` (#1589/#1852 residual).
|
|
303
|
+
// Config names let an axios/custom-client wrapper — or one named outside the
|
|
304
|
+
// built-in convention — still produce route_map consumers; without them it
|
|
305
|
+
// silently falls back to `consumers: []`. Configured names alone are enough
|
|
306
|
+
// to run the scan even when nothing was auto-detected.
|
|
307
|
+
// Configured names are already validated/trimmed/de-duped/capped by
|
|
308
|
+
// analyze-config.ts — trusted as-is (#1589/#1852 review F9, dropped the
|
|
309
|
+
// redundant re-trim/re-filter). The single filter below guards only the
|
|
310
|
+
// auto-detected `functionName`s, which have no shape guarantee.
|
|
311
|
+
const configuredWrappers = ctx.options?.fetchWrappers ?? [];
|
|
312
|
+
const wrapperNames = new Set([...(allFetchWrapperDefs ?? []).map((d) => d.functionName), ...configuredWrappers].filter((n) => typeof n === 'string' && n.trim().length > 0));
|
|
313
|
+
if (wrapperNames.size > 0 && routeRegistry.size > 0) {
|
|
302
314
|
const jsFiles = allPaths.filter((p) => /\.[jt]sx?$/.test(p));
|
|
303
|
-
if (jsFiles.length > 0
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
+
if (jsFiles.length > 0) {
|
|
316
|
+
// Reuse contents already read for handler extraction; only read the
|
|
317
|
+
// remainder (mirrors the Expo block above). Avoids a second full read of
|
|
318
|
+
// files we already have in memory.
|
|
319
|
+
const unreadJsFiles = jsFiles.filter((p) => !handlerContents?.has(p));
|
|
320
|
+
const extraContents = unreadJsFiles.length > 0
|
|
321
|
+
? await readFileContents(ctx.repoPath, unreadJsFiles)
|
|
322
|
+
: new Map();
|
|
323
|
+
// One alternation regex over every wrapper name per file — O(files), not
|
|
324
|
+
// O(files × wrappers) (#1852 review F3). Names are escaped and grouped
|
|
325
|
+
// non-capturing so capture group 1 stays the URL. The left boundary is a
|
|
326
|
+
// negative lookbehind, not `\b`: a bare configured name like `get` must
|
|
327
|
+
// match the free call `get('/x')` but NOT a member access `client.get(`
|
|
328
|
+
// (a `.get(` on an unrelated object), and `apiFetch` must not match
|
|
329
|
+
// `myApiFetch`. Member-style wrappers are configured with the dot
|
|
330
|
+
// (`client.get`), where the `.` is part of the pattern. The `u` flag +
|
|
331
|
+
// Unicode property classes make the boundary cover non-ASCII identifier
|
|
332
|
+
// characters too — ASCII `\w` would let `caféget('/x')` match `get`
|
|
333
|
+
// (#1852 review F10).
|
|
334
|
+
const alternation = [...wrapperNames].map(escapeRegex).join('|');
|
|
335
|
+
const wrapperCallRegex = new RegExp(`(?<![.\\p{L}\\p{N}_$])(?:${alternation})\\s*\\(\\s*['"\`](/[^'"\`\\s)]+)['"\`]`, 'gu');
|
|
336
|
+
const scanContent = (filePath, content) => {
|
|
337
|
+
wrapperCallRegex.lastIndex = 0;
|
|
338
|
+
// 1-based line number via a running newline counter: matches arrive in
|
|
339
|
+
// ascending index, so accumulate newlines incrementally instead of
|
|
340
|
+
// re-allocating `content.substring(0, match.index).split('\n')` on
|
|
341
|
+
// every match (#1852 review F12). Output is identical.
|
|
342
|
+
let line = 1;
|
|
343
|
+
let scanned = 0;
|
|
344
|
+
let match;
|
|
345
|
+
while ((match = wrapperCallRegex.exec(content)) !== null) {
|
|
346
|
+
for (; scanned < match.index; scanned++) {
|
|
347
|
+
if (content.charCodeAt(scanned) === 10 /* '\n' */)
|
|
348
|
+
line++;
|
|
315
349
|
}
|
|
350
|
+
allFetchCalls.push({
|
|
351
|
+
filePath,
|
|
352
|
+
fetchURL: match[1],
|
|
353
|
+
lineNumber: line,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
for (const [filePath, content] of extraContents)
|
|
358
|
+
scanContent(filePath, content);
|
|
359
|
+
// Also scan already-read JS/TS handler files (a handler can itself
|
|
360
|
+
// consume another route through a wrapper).
|
|
361
|
+
if (handlerContents) {
|
|
362
|
+
for (const p of jsFiles) {
|
|
363
|
+
const cached = handlerContents.get(p);
|
|
364
|
+
if (cached !== undefined)
|
|
365
|
+
scanContent(p, cached);
|
|
316
366
|
}
|
|
317
367
|
}
|
|
318
368
|
}
|
|
@@ -104,6 +104,15 @@ export interface PipelineOptions {
|
|
|
104
104
|
* `process.env` state across invocations. When undefined, the env var decides.
|
|
105
105
|
*/
|
|
106
106
|
keepLocalValueSymbols?: boolean;
|
|
107
|
+
/**
|
|
108
|
+
* Extra fetch-wrapper function names to treat as HTTP consumers, threaded
|
|
109
|
+
* from `.gitnexusrc` `fetchWrappers` via `AnalyzeOptions` (#1589/#1852
|
|
110
|
+
* residual). The routes phase unions these with the auto-detected `fetch()`
|
|
111
|
+
* wrappers when scanning for `route_map` consumers, so a wrapper named outside
|
|
112
|
+
* the built-in convention (or built on axios / a custom client) is still
|
|
113
|
+
* traced. Empty/undefined leaves behavior unchanged.
|
|
114
|
+
*/
|
|
115
|
+
fetchWrappers?: readonly string[];
|
|
107
116
|
}
|
|
108
117
|
/**
|
|
109
118
|
* All pipeline phases with their dependency relationships.
|
|
@@ -88,6 +88,13 @@ export interface AnalyzeOptions {
|
|
|
88
88
|
* removed); `undefined` defers to the env / auto-formula fallback.
|
|
89
89
|
*/
|
|
90
90
|
workerPoolSize?: number;
|
|
91
|
+
/**
|
|
92
|
+
* Extra fetch-wrapper function names to treat as HTTP consumers, forwarded to
|
|
93
|
+
* `PipelineOptions.fetchWrappers` (#1589/#1852 residual). Sourced from the CLI
|
|
94
|
+
* `.gitnexusrc` `fetchWrappers` list. `undefined`/empty leaves the route
|
|
95
|
+
* consumer scan unchanged.
|
|
96
|
+
*/
|
|
97
|
+
fetchWrappers?: string[];
|
|
91
98
|
}
|
|
92
99
|
export interface AnalyzeResult {
|
|
93
100
|
repoName: string;
|
package/dist/core/run-analyze.js
CHANGED
|
@@ -416,6 +416,7 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
|
|
|
416
416
|
}, {
|
|
417
417
|
parseCache,
|
|
418
418
|
workerPoolSize: options.workerPoolSize,
|
|
419
|
+
fetchWrappers: options.fetchWrappers,
|
|
419
420
|
});
|
|
420
421
|
// ── Phase 2: LadybugDB (60–85%) ──────────────────────────────────
|
|
421
422
|
progress('lbug', 60, 'Loading into LadybugDB...');
|
|
@@ -16,6 +16,15 @@ export declare function isTestFilePath(filePath: string): boolean;
|
|
|
16
16
|
export declare const VALID_NODE_LABELS: Set<string>;
|
|
17
17
|
/** Valid relation types for impact analysis filtering */
|
|
18
18
|
export declare const VALID_RELATION_TYPES: Set<string>;
|
|
19
|
+
/**
|
|
20
|
+
* Relation types the #1858 epistemic-boundary probe keys on. Kept as
|
|
21
|
+
* module-level `readonly` arrays (not Sets) because computeEpistemicBoundary
|
|
22
|
+
* binds them as Cypher query params (`r.type IN $heritage` / `IN $types`).
|
|
23
|
+
* The heritage set is exactly the IMPACT_RELATION_CONFIDENCE 0.85 tier —
|
|
24
|
+
* "statically verifiable, but the concrete binding past it is not".
|
|
25
|
+
*/
|
|
26
|
+
export declare const EPISTEMIC_HERITAGE_RELATION_TYPES: readonly string[];
|
|
27
|
+
export declare const EPISTEMIC_CONSUMER_RELATION_TYPES: readonly string[];
|
|
19
28
|
/**
|
|
20
29
|
* Per-relation-type confidence floor for impact analysis.
|
|
21
30
|
*
|
|
@@ -431,6 +440,28 @@ export declare class LocalBackend {
|
|
|
431
440
|
private rename;
|
|
432
441
|
private impact;
|
|
433
442
|
private _impactImpl;
|
|
443
|
+
/**
|
|
444
|
+
* #1858 — epistemic lower-bound detection.
|
|
445
|
+
*
|
|
446
|
+
* impact()/context() traverse only edges materialized in the graph. When the
|
|
447
|
+
* queried symbol sits on an interface / abstract boundary, callers that bind
|
|
448
|
+
* to the interface via DI, a container, or dynamic dispatch — rather than
|
|
449
|
+
* naming the concrete symbol — are not traced. The reported count is then a
|
|
450
|
+
* lower bound, not an exact figure. Instead of returning a confident count
|
|
451
|
+
* that silently omits those callers, annotate the result with
|
|
452
|
+
* `epistemic: 'lower-bound'` plus a human-readable boundary note. A fully
|
|
453
|
+
* resolved leaf with no indirection stays `epistemic: 'exact'`.
|
|
454
|
+
*
|
|
455
|
+
* Aligns with the numeric confidence model rather than the long-deleted
|
|
456
|
+
* TIER_CONFIDENCE enum: the heritage/indirection edges this keys on
|
|
457
|
+
* (IMPLEMENTS / METHOD_IMPLEMENTS / EXTENDS) carry the 0.85
|
|
458
|
+
* `IMPACT_RELATION_CONFIDENCE` floor — "statically verifiable, but the
|
|
459
|
+
* concrete binding past it is not".
|
|
460
|
+
*
|
|
461
|
+
* Never throws: on query error it returns 'exact', so it can only add signal,
|
|
462
|
+
* never suppress a result.
|
|
463
|
+
*/
|
|
464
|
+
private computeEpistemicBoundary;
|
|
434
465
|
/**
|
|
435
466
|
* Shared BFS traversal for impact analysis (name-resolved or UID-resolved symbol).
|
|
436
467
|
*/
|
|
@@ -96,12 +96,32 @@ export const VALID_RELATION_TYPES = new Set([
|
|
|
96
96
|
'OVERRIDES', // Legacy alias — dual-read for pre-rename indexes
|
|
97
97
|
'METHOD_IMPLEMENTS',
|
|
98
98
|
'ACCESSES',
|
|
99
|
+
// Emitted by emit-references.ts / scope-resolution/graph-bridge/edges.ts and
|
|
100
|
+
// already part of the default impact relTypes + context() incoming queries.
|
|
101
|
+
// It was missing from this allowlist, so `impact({relationTypes:['USES']})`
|
|
102
|
+
// silently filtered to [] and fell back to the full default traversal
|
|
103
|
+
// (#2129/#1858 review F5). No IMPACT_RELATION_CONFIDENCE floor → 0.5 fallback,
|
|
104
|
+
// matching the FETCHES / WRAPS / HANDLES_ROUTE precedent below.
|
|
105
|
+
'USES',
|
|
99
106
|
'HANDLES_ROUTE',
|
|
100
107
|
'FETCHES',
|
|
101
108
|
'HANDLES_TOOL',
|
|
102
109
|
'ENTRY_POINT_OF',
|
|
103
110
|
'WRAPS',
|
|
104
111
|
]);
|
|
112
|
+
/**
|
|
113
|
+
* Relation types the #1858 epistemic-boundary probe keys on. Kept as
|
|
114
|
+
* module-level `readonly` arrays (not Sets) because computeEpistemicBoundary
|
|
115
|
+
* binds them as Cypher query params (`r.type IN $heritage` / `IN $types`).
|
|
116
|
+
* The heritage set is exactly the IMPACT_RELATION_CONFIDENCE 0.85 tier —
|
|
117
|
+
* "statically verifiable, but the concrete binding past it is not".
|
|
118
|
+
*/
|
|
119
|
+
export const EPISTEMIC_HERITAGE_RELATION_TYPES = [
|
|
120
|
+
'IMPLEMENTS',
|
|
121
|
+
'METHOD_IMPLEMENTS',
|
|
122
|
+
'EXTENDS',
|
|
123
|
+
];
|
|
124
|
+
export const EPISTEMIC_CONSUMER_RELATION_TYPES = ['CALLS', 'USES', 'ACCESSES'];
|
|
105
125
|
/**
|
|
106
126
|
* Per-relation-type confidence floor for impact analysis.
|
|
107
127
|
*
|
|
@@ -2100,6 +2120,23 @@ export class LocalBackend {
|
|
|
2100
2120
|
// Method/Function/Constructor enrichment: fetch method-specific properties
|
|
2101
2121
|
const symKind = isClassLike ? resolvedLabel || 'Class' : sym.type || sym[2];
|
|
2102
2122
|
const isMethodLike = symKind === 'Method' || symKind === 'Function' || symKind === 'Constructor';
|
|
2123
|
+
// #1858 review F2 — start the epistemic boundary probe here (right after
|
|
2124
|
+
// `symKind` is known) so it runs CONCURRENTLY with the methodMetadata fetch
|
|
2125
|
+
// below, mirroring how _runImpactBFS overlaps it with the BFS. It is awaited
|
|
2126
|
+
// at result assembly. (It cannot start earlier — `symKind` is only computed
|
|
2127
|
+
// on this line, after the incoming/outgoing round-trips.)
|
|
2128
|
+
//
|
|
2129
|
+
// #1858 review F3 — pass an interface-preserving type, NOT `symKind`.
|
|
2130
|
+
// `symKind` collapses a single-resolved Interface to 'Class' (resolvedLabel
|
|
2131
|
+
// is '' on the single-candidate path), which would skip computeEpistemicBoundary's
|
|
2132
|
+
// `symType === 'Interface'` self-boundary branch and under-report a leaf
|
|
2133
|
+
// interface as 'exact'. `enrichCandidateLabels` runs BEFORE the single-candidate
|
|
2134
|
+
// early return and patches `sym.type` from '' to 'Interface' (LadybugDB returns
|
|
2135
|
+
// '' for labels()[0] on Interface/Class), so `sym.type` is the reliable signal
|
|
2136
|
+
// here — mirroring impact()'s `resolvedLabel || symbol.type` derivation. Do not
|
|
2137
|
+
// "fix" enrichment ordering; F3 depends on enrichment-before-early-return.
|
|
2138
|
+
const epistemicSymType = (resolvedLabel || sym.type || symKind || '');
|
|
2139
|
+
const epistemicPromise = this.computeEpistemicBoundary(repo, symId, epistemicSymType, (sym.name || sym[1]));
|
|
2103
2140
|
let methodMetadata;
|
|
2104
2141
|
if (isMethodLike) {
|
|
2105
2142
|
try {
|
|
@@ -2130,6 +2167,12 @@ export class LocalBackend {
|
|
|
2130
2167
|
/* method metadata unavailable — omit silently */
|
|
2131
2168
|
}
|
|
2132
2169
|
}
|
|
2170
|
+
// #1858 — same epistemic boundary signal as impact(): when this symbol sits
|
|
2171
|
+
// behind an interface / indirection boundary, callers binding via DI or
|
|
2172
|
+
// dynamic dispatch are not reflected in `incoming`, so the view is a lower
|
|
2173
|
+
// bound. Additive; never suppresses a field. Resolved from the probe started
|
|
2174
|
+
// above (concurrent with methodMetadata).
|
|
2175
|
+
const epistemic = await epistemicPromise;
|
|
2133
2176
|
return {
|
|
2134
2177
|
status: 'found',
|
|
2135
2178
|
symbol: {
|
|
@@ -2142,6 +2185,7 @@ export class LocalBackend {
|
|
|
2142
2185
|
...(include_content && (sym.content || sym[6]) ? { content: sym.content || sym[6] } : {}),
|
|
2143
2186
|
...(methodMetadata ? { methodMetadata } : {}),
|
|
2144
2187
|
},
|
|
2188
|
+
...epistemic,
|
|
2145
2189
|
incoming: categorize(incomingRows),
|
|
2146
2190
|
outgoing: categorize(outgoingRows),
|
|
2147
2191
|
...(typedPropertyRows.length > 0
|
|
@@ -2675,21 +2719,103 @@ export class LocalBackend {
|
|
|
2675
2719
|
};
|
|
2676
2720
|
}
|
|
2677
2721
|
if (outcome.kind === 'ambiguous') {
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2722
|
+
// #2129 — a bare name that collides with several symbols must NOT report a
|
|
2723
|
+
// bare `impactedCount: 0`. The real blast radius lives under whichever
|
|
2724
|
+
// candidate the caller meant; a flat zero here is precisely the silent
|
|
2725
|
+
// under-report the "run impact before editing" workflow exists to prevent
|
|
2726
|
+
// (the dropped caller calls a *different* same-name node, so it never shows
|
|
2727
|
+
// up against the one the resolver happened to pick). Run a bounded,
|
|
2728
|
+
// summary-only BFS per candidate so each one's true count + risk is
|
|
2729
|
+
// visible, and surface the maximum at the top level so the headline can
|
|
2730
|
+
// never read as "safe to refactor". Candidates arrive sorted by score.
|
|
2731
|
+
const AMBIGUOUS_MAX_CANDIDATES = 6;
|
|
2732
|
+
const probed = outcome.candidates.slice(0, AMBIGUOUS_MAX_CANDIDATES);
|
|
2733
|
+
// `partialProbe` is intentionally a SECOND incompleteness flag, distinct
|
|
2734
|
+
// from the traversal-interrupted `partial` flag used elsewhere: it means
|
|
2735
|
+
// one or more per-candidate probes threw, so maxRisk / maxImpactedCount
|
|
2736
|
+
// are lower bounds over the probes that succeeded (a failed candidate must
|
|
2737
|
+
// not be masked by a benign sibling success).
|
|
2738
|
+
let probeFailed = false;
|
|
2739
|
+
const candidateSummaries = await Promise.all(probed.map(async (c) => {
|
|
2740
|
+
const cType = c.type || '';
|
|
2741
|
+
const cRelTypes = (cType === 'Class' || cType === 'Interface') &&
|
|
2742
|
+
!hasExplicitRelationTypes &&
|
|
2743
|
+
!relationTypes.includes('ACCESSES')
|
|
2744
|
+
? [...relationTypes, 'ACCESSES']
|
|
2745
|
+
: relationTypes;
|
|
2746
|
+
// #1858/#2129 review F8 — name the shape the probe summary is read
|
|
2747
|
+
// through (`_runImpactBFS` returns `Promise<any>`, so this is the
|
|
2748
|
+
// narrowing cast) so a future rename of those fields fails tsc instead
|
|
2749
|
+
// of silently zeroing candidate counts.
|
|
2750
|
+
let summary = null;
|
|
2751
|
+
try {
|
|
2752
|
+
summary = await this._runImpactBFS(repo, { id: c.id, name: c.name, filePath: c.filePath }, cType, direction, {
|
|
2753
|
+
maxDepth,
|
|
2754
|
+
relationTypes: cRelTypes,
|
|
2755
|
+
includeTests,
|
|
2756
|
+
minConfidence,
|
|
2757
|
+
summaryOnly: true,
|
|
2758
|
+
skipEpistemic: true,
|
|
2759
|
+
skipEnrichment: true,
|
|
2760
|
+
});
|
|
2761
|
+
}
|
|
2762
|
+
catch (e) {
|
|
2763
|
+
probeFailed = true;
|
|
2764
|
+
logQueryError('impact:ambiguous-candidate', e);
|
|
2765
|
+
}
|
|
2766
|
+
return {
|
|
2686
2767
|
uid: c.id,
|
|
2687
2768
|
name: c.name,
|
|
2688
2769
|
kind: c.type,
|
|
2689
2770
|
filePath: c.filePath,
|
|
2690
2771
|
line: c.startLine,
|
|
2691
2772
|
score: Number(c.score.toFixed(2)),
|
|
2692
|
-
|
|
2773
|
+
impactedCount: summary?.impactedCount ?? 0,
|
|
2774
|
+
risk: summary?.risk ?? 'UNKNOWN',
|
|
2775
|
+
direct: summary?.summary?.direct ?? 0,
|
|
2776
|
+
};
|
|
2777
|
+
}));
|
|
2778
|
+
// Rank by blast radius so the most-impactful interpretation is first, and
|
|
2779
|
+
// hoist the maximum count/risk to the top level so the response cannot be
|
|
2780
|
+
// misread as "no impact".
|
|
2781
|
+
candidateSummaries.sort((a, b) => b.impactedCount - a.impactedCount);
|
|
2782
|
+
const maxImpactedCount = candidateSummaries.reduce((m, c) => Math.max(m, c.impactedCount), 0);
|
|
2783
|
+
const RISK_ORDER = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'];
|
|
2784
|
+
// If EVERY candidate probe failed (all 'UNKNOWN' — e.g. pool exhaustion
|
|
2785
|
+
// under the fan-out), the worst real risk is genuinely unknown, not LOW.
|
|
2786
|
+
// Reporting LOW here would re-introduce the false-safe signal. Only fall to
|
|
2787
|
+
// the LOW seed when at least one candidate produced a real risk.
|
|
2788
|
+
const anyKnownRisk = candidateSummaries.some((c) => RISK_ORDER.includes(c.risk));
|
|
2789
|
+
const maxRisk = anyKnownRisk
|
|
2790
|
+
? candidateSummaries.reduce((worst, c) => (RISK_ORDER.indexOf(c.risk) > RISK_ORDER.indexOf(worst) ? c.risk : worst), 'LOW')
|
|
2791
|
+
: 'UNKNOWN';
|
|
2792
|
+
const truncated = outcome.candidates.length > probed.length;
|
|
2793
|
+
return {
|
|
2794
|
+
status: 'ambiguous',
|
|
2795
|
+
message: `Found ${outcome.candidates.length} symbols matching '${target}'` +
|
|
2796
|
+
(truncated
|
|
2797
|
+
? ` (showing ${candidateSummaries.length} of ${outcome.candidates.length})`
|
|
2798
|
+
: '') +
|
|
2799
|
+
`. Blast radius differs per candidate (max ${maxImpactedCount} impacted at risk ${maxRisk}). ` +
|
|
2800
|
+
`Disambiguate with target_uid (or file_path/kind) for a single authoritative result.`,
|
|
2801
|
+
target: { name: target },
|
|
2802
|
+
direction,
|
|
2803
|
+
// Full match count — `candidates[]` is truncated to AMBIGUOUS_MAX_CANDIDATES,
|
|
2804
|
+
// so consumers (CLI formatter) need this to report "N of M" honestly (#2129
|
|
2805
|
+
// review F11; the CLI previously read the truncated array length).
|
|
2806
|
+
totalCandidates: outcome.candidates.length,
|
|
2807
|
+
// `impactedCount` stays 0 and `risk` stays UNKNOWN — there is no single
|
|
2808
|
+
// resolved symbol, and UNKNOWN must NOT read as "safe to refactor". The
|
|
2809
|
+
// real blast radius is surfaced per-candidate plus `maxImpactedCount` /
|
|
2810
|
+
// `maxRisk` so a real caller can never hide behind the ambiguous zero
|
|
2811
|
+
// (#2129).
|
|
2812
|
+
impactedCount: 0,
|
|
2813
|
+
risk: 'UNKNOWN',
|
|
2814
|
+
maxImpactedCount,
|
|
2815
|
+
maxRisk,
|
|
2816
|
+
...(probeFailed ? { partialProbe: true } : {}),
|
|
2817
|
+
...(truncated && { candidatesTruncated: true }),
|
|
2818
|
+
candidates: candidateSummaries,
|
|
2693
2819
|
};
|
|
2694
2820
|
}
|
|
2695
2821
|
const sym = {
|
|
@@ -2713,12 +2839,109 @@ export class LocalBackend {
|
|
|
2713
2839
|
summaryOnly: params.summaryOnly,
|
|
2714
2840
|
});
|
|
2715
2841
|
}
|
|
2842
|
+
/**
|
|
2843
|
+
* #1858 — epistemic lower-bound detection.
|
|
2844
|
+
*
|
|
2845
|
+
* impact()/context() traverse only edges materialized in the graph. When the
|
|
2846
|
+
* queried symbol sits on an interface / abstract boundary, callers that bind
|
|
2847
|
+
* to the interface via DI, a container, or dynamic dispatch — rather than
|
|
2848
|
+
* naming the concrete symbol — are not traced. The reported count is then a
|
|
2849
|
+
* lower bound, not an exact figure. Instead of returning a confident count
|
|
2850
|
+
* that silently omits those callers, annotate the result with
|
|
2851
|
+
* `epistemic: 'lower-bound'` plus a human-readable boundary note. A fully
|
|
2852
|
+
* resolved leaf with no indirection stays `epistemic: 'exact'`.
|
|
2853
|
+
*
|
|
2854
|
+
* Aligns with the numeric confidence model rather than the long-deleted
|
|
2855
|
+
* TIER_CONFIDENCE enum: the heritage/indirection edges this keys on
|
|
2856
|
+
* (IMPLEMENTS / METHOD_IMPLEMENTS / EXTENDS) carry the 0.85
|
|
2857
|
+
* `IMPACT_RELATION_CONFIDENCE` floor — "statically verifiable, but the
|
|
2858
|
+
* concrete binding past it is not".
|
|
2859
|
+
*
|
|
2860
|
+
* Never throws: on query error it returns 'exact', so it can only add signal,
|
|
2861
|
+
* never suppress a result.
|
|
2862
|
+
*/
|
|
2863
|
+
async computeEpistemicBoundary(repo, symId, symType, symName) {
|
|
2864
|
+
const HERITAGE_TYPES = EPISTEMIC_HERITAGE_RELATION_TYPES;
|
|
2865
|
+
const CONSUMER_TYPES = EPISTEMIC_CONSUMER_RELATION_TYPES;
|
|
2866
|
+
try {
|
|
2867
|
+
// Discover the interface / abstract supertypes on the target's boundary.
|
|
2868
|
+
// If the target is itself an interface, it is its own boundary node.
|
|
2869
|
+
const boundary = new Map();
|
|
2870
|
+
if (symType === 'Interface') {
|
|
2871
|
+
boundary.set(symId, { name: symName || '', label: 'Interface' });
|
|
2872
|
+
}
|
|
2873
|
+
const ifaceRows = await executeParameterized(repo.lbugPath, `MATCH (x)-[r:CodeRelation]->(iface)
|
|
2874
|
+
WHERE x.id = $symId AND r.type IN $heritage
|
|
2875
|
+
RETURN DISTINCT iface.id AS id, iface.name AS name, labels(iface)[0] AS label
|
|
2876
|
+
LIMIT 25`, { symId, heritage: HERITAGE_TYPES }).catch(() => []);
|
|
2877
|
+
for (const r of ifaceRows) {
|
|
2878
|
+
const id = (r.id ?? r[0]);
|
|
2879
|
+
if (id && !boundary.has(id)) {
|
|
2880
|
+
boundary.set(id, {
|
|
2881
|
+
name: (r.name ?? r[1] ?? ''),
|
|
2882
|
+
label: (r.label ?? r[2] ?? 'Interface'),
|
|
2883
|
+
});
|
|
2884
|
+
}
|
|
2885
|
+
}
|
|
2886
|
+
if (boundary.size === 0)
|
|
2887
|
+
return { epistemic: 'exact' };
|
|
2888
|
+
const ifaceIds = Array.from(boundary.keys());
|
|
2889
|
+
// Count per interface id with scalar equality. A parameterized
|
|
2890
|
+
// `iface.id IN $ids` combined with `COUNT(DISTINCT ...)` + implicit
|
|
2891
|
+
// group-by returns no rows under the LadybugDB cypher subset, so query
|
|
2892
|
+
// each boundary node individually (boundary is small — capped at 25).
|
|
2893
|
+
const countByType = async (types) => {
|
|
2894
|
+
const m = new Map();
|
|
2895
|
+
await Promise.all(ifaceIds.map(async (ifaceId) => {
|
|
2896
|
+
const rows = await executeParameterized(repo.lbugPath, `MATCH (other)-[r:CodeRelation]->(iface)
|
|
2897
|
+
WHERE iface.id = $ifaceId AND r.type IN $types
|
|
2898
|
+
RETURN COUNT(DISTINCT other.id) AS cnt`, { ifaceId, types }).catch(() => []);
|
|
2899
|
+
const cnt = rows.length > 0 ? Number(rows[0].cnt ?? rows[0][0] ?? 0) : 0;
|
|
2900
|
+
m.set(ifaceId, cnt);
|
|
2901
|
+
}));
|
|
2902
|
+
return m;
|
|
2903
|
+
};
|
|
2904
|
+
const [implCounts, consumerCounts] = await Promise.all([
|
|
2905
|
+
countByType(HERITAGE_TYPES),
|
|
2906
|
+
countByType(CONSUMER_TYPES),
|
|
2907
|
+
]);
|
|
2908
|
+
const boundaries = [];
|
|
2909
|
+
for (const [id, info] of boundary) {
|
|
2910
|
+
const impls = implCounts.get(id) ?? 0;
|
|
2911
|
+
const consumers = consumerCounts.get(id) ?? 0;
|
|
2912
|
+
// Flag only a genuine indirection risk: an interface that is actually
|
|
2913
|
+
// consumed (callers bind to it) or that has multiple implementations
|
|
2914
|
+
// (runtime dispatch is ambiguous). A concrete type implementing an
|
|
2915
|
+
// interface nothing references is fully traced → stays exact.
|
|
2916
|
+
if (consumers >= 1 || impls >= 2) {
|
|
2917
|
+
const label = (info.label || 'Interface').toLowerCase();
|
|
2918
|
+
const name = info.name || '(unnamed)';
|
|
2919
|
+
const article = /^[aeiou]/.test(label) ? 'an' : 'a';
|
|
2920
|
+
const parts = [];
|
|
2921
|
+
if (impls >= 1)
|
|
2922
|
+
parts.push(`${impls} ${impls === 1 ? 'implementation' : 'implementations'}`);
|
|
2923
|
+
if (consumers >= 1)
|
|
2924
|
+
parts.push(`${consumers} interface-level ${consumers === 1 ? 'consumer' : 'consumers'}`);
|
|
2925
|
+
boundaries.push(`${name} is ${article} ${label} with ${parts.join(' and ')}; callers that bind via the ${label} ` +
|
|
2926
|
+
`(e.g. a DI container or dynamic dispatch) are not traced to the concrete symbol — ` +
|
|
2927
|
+
`actual impact may be higher.`);
|
|
2928
|
+
}
|
|
2929
|
+
}
|
|
2930
|
+
if (boundaries.length === 0)
|
|
2931
|
+
return { epistemic: 'exact' };
|
|
2932
|
+
return { epistemic: 'lower-bound', boundaries };
|
|
2933
|
+
}
|
|
2934
|
+
catch {
|
|
2935
|
+
return { epistemic: 'exact' };
|
|
2936
|
+
}
|
|
2937
|
+
}
|
|
2716
2938
|
/**
|
|
2717
2939
|
* Shared BFS traversal for impact analysis (name-resolved or UID-resolved symbol).
|
|
2718
2940
|
*/
|
|
2719
2941
|
async _runImpactBFS(repo, sym, symType, direction, opts) {
|
|
2720
2942
|
const { maxDepth, relationTypes, includeTests, minConfidence } = opts;
|
|
2721
2943
|
const skipPerSymbolEnrichment = opts.skipPerSymbolEnrichment ?? false;
|
|
2944
|
+
const skipEnrichment = opts.skipEnrichment ?? false;
|
|
2722
2945
|
const hasExplicitLimit = typeof opts.limit === 'number' && Number.isFinite(opts.limit);
|
|
2723
2946
|
const paginationLimit = hasExplicitLimit
|
|
2724
2947
|
? Math.max(1, Math.min(Math.trunc(opts.limit), 10000))
|
|
@@ -2735,6 +2958,18 @@ export class LocalBackend {
|
|
|
2735
2958
|
const safeMinConfidence = Number.isFinite(minConfidence) ? minConfidence : 0;
|
|
2736
2959
|
const confidenceFilter = safeMinConfidence > 0 ? ' AND r.confidence >= $minConfidence' : '';
|
|
2737
2960
|
const symId = sym.id || sym[0];
|
|
2961
|
+
// #1858 — kick off the epistemic boundary probe concurrently with the BFS.
|
|
2962
|
+
// It depends only on symId/symType/symName (all known now) and touches no
|
|
2963
|
+
// shared state, so its extra round-trip overlaps the traversal instead of
|
|
2964
|
+
// adding to the serial path. `skipEpistemic` (ambiguous #2129 candidate
|
|
2965
|
+
// probes, group fan-out) resolves to no field, preserving prior behavior.
|
|
2966
|
+
// #1858/#2129 review F8 — the skip case adds no field, so `epistemic` is
|
|
2967
|
+
// optional here (the union's `{}` subtype). computeEpistemicBoundary's own
|
|
2968
|
+
// return keeps `epistemic` REQUIRED — only this promise widens to the skip
|
|
2969
|
+
// subtype.
|
|
2970
|
+
const epistemicPromise = opts.skipEpistemic
|
|
2971
|
+
? Promise.resolve({})
|
|
2972
|
+
: this.computeEpistemicBoundary(repo, symId, symType, (sym.name || sym[1]));
|
|
2738
2973
|
const impacted = [];
|
|
2739
2974
|
const visited = new Set([symId]);
|
|
2740
2975
|
let frontier = [symId];
|
|
@@ -2872,7 +3107,13 @@ export class LocalBackend {
|
|
|
2872
3107
|
// Max number of chunks to process to avoid unbounded DB round-trips.
|
|
2873
3108
|
// Configurable via env IMPACT_MAX_CHUNKS, default 10 => max items = 1000
|
|
2874
3109
|
const MAX_CHUNKS = parseInt(process.env.IMPACT_MAX_CHUNKS || '10', 10);
|
|
2875
|
-
|
|
3110
|
+
// `skipEnrichment` (ambiguous #2129 per-candidate probes) bypasses the
|
|
3111
|
+
// process/module aggregation passes entirely — those probes need only the
|
|
3112
|
+
// count + a count-based risk, so paying the bounded-but-real enrichment cost
|
|
3113
|
+
// ~6× per ambiguous call is wasted. risk then derives from directCount /
|
|
3114
|
+
// total only (processCount/moduleCount stay 0), an acceptable approximation
|
|
3115
|
+
// for a disambiguation aid.
|
|
3116
|
+
if (impacted.length > 0 && !skipEnrichment) {
|
|
2876
3117
|
// ── Process enrichment: batched chunking (bounded by MAX_CHUNKS) ─
|
|
2877
3118
|
// Uses merged Cypher query (WITH + OPTIONAL MATCH) to fetch
|
|
2878
3119
|
// process + entry point info in 1 round-trip per chunk. Converted to
|
|
@@ -3093,6 +3334,9 @@ export class LocalBackend {
|
|
|
3093
3334
|
for (const [depth, items] of Object.entries(grouped)) {
|
|
3094
3335
|
byDepthCounts[Number(depth)] = items.length;
|
|
3095
3336
|
}
|
|
3337
|
+
// #1858 — await the epistemic boundary probe kicked off alongside the BFS
|
|
3338
|
+
// above. Additive: leaves impactedCount and every existing field untouched.
|
|
3339
|
+
const epistemic = await epistemicPromise;
|
|
3096
3340
|
const base = {
|
|
3097
3341
|
target: {
|
|
3098
3342
|
id: symId,
|
|
@@ -3103,6 +3347,7 @@ export class LocalBackend {
|
|
|
3103
3347
|
direction,
|
|
3104
3348
|
impactedCount: impacted.length,
|
|
3105
3349
|
risk,
|
|
3350
|
+
...epistemic,
|
|
3106
3351
|
...(!traversalComplete && { partial: true }),
|
|
3107
3352
|
summary: {
|
|
3108
3353
|
direct: directCount,
|
|
@@ -3292,6 +3537,10 @@ export class LocalBackend {
|
|
|
3292
3537
|
includeTests: opts.includeTests,
|
|
3293
3538
|
minConfidence: opts.minConfidence,
|
|
3294
3539
|
skipPerSymbolEnrichment: true,
|
|
3540
|
+
// Group cross-repo fan-out consumes only byDepth (cross-impact.ts), not
|
|
3541
|
+
// the #1858 epistemic/boundaries fields — computing them per neighbor is
|
|
3542
|
+
// dead work on the highest-volume path, so suppress them here too.
|
|
3543
|
+
skipEpistemic: true,
|
|
3295
3544
|
});
|
|
3296
3545
|
}
|
|
3297
3546
|
catch {
|
package/package.json
CHANGED