gitnexus 1.4.8 → 1.4.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/dist/cli/index-repo.d.ts +15 -0
- package/dist/cli/index-repo.js +115 -0
- package/dist/cli/index.js +11 -2
- package/dist/cli/setup.js +12 -9
- package/dist/cli/wiki.d.ts +4 -0
- package/dist/cli/wiki.js +174 -53
- package/dist/config/supported-languages.d.ts +7 -5
- package/dist/config/supported-languages.js +6 -4
- package/dist/core/graph/graph.js +9 -1
- package/dist/core/graph/types.d.ts +10 -2
- package/dist/core/ingestion/call-processor.d.ts +18 -1
- package/dist/core/ingestion/call-processor.js +297 -38
- package/dist/core/ingestion/call-routing.d.ts +3 -18
- package/dist/core/ingestion/call-routing.js +0 -19
- package/dist/core/ingestion/cobol/cobol-copy-expander.d.ts +57 -0
- package/dist/core/ingestion/cobol/cobol-copy-expander.js +385 -0
- package/dist/core/ingestion/cobol/cobol-preprocessor.d.ts +210 -0
- package/dist/core/ingestion/cobol/cobol-preprocessor.js +1509 -0
- package/dist/core/ingestion/cobol/jcl-parser.d.ts +68 -0
- package/dist/core/ingestion/cobol/jcl-parser.js +217 -0
- package/dist/core/ingestion/cobol/jcl-processor.d.ts +33 -0
- package/dist/core/ingestion/cobol/jcl-processor.js +229 -0
- package/dist/core/ingestion/cobol-processor.d.ts +54 -0
- package/dist/core/ingestion/cobol-processor.js +1186 -0
- package/dist/core/ingestion/entry-point-scoring.d.ts +17 -0
- package/dist/core/ingestion/entry-point-scoring.js +18 -4
- package/dist/core/ingestion/export-detection.d.ts +47 -8
- package/dist/core/ingestion/export-detection.js +29 -50
- package/dist/core/ingestion/field-extractor.d.ts +29 -0
- package/dist/core/ingestion/field-extractor.js +25 -0
- package/dist/core/ingestion/field-extractors/configs/c-cpp.d.ts +3 -0
- package/dist/core/ingestion/field-extractors/configs/c-cpp.js +108 -0
- package/dist/core/ingestion/field-extractors/configs/csharp.d.ts +8 -0
- package/dist/core/ingestion/field-extractors/configs/csharp.js +73 -0
- package/dist/core/ingestion/field-extractors/configs/dart.d.ts +8 -0
- package/dist/core/ingestion/field-extractors/configs/dart.js +76 -0
- package/dist/core/ingestion/field-extractors/configs/go.d.ts +11 -0
- package/dist/core/ingestion/field-extractors/configs/go.js +64 -0
- package/dist/core/ingestion/field-extractors/configs/helpers.d.ts +44 -0
- package/dist/core/ingestion/field-extractors/configs/helpers.js +134 -0
- package/dist/core/ingestion/field-extractors/configs/jvm.d.ts +3 -0
- package/dist/core/ingestion/field-extractors/configs/jvm.js +118 -0
- package/dist/core/ingestion/field-extractors/configs/php.d.ts +8 -0
- package/dist/core/ingestion/field-extractors/configs/php.js +67 -0
- package/dist/core/ingestion/field-extractors/configs/python.d.ts +12 -0
- package/dist/core/ingestion/field-extractors/configs/python.js +91 -0
- package/dist/core/ingestion/field-extractors/configs/ruby.d.ts +16 -0
- package/dist/core/ingestion/field-extractors/configs/ruby.js +75 -0
- package/dist/core/ingestion/field-extractors/configs/rust.d.ts +9 -0
- package/dist/core/ingestion/field-extractors/configs/rust.js +55 -0
- package/dist/core/ingestion/field-extractors/configs/swift.d.ts +8 -0
- package/dist/core/ingestion/field-extractors/configs/swift.js +63 -0
- package/dist/core/ingestion/field-extractors/configs/typescript-javascript.d.ts +3 -0
- package/dist/core/ingestion/field-extractors/configs/typescript-javascript.js +60 -0
- package/dist/core/ingestion/field-extractors/generic.d.ts +46 -0
- package/dist/core/ingestion/field-extractors/generic.js +111 -0
- package/dist/core/ingestion/field-extractors/typescript.d.ts +77 -0
- package/dist/core/ingestion/field-extractors/typescript.js +291 -0
- package/dist/core/ingestion/field-types.d.ts +59 -0
- package/dist/core/ingestion/field-types.js +2 -0
- package/dist/core/ingestion/framework-detection.d.ts +87 -0
- package/dist/core/ingestion/framework-detection.js +65 -2
- package/dist/core/ingestion/heritage-processor.js +15 -17
- package/dist/core/ingestion/import-processor.d.ts +9 -10
- package/dist/core/ingestion/import-processor.js +59 -14
- package/dist/core/ingestion/{resolvers → import-resolvers}/csharp.d.ts +6 -9
- package/dist/core/ingestion/{resolvers → import-resolvers}/csharp.js +20 -2
- package/dist/core/ingestion/import-resolvers/dart.d.ts +7 -0
- package/dist/core/ingestion/import-resolvers/dart.js +44 -0
- package/dist/core/ingestion/{resolvers → import-resolvers}/go.d.ts +4 -5
- package/dist/core/ingestion/{resolvers → import-resolvers}/go.js +17 -0
- package/dist/core/ingestion/{resolvers → import-resolvers}/jvm.d.ts +9 -1
- package/dist/core/ingestion/{resolvers → import-resolvers}/jvm.js +56 -0
- package/dist/core/ingestion/{resolvers → import-resolvers}/php.d.ts +6 -10
- package/dist/core/ingestion/{resolvers → import-resolvers}/php.js +7 -2
- package/dist/core/ingestion/{resolvers → import-resolvers}/python.d.ts +9 -3
- package/dist/core/ingestion/{resolvers → import-resolvers}/python.js +35 -3
- package/dist/core/ingestion/{resolvers → import-resolvers}/ruby.d.ts +5 -2
- package/dist/core/ingestion/{resolvers → import-resolvers}/ruby.js +7 -2
- package/dist/core/ingestion/{resolvers → import-resolvers}/rust.d.ts +5 -2
- package/dist/core/ingestion/{resolvers → import-resolvers}/rust.js +41 -2
- package/dist/core/ingestion/{resolvers → import-resolvers}/standard.d.ts +15 -7
- package/dist/core/ingestion/{resolvers → import-resolvers}/standard.js +22 -3
- package/dist/core/ingestion/import-resolvers/swift.d.ts +7 -0
- package/dist/core/ingestion/import-resolvers/swift.js +23 -0
- package/dist/core/ingestion/import-resolvers/types.d.ts +44 -0
- package/dist/core/ingestion/import-resolvers/types.js +6 -0
- package/dist/core/ingestion/{resolvers → import-resolvers}/utils.d.ts +0 -3
- package/dist/core/ingestion/{resolvers → import-resolvers}/utils.js +0 -9
- package/dist/core/ingestion/language-config.d.ts +4 -1
- package/dist/core/ingestion/language-provider.d.ts +121 -0
- package/dist/core/ingestion/language-provider.js +24 -0
- package/dist/core/ingestion/languages/c-cpp.d.ts +12 -0
- package/dist/core/ingestion/languages/c-cpp.js +71 -0
- package/dist/core/ingestion/languages/cobol.d.ts +1 -0
- package/dist/core/ingestion/languages/cobol.js +26 -0
- package/dist/core/ingestion/languages/csharp.d.ts +8 -0
- package/dist/core/ingestion/languages/csharp.js +49 -0
- package/dist/core/ingestion/languages/dart.d.ts +12 -0
- package/dist/core/ingestion/languages/dart.js +58 -0
- package/dist/core/ingestion/languages/go.d.ts +11 -0
- package/dist/core/ingestion/languages/go.js +28 -0
- package/dist/core/ingestion/languages/index.d.ts +38 -0
- package/dist/core/ingestion/languages/index.js +63 -0
- package/dist/core/ingestion/languages/java.d.ts +9 -0
- package/dist/core/ingestion/languages/java.js +29 -0
- package/dist/core/ingestion/languages/kotlin.d.ts +9 -0
- package/dist/core/ingestion/languages/kotlin.js +53 -0
- package/dist/core/ingestion/languages/php.d.ts +8 -0
- package/dist/core/ingestion/languages/php.js +145 -0
- package/dist/core/ingestion/languages/python.d.ts +12 -0
- package/dist/core/ingestion/languages/python.js +39 -0
- package/dist/core/ingestion/languages/ruby.d.ts +9 -0
- package/dist/core/ingestion/languages/ruby.js +44 -0
- package/dist/core/ingestion/languages/rust.d.ts +12 -0
- package/dist/core/ingestion/languages/rust.js +44 -0
- package/dist/core/ingestion/languages/swift.d.ts +12 -0
- package/dist/core/ingestion/languages/swift.js +133 -0
- package/dist/core/ingestion/languages/typescript.d.ts +10 -0
- package/dist/core/ingestion/languages/typescript.js +60 -0
- package/dist/core/ingestion/mro-processor.js +14 -15
- package/dist/core/ingestion/{named-binding-extraction.d.ts → named-binding-processor.d.ts} +0 -9
- package/dist/core/ingestion/named-binding-processor.js +42 -0
- package/dist/core/ingestion/named-bindings/csharp.d.ts +3 -0
- package/dist/core/ingestion/named-bindings/csharp.js +37 -0
- package/dist/core/ingestion/named-bindings/java.d.ts +3 -0
- package/dist/core/ingestion/named-bindings/java.js +29 -0
- package/dist/core/ingestion/named-bindings/kotlin.d.ts +3 -0
- package/dist/core/ingestion/named-bindings/kotlin.js +36 -0
- package/dist/core/ingestion/named-bindings/php.d.ts +3 -0
- package/dist/core/ingestion/named-bindings/php.js +61 -0
- package/dist/core/ingestion/named-bindings/python.d.ts +3 -0
- package/dist/core/ingestion/named-bindings/python.js +49 -0
- package/dist/core/ingestion/named-bindings/rust.d.ts +3 -0
- package/dist/core/ingestion/named-bindings/rust.js +64 -0
- package/dist/core/ingestion/named-bindings/types.d.ts +16 -0
- package/dist/core/ingestion/named-bindings/types.js +6 -0
- package/dist/core/ingestion/named-bindings/typescript.d.ts +3 -0
- package/dist/core/ingestion/named-bindings/typescript.js +58 -0
- package/dist/core/ingestion/parsing-processor.d.ts +5 -1
- package/dist/core/ingestion/parsing-processor.js +115 -16
- package/dist/core/ingestion/pipeline.js +925 -424
- package/dist/core/ingestion/resolution-context.js +1 -1
- package/dist/core/ingestion/route-extractors/expo.d.ts +1 -0
- package/dist/core/ingestion/route-extractors/expo.js +36 -0
- package/dist/core/ingestion/route-extractors/middleware.d.ts +47 -0
- package/dist/core/ingestion/route-extractors/middleware.js +143 -0
- package/dist/core/ingestion/route-extractors/nextjs.d.ts +3 -0
- package/dist/core/ingestion/route-extractors/nextjs.js +76 -0
- package/dist/core/ingestion/route-extractors/php.d.ts +7 -0
- package/dist/core/ingestion/route-extractors/php.js +21 -0
- package/dist/core/ingestion/route-extractors/response-shapes.d.ts +20 -0
- package/dist/core/ingestion/route-extractors/response-shapes.js +290 -0
- package/dist/core/ingestion/tree-sitter-queries.d.ts +8 -7
- package/dist/core/ingestion/tree-sitter-queries.js +231 -9
- package/dist/core/ingestion/type-env.d.ts +14 -17
- package/dist/core/ingestion/type-env.js +66 -14
- package/dist/core/ingestion/type-extractors/c-cpp.d.ts +1 -1
- package/dist/core/ingestion/type-extractors/csharp.js +1 -1
- package/dist/core/ingestion/type-extractors/dart.d.ts +15 -0
- package/dist/core/ingestion/type-extractors/dart.js +371 -0
- package/dist/core/ingestion/type-extractors/jvm.js +1 -1
- package/dist/core/ingestion/type-extractors/shared.d.ts +1 -13
- package/dist/core/ingestion/type-extractors/shared.js +9 -102
- package/dist/core/ingestion/type-extractors/swift.js +334 -4
- package/dist/core/ingestion/type-extractors/types.d.ts +3 -1
- package/dist/core/ingestion/{ast-helpers.d.ts → utils/ast-helpers.d.ts} +16 -13
- package/dist/core/ingestion/{ast-helpers.js → utils/ast-helpers.js} +111 -32
- package/dist/core/ingestion/{call-analysis.js → utils/call-analysis.js} +37 -0
- package/dist/core/ingestion/utils/event-loop.d.ts +5 -0
- package/dist/core/ingestion/utils/event-loop.js +5 -0
- package/dist/core/ingestion/utils/language-detection.d.ts +9 -0
- package/dist/core/ingestion/utils/language-detection.js +70 -0
- package/dist/core/ingestion/utils/verbose.d.ts +1 -0
- package/dist/core/ingestion/utils/verbose.js +7 -0
- package/dist/core/ingestion/workers/parse-worker.d.ts +43 -2
- package/dist/core/ingestion/workers/parse-worker.js +361 -150
- package/dist/core/lbug/csv-generator.js +34 -1
- package/dist/core/lbug/lbug-adapter.js +6 -0
- package/dist/core/lbug/schema.d.ts +5 -3
- package/dist/core/lbug/schema.js +39 -2
- package/dist/core/tree-sitter/parser-loader.js +7 -1
- package/dist/core/wiki/cursor-client.d.ts +31 -0
- package/dist/core/wiki/cursor-client.js +127 -0
- package/dist/core/wiki/generator.d.ts +28 -9
- package/dist/core/wiki/generator.js +115 -18
- package/dist/core/wiki/graph-queries.d.ts +4 -0
- package/dist/core/wiki/graph-queries.js +7 -1
- package/dist/core/wiki/llm-client.d.ts +2 -0
- package/dist/core/wiki/llm-client.js +8 -4
- package/dist/core/wiki/prompts.d.ts +3 -3
- package/dist/core/wiki/prompts.js +6 -0
- package/dist/mcp/core/lbug-adapter.d.ts +5 -0
- package/dist/mcp/core/lbug-adapter.js +11 -1
- package/dist/mcp/local/local-backend.d.ts +16 -5
- package/dist/mcp/local/local-backend.js +711 -74
- package/dist/mcp/tools.js +71 -2
- package/dist/storage/repo-manager.d.ts +3 -0
- package/package.json +14 -14
- package/dist/core/ingestion/import-resolution.d.ts +0 -101
- package/dist/core/ingestion/import-resolution.js +0 -251
- package/dist/core/ingestion/named-binding-extraction.js +0 -373
- package/dist/core/ingestion/resolvers/index.d.ts +0 -18
- package/dist/core/ingestion/resolvers/index.js +0 -13
- package/dist/core/ingestion/type-extractors/index.d.ts +0 -22
- package/dist/core/ingestion/type-extractors/index.js +0 -31
- package/dist/core/ingestion/utils.d.ts +0 -20
- package/dist/core/ingestion/utils.js +0 -242
- package/scripts/patch-tree-sitter-swift.cjs +0 -74
- /package/dist/core/ingestion/{call-analysis.d.ts → utils/call-analysis.d.ts} +0 -0
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import fs from 'fs/promises';
|
|
9
9
|
import path from 'path';
|
|
10
|
-
import { initLbug, executeQuery, executeParameterized, closeLbug, isLbugReady } from '../core/lbug-adapter.js';
|
|
10
|
+
import { initLbug, executeQuery, executeParameterized, closeLbug, isLbugReady, isWriteQuery } from '../core/lbug-adapter.js';
|
|
11
|
+
export { isWriteQuery };
|
|
11
12
|
// Embedding imports are lazy (dynamic import) to avoid loading onnxruntime-node
|
|
12
13
|
// at MCP server startup — crashes on unsupported Node ABI versions (#89)
|
|
13
14
|
// git utilities available if needed
|
|
@@ -35,9 +36,11 @@ export const VALID_NODE_LABELS = new Set([
|
|
|
35
36
|
'Community', 'Process', 'Struct', 'Enum', 'Macro', 'Typedef', 'Union',
|
|
36
37
|
'Namespace', 'Trait', 'Impl', 'TypeAlias', 'Const', 'Static', 'Property',
|
|
37
38
|
'Record', 'Delegate', 'Annotation', 'Constructor', 'Template', 'Module',
|
|
39
|
+
'Route',
|
|
40
|
+
'Tool',
|
|
38
41
|
]);
|
|
39
42
|
/** Valid relation types for impact analysis filtering */
|
|
40
|
-
export const VALID_RELATION_TYPES = new Set(['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'HAS_PROPERTY', 'OVERRIDES', 'ACCESSES']);
|
|
43
|
+
export const VALID_RELATION_TYPES = new Set(['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'HAS_PROPERTY', 'OVERRIDES', 'ACCESSES', 'HANDLES_ROUTE', 'FETCHES', 'HANDLES_TOOL', 'ENTRY_POINT_OF', 'WRAPS']);
|
|
41
44
|
/**
|
|
42
45
|
* Per-relation-type confidence floor for impact analysis.
|
|
43
46
|
*
|
|
@@ -73,12 +76,6 @@ export const IMPACT_RELATION_CONFIDENCE = {
|
|
|
73
76
|
* Falls back to 0.5 for unknown types so they are not silently elevated.
|
|
74
77
|
*/
|
|
75
78
|
const confidenceForRelType = (relType) => IMPACT_RELATION_CONFIDENCE[relType ?? ''] ?? 0.5;
|
|
76
|
-
/** Regex to detect write operations in user-supplied Cypher queries */
|
|
77
|
-
export const CYPHER_WRITE_RE = /\b(CREATE|DELETE|SET|MERGE|REMOVE|DROP|ALTER|COPY|DETACH)\b/i;
|
|
78
|
-
/** Check if a Cypher query contains write operations */
|
|
79
|
-
export function isWriteQuery(query) {
|
|
80
|
-
return CYPHER_WRITE_RE.test(query);
|
|
81
|
-
}
|
|
82
79
|
/** Structured error logging for query failures — replaces empty catch blocks */
|
|
83
80
|
function logQueryError(context, err) {
|
|
84
81
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -346,6 +343,14 @@ export class LocalBackend {
|
|
|
346
343
|
return this.context(repo, { name: params?.name, ...params });
|
|
347
344
|
case 'overview':
|
|
348
345
|
return this.overview(repo, params);
|
|
346
|
+
case 'route_map':
|
|
347
|
+
return this.routeMap(repo, params);
|
|
348
|
+
case 'shape_check':
|
|
349
|
+
return this.shapeCheck(repo, params);
|
|
350
|
+
case 'tool_map':
|
|
351
|
+
return this.toolMap(repo, params);
|
|
352
|
+
case 'api_impact':
|
|
353
|
+
return this.apiImpact(repo, params);
|
|
349
354
|
default:
|
|
350
355
|
throw new Error(`Unknown tool: ${method}`);
|
|
351
356
|
}
|
|
@@ -370,10 +375,12 @@ export class LocalBackend {
|
|
|
370
375
|
const searchQuery = params.query.trim();
|
|
371
376
|
// Step 1: Run hybrid search to get matching symbols
|
|
372
377
|
const searchLimit = processLimit * maxSymbolsPerProcess; // fetch enough raw results
|
|
373
|
-
const [
|
|
378
|
+
const [bm25SearchResult, semanticResults] = await Promise.all([
|
|
374
379
|
this.bm25Search(repo, searchQuery, searchLimit),
|
|
375
380
|
this.semanticSearch(repo, searchQuery, searchLimit),
|
|
376
381
|
]);
|
|
382
|
+
const bm25Results = bm25SearchResult.results;
|
|
383
|
+
const ftsUsed = bm25SearchResult.ftsUsed;
|
|
377
384
|
// Merge via reciprocal rank fusion
|
|
378
385
|
const scoreMap = new Map();
|
|
379
386
|
for (let i = 0; i < bm25Results.length; i++) {
|
|
@@ -540,6 +547,7 @@ export class LocalBackend {
|
|
|
540
547
|
processes,
|
|
541
548
|
process_symbols: dedupedSymbols,
|
|
542
549
|
definitions: definitions.slice(0, 20), // cap standalone definitions
|
|
550
|
+
...(!ftsUsed && { warning: 'FTS extension unavailable - keyword search degraded. Run: gitnexus analyze --force to rebuild indexes.' }),
|
|
543
551
|
};
|
|
544
552
|
}
|
|
545
553
|
/**
|
|
@@ -553,8 +561,9 @@ export class LocalBackend {
|
|
|
553
561
|
}
|
|
554
562
|
catch (err) {
|
|
555
563
|
console.error('GitNexus: BM25/FTS search failed (FTS indexes may not exist) -', err.message);
|
|
556
|
-
return [];
|
|
564
|
+
return { results: [], ftsUsed: false };
|
|
557
565
|
}
|
|
566
|
+
const ftsUsed = bm25Results.length === 0 || (bm25Results[0]?.ftsUsed !== false);
|
|
558
567
|
const results = [];
|
|
559
568
|
for (const bm25Result of bm25Results) {
|
|
560
569
|
const fullPath = bm25Result.filePath;
|
|
@@ -598,7 +607,7 @@ export class LocalBackend {
|
|
|
598
607
|
});
|
|
599
608
|
}
|
|
600
609
|
}
|
|
601
|
-
return results;
|
|
610
|
+
return { results, ftsUsed };
|
|
602
611
|
}
|
|
603
612
|
/**
|
|
604
613
|
* Semantic vector search helper
|
|
@@ -671,7 +680,7 @@ export class LocalBackend {
|
|
|
671
680
|
return { error: 'LadybugDB not ready. Index may be corrupted.' };
|
|
672
681
|
}
|
|
673
682
|
// Block write operations (defense-in-depth — DB is already read-only)
|
|
674
|
-
if (
|
|
683
|
+
if (isWriteQuery(params.query)) {
|
|
675
684
|
return { error: 'Write operations (CREATE, DELETE, SET, MERGE, REMOVE, DROP, ALTER, COPY, DETACH) are not allowed. The knowledge graph is read-only.' };
|
|
676
685
|
}
|
|
677
686
|
try {
|
|
@@ -847,6 +856,47 @@ export class LocalBackend {
|
|
|
847
856
|
return { error: `Symbol '${name || uid}' not found` };
|
|
848
857
|
}
|
|
849
858
|
// Step 2: Disambiguation
|
|
859
|
+
// When multiple nodes share the same name (e.g. a Java Class and its
|
|
860
|
+
// Constructor both named 'SessionTracker'), prefer the Class node so
|
|
861
|
+
// context() returns the semantically meaningful result rather than
|
|
862
|
+
// triggering ambiguous disambiguation (#480).
|
|
863
|
+
// labels(n)[0] returns empty string in LadybugDB, so we resolve the
|
|
864
|
+
// preferred node by re-querying with explicit label filters, scoped to
|
|
865
|
+
// the candidate IDs already in symbols.
|
|
866
|
+
//
|
|
867
|
+
// Guard: only attempt Class-preference when at least one candidate has an
|
|
868
|
+
// empty/unknown type (LadybugDB limitation) or is a Constructor — meaning
|
|
869
|
+
// the ambiguity may be a Class/Constructor name collision rather than two
|
|
870
|
+
// genuinely distinct symbols (e.g. two Functions in different files).
|
|
871
|
+
//
|
|
872
|
+
// resolvedLabel is set here and threaded to Step 3 to avoid a redundant
|
|
873
|
+
// classCheck round-trip later.
|
|
874
|
+
let resolvedLabel = '';
|
|
875
|
+
if (symbols.length > 1 && !uid) {
|
|
876
|
+
const hasAmbiguousType = symbols.some((s) => {
|
|
877
|
+
const t = s.type || s[2] || '';
|
|
878
|
+
return t === '' || t === 'Constructor';
|
|
879
|
+
});
|
|
880
|
+
if (hasAmbiguousType) {
|
|
881
|
+
const candidateIds = symbols.map((s) => s.id || s[0]).filter(Boolean);
|
|
882
|
+
const PREFER_LABELS = ['Class', 'Interface'];
|
|
883
|
+
let preferred = null;
|
|
884
|
+
for (const label of PREFER_LABELS) {
|
|
885
|
+
const match = await executeParameterized(repo.id, `
|
|
886
|
+
MATCH (n:\`${label}\`) WHERE n.id IN $candidateIds RETURN n.id AS id LIMIT 1
|
|
887
|
+
`, { candidateIds }).catch(() => []);
|
|
888
|
+
if (match.length > 0) {
|
|
889
|
+
preferred = symbols.find((s) => (s.id || s[0]) === (match[0].id || match[0][0]));
|
|
890
|
+
if (preferred) {
|
|
891
|
+
resolvedLabel = label;
|
|
892
|
+
break;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
if (preferred)
|
|
897
|
+
symbols = [preferred];
|
|
898
|
+
}
|
|
899
|
+
}
|
|
850
900
|
if (symbols.length > 1 && !uid) {
|
|
851
901
|
return {
|
|
852
902
|
status: 'ambiguous',
|
|
@@ -864,12 +914,74 @@ export class LocalBackend {
|
|
|
864
914
|
const sym = symbols[0];
|
|
865
915
|
const symId = sym.id || sym[0];
|
|
866
916
|
// Categorized incoming refs
|
|
867
|
-
|
|
917
|
+
let incomingRows = await executeParameterized(repo.id, `
|
|
868
918
|
MATCH (caller)-[r:CodeRelation]->(n {id: $symId})
|
|
869
919
|
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'HAS_PROPERTY', 'OVERRIDES', 'ACCESSES']
|
|
870
920
|
RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind
|
|
871
921
|
LIMIT 30
|
|
872
922
|
`, { symId });
|
|
923
|
+
// Fix #480: Class/Interface nodes have no direct CALLS/IMPORTS edges —
|
|
924
|
+
// those point to Constructor and File nodes respectively. Fetch those
|
|
925
|
+
// extra incoming refs and merge them in so context() shows real callers.
|
|
926
|
+
//
|
|
927
|
+
// Determine if this is a Class/Interface node. If resolvedLabel was set
|
|
928
|
+
// during disambiguation (Step 2), use it directly — no extra round-trip.
|
|
929
|
+
// Otherwise fall back to a single label check only when the type field is
|
|
930
|
+
// empty (LadybugDB labels(n)[0] limitation).
|
|
931
|
+
const symRawType = sym.type || sym[2] || '';
|
|
932
|
+
let isClassLike = resolvedLabel === 'Class' || resolvedLabel === 'Interface';
|
|
933
|
+
if (!isClassLike && symRawType === '') {
|
|
934
|
+
try {
|
|
935
|
+
// Single UNION query instead of two serial round-trips.
|
|
936
|
+
const typeCheck = await executeParameterized(repo.id, `
|
|
937
|
+
MATCH (n:Class) WHERE n.id = $symId RETURN 'Class' AS label LIMIT 1
|
|
938
|
+
UNION ALL
|
|
939
|
+
MATCH (n:Interface) WHERE n.id = $symId RETURN 'Interface' AS label LIMIT 1
|
|
940
|
+
`, { symId });
|
|
941
|
+
isClassLike = typeCheck.length > 0;
|
|
942
|
+
}
|
|
943
|
+
catch { /* not a Class/Interface node */ }
|
|
944
|
+
}
|
|
945
|
+
else if (!isClassLike) {
|
|
946
|
+
isClassLike = symRawType === 'Class' || symRawType === 'Interface';
|
|
947
|
+
}
|
|
948
|
+
if (isClassLike) {
|
|
949
|
+
try {
|
|
950
|
+
// Run both incoming-ref queries in parallel — they are independent.
|
|
951
|
+
const [ctorIncoming, fileIncoming] = await Promise.all([
|
|
952
|
+
executeParameterized(repo.id, `
|
|
953
|
+
MATCH (n)-[hm:CodeRelation]->(ctor:Constructor)
|
|
954
|
+
WHERE n.id = $symId AND hm.type = 'HAS_METHOD'
|
|
955
|
+
MATCH (caller)-[r:CodeRelation]->(ctor)
|
|
956
|
+
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'ACCESSES']
|
|
957
|
+
RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind
|
|
958
|
+
LIMIT 30
|
|
959
|
+
`, { symId }),
|
|
960
|
+
executeParameterized(repo.id, `
|
|
961
|
+
MATCH (f:File)-[rel:CodeRelation]->(n)
|
|
962
|
+
WHERE n.id = $symId AND rel.type = 'DEFINES'
|
|
963
|
+
MATCH (caller)-[r:CodeRelation]->(f)
|
|
964
|
+
WHERE r.type IN ['CALLS', 'IMPORTS']
|
|
965
|
+
RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind
|
|
966
|
+
LIMIT 30
|
|
967
|
+
`, { symId }),
|
|
968
|
+
]);
|
|
969
|
+
// Deduplicate by (relType, uid) — a caller can have multiple relation
|
|
970
|
+
// types to the same target (e.g. both IMPORTS and CALLS), and each
|
|
971
|
+
// must be preserved so every category appears in the output.
|
|
972
|
+
const seenKeys = new Set(incomingRows.map((r) => `${r.relType || r[0]}:${r.uid || r[1]}`));
|
|
973
|
+
for (const r of [...ctorIncoming, ...fileIncoming]) {
|
|
974
|
+
const key = `${r.relType || r[0]}:${r.uid || r[1]}`;
|
|
975
|
+
if (!seenKeys.has(key)) {
|
|
976
|
+
seenKeys.add(key);
|
|
977
|
+
incomingRows.push(r);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
catch (e) {
|
|
982
|
+
logQueryError('context:class-incoming-expansion', e);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
873
985
|
// Categorized outgoing refs
|
|
874
986
|
const outgoingRows = await executeParameterized(repo.id, `
|
|
875
987
|
MATCH (n {id: $symId})-[r:CodeRelation]->(target)
|
|
@@ -910,7 +1022,7 @@ export class LocalBackend {
|
|
|
910
1022
|
symbol: {
|
|
911
1023
|
uid: sym.id || sym[0],
|
|
912
1024
|
name: sym.name || sym[1],
|
|
913
|
-
kind: sym.type || sym[2],
|
|
1025
|
+
kind: isClassLike ? (resolvedLabel || 'Class') : (sym.type || sym[2]),
|
|
914
1026
|
filePath: sym.filePath || sym[3],
|
|
915
1027
|
startLine: sym.startLine || sym[4],
|
|
916
1028
|
endLine: sym.endLine || sym[5],
|
|
@@ -1294,20 +1406,100 @@ export class LocalBackend {
|
|
|
1294
1406
|
const minConfidence = params.minConfidence ?? 0;
|
|
1295
1407
|
const relTypeFilter = relationTypes.map(t => `'${t}'`).join(', ');
|
|
1296
1408
|
const confidenceFilter = minConfidence > 0 ? ` AND r.confidence >= ${minConfidence}` : '';
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1409
|
+
// Resolve target by name, preferring Class/Interface over Constructor
|
|
1410
|
+
// (fix #480: Java class and constructor share the same name).
|
|
1411
|
+
// labels(n)[0] returns empty string in LadybugDB, so we use explicit
|
|
1412
|
+
// label-typed sub-queries in a single UNION ordered by priority to avoid
|
|
1413
|
+
// up to 6 serial round-trips for non-Class targets.
|
|
1414
|
+
let sym = null;
|
|
1415
|
+
let symType = '';
|
|
1416
|
+
try {
|
|
1417
|
+
const rows = await executeParameterized(repo.id, `
|
|
1418
|
+
MATCH (n:\`Class\`) WHERE n.name = $targetName
|
|
1419
|
+
RETURN n.id AS id, n.name AS name, n.filePath AS filePath, 0 AS priority LIMIT 1
|
|
1420
|
+
UNION ALL
|
|
1421
|
+
MATCH (n:\`Interface\`) WHERE n.name = $targetName
|
|
1422
|
+
RETURN n.id AS id, n.name AS name, n.filePath AS filePath, 1 AS priority LIMIT 1
|
|
1423
|
+
UNION ALL
|
|
1424
|
+
MATCH (n:\`Function\`) WHERE n.name = $targetName
|
|
1425
|
+
RETURN n.id AS id, n.name AS name, n.filePath AS filePath, 2 AS priority LIMIT 1
|
|
1426
|
+
UNION ALL
|
|
1427
|
+
MATCH (n:\`Method\`) WHERE n.name = $targetName
|
|
1428
|
+
RETURN n.id AS id, n.name AS name, n.filePath AS filePath, 3 AS priority LIMIT 1
|
|
1429
|
+
UNION ALL
|
|
1430
|
+
MATCH (n:\`Constructor\`) WHERE n.name = $targetName
|
|
1431
|
+
RETURN n.id AS id, n.name AS name, n.filePath AS filePath, 4 AS priority LIMIT 1
|
|
1432
|
+
`, { targetName: target }).catch(() => []);
|
|
1433
|
+
if (rows.length > 0) {
|
|
1434
|
+
// Pick the row with the lowest priority value (Class wins over Constructor)
|
|
1435
|
+
const best = rows.reduce((a, b) => (a.priority ?? a[3] ?? 99) <= (b.priority ?? b[3] ?? 99) ? a : b);
|
|
1436
|
+
sym = best;
|
|
1437
|
+
const priorityToLabel = ['Class', 'Interface', 'Function', 'Method', 'Constructor'];
|
|
1438
|
+
symType = priorityToLabel[best.priority ?? best[3]] ?? '';
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
catch { /* fall through to unlabeled match */ }
|
|
1442
|
+
// Fall back to unlabeled match for any other node type
|
|
1443
|
+
if (!sym) {
|
|
1444
|
+
const rows = await executeParameterized(repo.id, `
|
|
1445
|
+
MATCH (n)
|
|
1446
|
+
WHERE n.name = $targetName
|
|
1447
|
+
RETURN n.id AS id, n.name AS name, n.filePath AS filePath
|
|
1448
|
+
LIMIT 1
|
|
1449
|
+
`, { targetName: target });
|
|
1450
|
+
if (rows.length > 0)
|
|
1451
|
+
sym = rows[0];
|
|
1452
|
+
}
|
|
1453
|
+
if (!sym)
|
|
1304
1454
|
return { error: `Target '${target}' not found` };
|
|
1305
|
-
const sym = targets[0];
|
|
1306
1455
|
const symId = sym.id || sym[0];
|
|
1307
1456
|
const impacted = [];
|
|
1308
1457
|
const visited = new Set([symId]);
|
|
1309
1458
|
let frontier = [symId];
|
|
1310
1459
|
let traversalComplete = true;
|
|
1460
|
+
// Fix #480: For Java (and other JVM) Class/Interface nodes, CALLS edges
|
|
1461
|
+
// point to Constructor nodes and IMPORTS edges point to File nodes — not
|
|
1462
|
+
// the Class/Interface itself. Seed the frontier with the Constructor(s)
|
|
1463
|
+
// and owning File so the BFS traversal finds those edges naturally.
|
|
1464
|
+
// The owning File is kept only as an internal seed (frontier/visited) and
|
|
1465
|
+
// is NOT added to impacted — it is the definition container, not an
|
|
1466
|
+
// upstream dependent. The BFS will discover IMPORTS edges on it naturally.
|
|
1467
|
+
if (symType === 'Class' || symType === 'Interface') {
|
|
1468
|
+
try {
|
|
1469
|
+
// Run both seed queries in parallel — they are independent.
|
|
1470
|
+
const [ctorRows, fileRows] = await Promise.all([
|
|
1471
|
+
executeParameterized(repo.id, `
|
|
1472
|
+
MATCH (n)-[hm:CodeRelation]->(c:Constructor)
|
|
1473
|
+
WHERE n.id = $symId AND hm.type = 'HAS_METHOD'
|
|
1474
|
+
RETURN c.id AS id, c.name AS name, labels(c)[0] AS type, c.filePath AS filePath
|
|
1475
|
+
`, { symId }),
|
|
1476
|
+
// Restrict to DEFINES edges only — other File->Class edge types (if
|
|
1477
|
+
// any) should not be treated as the owning file relationship.
|
|
1478
|
+
executeParameterized(repo.id, `
|
|
1479
|
+
MATCH (f:File)-[rel:CodeRelation]->(n)
|
|
1480
|
+
WHERE n.id = $symId AND rel.type = 'DEFINES'
|
|
1481
|
+
RETURN f.id AS id, f.name AS name, labels(f)[0] AS type, f.filePath AS filePath
|
|
1482
|
+
`, { symId }),
|
|
1483
|
+
]);
|
|
1484
|
+
for (const r of ctorRows) {
|
|
1485
|
+
const rid = r.id || r[0];
|
|
1486
|
+
if (rid && !visited.has(rid)) {
|
|
1487
|
+
visited.add(rid);
|
|
1488
|
+
frontier.push(rid);
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
for (const r of fileRows) {
|
|
1492
|
+
const rid = r.id || r[0];
|
|
1493
|
+
if (rid && !visited.has(rid)) {
|
|
1494
|
+
visited.add(rid);
|
|
1495
|
+
frontier.push(rid);
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
catch (e) {
|
|
1500
|
+
logQueryError('impact:class-node-expansion', e);
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1311
1503
|
for (let depth = 1; depth <= maxDepth && frontier.length > 0; depth++) {
|
|
1312
1504
|
const nextFrontier = [];
|
|
1313
1505
|
// Batch frontier nodes into a single Cypher query per depth level
|
|
@@ -1364,62 +1556,201 @@ export class LocalBackend {
|
|
|
1364
1556
|
let affectedProcesses = [];
|
|
1365
1557
|
let affectedModules = [];
|
|
1366
1558
|
if (impacted.length > 0) {
|
|
1367
|
-
|
|
1368
|
-
//
|
|
1369
|
-
|
|
1370
|
-
const
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
//
|
|
1374
|
-
//
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
const
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1559
|
+
const CHUNK_SIZE = 100;
|
|
1560
|
+
// Max number of chunks to process to avoid unbounded DB round-trips.
|
|
1561
|
+
// Configurable via env IMPACT_MAX_CHUNKS, default 10 => max items = 1000
|
|
1562
|
+
const MAX_CHUNKS = parseInt(process.env.IMPACT_MAX_CHUNKS || '10', 10);
|
|
1563
|
+
// ── Process enrichment: batched chunking (bounded by MAX_CHUNKS) ─
|
|
1564
|
+
// Uses merged Cypher query (WITH + OPTIONAL MATCH) to fetch
|
|
1565
|
+
// process + entry point info in 1 round-trip per chunk. Converted to
|
|
1566
|
+
// parameterized queries to avoid manual string escaping and long query strings.
|
|
1567
|
+
const entryPointMap = new Map();
|
|
1568
|
+
// Map process id -> entryPointId to allow fixing missing minStep values later
|
|
1569
|
+
const processToEntryPoint = new Map();
|
|
1570
|
+
// Collect process ids where MIN(r.step) returned null so we can retry in batch
|
|
1571
|
+
const processesMissingMinStep = new Set();
|
|
1572
|
+
let chunksProcessed = 0;
|
|
1573
|
+
for (let i = 0; i < impacted.length && chunksProcessed < MAX_CHUNKS; i += CHUNK_SIZE, chunksProcessed++) {
|
|
1574
|
+
const chunk = impacted.slice(i, i + CHUNK_SIZE);
|
|
1575
|
+
const ids = chunk.map(item => String(item.id ?? ''));
|
|
1576
|
+
try {
|
|
1577
|
+
// Use parameterized list to avoid building long query strings
|
|
1578
|
+
const rows = await executeParameterized(repo.id, `
|
|
1579
|
+
MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
|
|
1580
|
+
WHERE s.id IN $ids
|
|
1581
|
+
WITH p, COUNT(DISTINCT s.id) AS hits, MIN(r.step) AS minStep
|
|
1582
|
+
OPTIONAL MATCH (ep {id: p.entryPointId})
|
|
1583
|
+
RETURN p.id AS pId, p.heuristicLabel AS name, p.processType AS processType,
|
|
1584
|
+
p.entryPointId AS entryPointId, hits, minStep, p.stepCount AS stepCount,
|
|
1585
|
+
ep.name AS epName, labels(ep)[0] AS epType, ep.filePath AS epFilePath
|
|
1586
|
+
`, { ids }).catch(() => []);
|
|
1587
|
+
for (const row of rows) {
|
|
1588
|
+
const pId = row.pId ?? row[0];
|
|
1589
|
+
const epId = row.entryPointId ?? row[3] ?? row.pId ?? row[0];
|
|
1590
|
+
// Track mapping from process -> entryPoint so we can backfill missing minStep
|
|
1591
|
+
if (pId)
|
|
1592
|
+
processToEntryPoint.set(String(pId), String(epId));
|
|
1593
|
+
// Normalize epName: prefer epName, fall back to other columns, and
|
|
1594
|
+
// ensure we don't keep an empty string (labels(...) can return "").
|
|
1595
|
+
const epNameRaw = row.epName ?? row[7] ?? row.name ?? row[1] ?? 'unknown';
|
|
1596
|
+
const epName = (typeof epNameRaw === 'string' && epNameRaw.trim().length > 0) ? epNameRaw.trim() : 'unknown';
|
|
1597
|
+
// Normalize epType: labels(ep)[0] can return an empty string in
|
|
1598
|
+
// some DBs (LadybugDB). Using nullish coalescing (??) preserves
|
|
1599
|
+
// empty strings, which results in empty `type` values being
|
|
1600
|
+
// propagated. Treat empty-string labels as missing and fall back
|
|
1601
|
+
// to the next candidate or a sensible default.
|
|
1602
|
+
const epTypeRaw = row.epType ?? row[8] ?? '';
|
|
1603
|
+
const epType = (typeof epTypeRaw === 'string' && epTypeRaw.trim().length > 0)
|
|
1604
|
+
? epTypeRaw.trim()
|
|
1605
|
+
: 'Function';
|
|
1606
|
+
const epFilePath = row.epFilePath ?? row[9] ?? '';
|
|
1607
|
+
const hits = row.hits ?? row[4] ?? 0;
|
|
1608
|
+
const minStep = row.minStep ?? row[5];
|
|
1609
|
+
// If the DB returned null for minStep, note the process id so we
|
|
1610
|
+
// can run a follow-up query using a different aggregation strategy.
|
|
1611
|
+
if (minStep === null || minStep === undefined) {
|
|
1612
|
+
if (pId)
|
|
1613
|
+
processesMissingMinStep.add(String(pId));
|
|
1614
|
+
}
|
|
1615
|
+
if (!entryPointMap.has(epId)) {
|
|
1616
|
+
entryPointMap.set(epId, {
|
|
1617
|
+
name: epName,
|
|
1618
|
+
type: epType,
|
|
1619
|
+
filePath: epFilePath,
|
|
1620
|
+
affected_process_count: 0,
|
|
1621
|
+
total_hits: 0,
|
|
1622
|
+
earliest_broken_step: Infinity,
|
|
1623
|
+
});
|
|
1624
|
+
}
|
|
1625
|
+
const ep = entryPointMap.get(epId);
|
|
1626
|
+
ep.affected_process_count += 1;
|
|
1627
|
+
ep.total_hits += hits;
|
|
1628
|
+
ep.earliest_broken_step = Math.min(ep.earliest_broken_step, minStep ?? Infinity);
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
catch (e) {
|
|
1632
|
+
logQueryError('impact:process-chunk', e);
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
// If some processes returned null minStep, try a batched follow-up query
|
|
1636
|
+
// using the full impacted id set. This handles older indexes or DBs
|
|
1637
|
+
// where MIN(r.step) can come back null even when step properties exist.
|
|
1638
|
+
if (processesMissingMinStep.size > 0) {
|
|
1639
|
+
try {
|
|
1640
|
+
const pIds = Array.from(processesMissingMinStep);
|
|
1641
|
+
const allImpactedIds = impacted.map(it => String(it.id ?? ''));
|
|
1642
|
+
const missingRows = await executeParameterized(repo.id, `
|
|
1643
|
+
MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
|
|
1644
|
+
WHERE p.id IN $pIds AND s.id IN $ids
|
|
1645
|
+
RETURN p.id AS pid, MIN(r.step) AS minStep
|
|
1646
|
+
`, { pIds, ids: allImpactedIds }).catch(() => []);
|
|
1647
|
+
for (const mr of missingRows) {
|
|
1648
|
+
const pid = mr.pid ?? mr[0];
|
|
1649
|
+
const minStep = mr.minStep ?? mr[1];
|
|
1650
|
+
const epId = processToEntryPoint.get(String(pid));
|
|
1651
|
+
if (!epId)
|
|
1652
|
+
continue;
|
|
1653
|
+
const ep = entryPointMap.get(epId);
|
|
1654
|
+
if (!ep)
|
|
1655
|
+
continue;
|
|
1656
|
+
if (typeof minStep === 'number') {
|
|
1657
|
+
ep.earliest_broken_step = Math.min(ep.earliest_broken_step, minStep);
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
catch (e) {
|
|
1662
|
+
logQueryError('impact:process-chunk-backfill', e);
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
// If we capped chunks, mark traversal incomplete so caller knows results are partial
|
|
1666
|
+
if (chunksProcessed * CHUNK_SIZE < impacted.length) {
|
|
1667
|
+
traversalComplete = false;
|
|
1668
|
+
}
|
|
1669
|
+
affectedProcesses = Array.from(entryPointMap.values())
|
|
1670
|
+
.map(ep => ({
|
|
1671
|
+
...ep,
|
|
1672
|
+
earliest_broken_step: ep.earliest_broken_step === Infinity ? null : ep.earliest_broken_step,
|
|
1673
|
+
}))
|
|
1674
|
+
.sort((a, b) => b.total_hits - a.total_hits);
|
|
1675
|
+
// ── Module enrichment: use same cap as process enrichment and parameterized queries
|
|
1676
|
+
const maxItems = Math.min(impacted.length, MAX_CHUNKS * CHUNK_SIZE);
|
|
1677
|
+
const cappedImpacted = impacted.slice(0, maxItems);
|
|
1678
|
+
const allIdsArr = cappedImpacted.map((i) => String(i.id ?? ''));
|
|
1679
|
+
const d1Items = (grouped[1] || []).slice(0, maxItems);
|
|
1680
|
+
const d1IdsArr = d1Items.map((i) => String(i.id ?? ''));
|
|
1681
|
+
// Chunked module enrichment: run the MEMBER_OF queries in chunks
|
|
1682
|
+
// to avoid large single queries or concurrent Kuzu calls that can
|
|
1683
|
+
// crash (SIGSEGV) on arm64 macOS; behavior preserves existing maxItems cap and returns equivalent aggregated results.
|
|
1684
|
+
const moduleHitsMap = new Map();
|
|
1685
|
+
const directModuleSet = new Set();
|
|
1686
|
+
// Helper to run a single module chunk and accumulate hits by name
|
|
1687
|
+
const runModuleChunk = async (idsChunk) => {
|
|
1688
|
+
if (!idsChunk || idsChunk.length === 0)
|
|
1689
|
+
return;
|
|
1690
|
+
try {
|
|
1691
|
+
const rows = await executeParameterized(repo.id, `
|
|
1692
|
+
MATCH (s)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
|
|
1693
|
+
WHERE s.id IN $ids
|
|
1694
|
+
RETURN c.heuristicLabel AS name, COUNT(DISTINCT s.id) AS hits
|
|
1695
|
+
ORDER BY hits DESC
|
|
1696
|
+
LIMIT 20
|
|
1697
|
+
`, { ids: idsChunk }).catch(() => []);
|
|
1698
|
+
for (const r of rows) {
|
|
1699
|
+
const name = r.name ?? r[0] ?? null;
|
|
1700
|
+
const hits = (r.hits ?? r[1]) || 0;
|
|
1701
|
+
if (!name)
|
|
1702
|
+
continue;
|
|
1703
|
+
moduleHitsMap.set(name, (moduleHitsMap.get(name) || 0) + hits);
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
catch (e) {
|
|
1707
|
+
logQueryError('impact:module-chunk', e);
|
|
1708
|
+
}
|
|
1709
|
+
};
|
|
1710
|
+
// Run module query chunks sequentially (safe on arm64 macOS)
|
|
1711
|
+
for (let i = 0; i < allIdsArr.length; i += CHUNK_SIZE) {
|
|
1712
|
+
const chunkIds = allIdsArr.slice(i, i + CHUNK_SIZE);
|
|
1713
|
+
await runModuleChunk(chunkIds);
|
|
1714
|
+
}
|
|
1715
|
+
// Run direct module query similarly (distinct heuristic labels for depth-1 items)
|
|
1716
|
+
const runDirectModuleChunk = async (idsChunk) => {
|
|
1717
|
+
if (!idsChunk || idsChunk.length === 0)
|
|
1718
|
+
return;
|
|
1719
|
+
try {
|
|
1720
|
+
const rows = await executeParameterized(repo.id, `
|
|
1393
1721
|
MATCH (s)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
|
|
1394
|
-
WHERE s.id IN
|
|
1722
|
+
WHERE s.id IN $ids
|
|
1395
1723
|
RETURN DISTINCT c.heuristicLabel AS name
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1724
|
+
`, { ids: idsChunk }).catch(() => []);
|
|
1725
|
+
for (const r of rows) {
|
|
1726
|
+
const name = r.name ?? r[0] ?? null;
|
|
1727
|
+
if (name)
|
|
1728
|
+
directModuleSet.add(name);
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
catch (e) {
|
|
1732
|
+
logQueryError('impact:direct-module-chunk', e);
|
|
1733
|
+
}
|
|
1734
|
+
};
|
|
1735
|
+
for (let i = 0; i < d1IdsArr.length; i += CHUNK_SIZE) {
|
|
1736
|
+
const chunkIds = d1IdsArr.slice(i, i + CHUNK_SIZE);
|
|
1737
|
+
await runDirectModuleChunk(chunkIds);
|
|
1404
1738
|
}
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
broken_at_step: r.minStep ?? r[2],
|
|
1414
|
-
step_count: r.stepCount ?? r[3],
|
|
1415
|
-
}));
|
|
1416
|
-
const directModuleSet = new Set(directModuleRows.map((r) => r.name || r[0]));
|
|
1739
|
+
// Build final moduleRows array from aggregated hits map, sorted & limited
|
|
1740
|
+
const moduleRows = Array.from(moduleHitsMap.entries())
|
|
1741
|
+
.map(([name, hits]) => ({ name, hits }))
|
|
1742
|
+
.sort((a, b) => b.hits - a.hits)
|
|
1743
|
+
.slice(0, 20);
|
|
1744
|
+
const directModuleRows = Array.from(directModuleSet).map(name => ({ name }));
|
|
1745
|
+
// Build affectedModules in the same shape as original implementation
|
|
1746
|
+
const directModuleNameSet = new Set(directModuleRows.map((r) => r.name || r[0]));
|
|
1417
1747
|
affectedModules = moduleRows.map((r) => {
|
|
1418
|
-
const name = r.name
|
|
1748
|
+
const name = r.name ?? r[0];
|
|
1749
|
+
const hits = r.hits ?? r[1] ?? 0;
|
|
1419
1750
|
return {
|
|
1420
1751
|
name,
|
|
1421
|
-
hits
|
|
1422
|
-
impact:
|
|
1752
|
+
hits,
|
|
1753
|
+
impact: directModuleNameSet.has(name) ? 'direct' : 'indirect',
|
|
1423
1754
|
};
|
|
1424
1755
|
});
|
|
1425
1756
|
}
|
|
@@ -1440,8 +1771,8 @@ export class LocalBackend {
|
|
|
1440
1771
|
target: {
|
|
1441
1772
|
id: symId,
|
|
1442
1773
|
name: sym.name || sym[1],
|
|
1443
|
-
type:
|
|
1444
|
-
filePath: sym.filePath || sym[
|
|
1774
|
+
type: symType,
|
|
1775
|
+
filePath: sym.filePath || sym[2],
|
|
1445
1776
|
},
|
|
1446
1777
|
direction,
|
|
1447
1778
|
impactedCount: impacted.length,
|
|
@@ -1457,6 +1788,312 @@ export class LocalBackend {
|
|
|
1457
1788
|
byDepth: grouped,
|
|
1458
1789
|
};
|
|
1459
1790
|
}
|
|
1791
|
+
/**
|
|
1792
|
+
* Fetch Route nodes with their consumers in a single query.
|
|
1793
|
+
* Shared by routeMap and shapeCheck to avoid N+1 query patterns.
|
|
1794
|
+
*/
|
|
1795
|
+
async fetchRoutesWithConsumers(repoId, routeFilter, params) {
|
|
1796
|
+
const rows = await executeParameterized(repoId, `
|
|
1797
|
+
MATCH (n:Route)
|
|
1798
|
+
WHERE n.id STARTS WITH 'Route:' ${routeFilter}
|
|
1799
|
+
OPTIONAL MATCH (consumer)-[r:CodeRelation]->(n)
|
|
1800
|
+
WHERE r.type = 'FETCHES'
|
|
1801
|
+
RETURN n.id AS routeId, n.name AS routeName, n.filePath AS handlerFile,
|
|
1802
|
+
n.responseKeys AS responseKeys, n.errorKeys AS errorKeys, n.middleware AS middleware,
|
|
1803
|
+
consumer.name AS consumerName, consumer.filePath AS consumerFile,
|
|
1804
|
+
r.reason AS fetchReason
|
|
1805
|
+
`, params);
|
|
1806
|
+
// Strip wrapping quotes from DB array elements — CSV COPY stores ['key'] which
|
|
1807
|
+
// LadybugDB may return as "'key'" rather than "key"
|
|
1808
|
+
const stripQuotes = (keys) => keys ? keys.map(k => k.replace(/^['"]|['"]$/g, '')) : null;
|
|
1809
|
+
const routeMap = new Map();
|
|
1810
|
+
for (const row of rows) {
|
|
1811
|
+
const id = row.routeId ?? row[0];
|
|
1812
|
+
const name = row.routeName ?? row[1];
|
|
1813
|
+
const filePath = row.handlerFile ?? row[2];
|
|
1814
|
+
const responseKeys = stripQuotes(row.responseKeys ?? row[3] ?? null);
|
|
1815
|
+
const errorKeys = stripQuotes(row.errorKeys ?? row[4] ?? null);
|
|
1816
|
+
const middleware = stripQuotes(row.middleware ?? row[5] ?? null);
|
|
1817
|
+
const consumerName = row.consumerName ?? row[6];
|
|
1818
|
+
const consumerFile = row.consumerFile ?? row[7];
|
|
1819
|
+
const fetchReason = row.fetchReason ?? row[8] ?? null;
|
|
1820
|
+
if (!routeMap.has(id)) {
|
|
1821
|
+
routeMap.set(id, { id, name, filePath, responseKeys, errorKeys, middleware, consumers: [] });
|
|
1822
|
+
}
|
|
1823
|
+
if (consumerName && consumerFile) {
|
|
1824
|
+
// Parse accessed keys from reason field: "fetch-url-match|keys:data,pagination|fetches:3"
|
|
1825
|
+
let accessedKeys;
|
|
1826
|
+
let fetchCount;
|
|
1827
|
+
if (fetchReason) {
|
|
1828
|
+
const keysMatch = fetchReason.match(/\|keys:([^|]+)/);
|
|
1829
|
+
if (keysMatch) {
|
|
1830
|
+
accessedKeys = keysMatch[1].split(',').filter(k => k.length > 0);
|
|
1831
|
+
}
|
|
1832
|
+
const fetchesMatch = fetchReason.match(/\|fetches:(\d+)/);
|
|
1833
|
+
if (fetchesMatch) {
|
|
1834
|
+
fetchCount = parseInt(fetchesMatch[1], 10);
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
routeMap.get(id).consumers.push({
|
|
1838
|
+
name: consumerName,
|
|
1839
|
+
filePath: consumerFile,
|
|
1840
|
+
...(accessedKeys ? { accessedKeys } : {}),
|
|
1841
|
+
...(fetchCount && fetchCount > 1 ? { fetchCount } : {}),
|
|
1842
|
+
});
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
return [...routeMap.values()];
|
|
1846
|
+
}
|
|
1847
|
+
/**
|
|
1848
|
+
* Batch-fetch execution flows linked to a set of Route or Tool nodes.
|
|
1849
|
+
* Single query instead of N+1.
|
|
1850
|
+
*/
|
|
1851
|
+
async fetchLinkedFlowsBatch(repoId, nodeIds) {
|
|
1852
|
+
const result = new Map();
|
|
1853
|
+
if (nodeIds.length === 0)
|
|
1854
|
+
return result;
|
|
1855
|
+
try {
|
|
1856
|
+
// Use list_contains to filter at DB level instead of fetching all and filtering in memory
|
|
1857
|
+
const rows = await executeParameterized(repoId, `
|
|
1858
|
+
MATCH (source)-[r:CodeRelation]->(proc:Process)
|
|
1859
|
+
WHERE r.type = 'ENTRY_POINT_OF'
|
|
1860
|
+
AND list_contains($nodeIds, source.id)
|
|
1861
|
+
RETURN source.id AS sourceId, proc.label AS name
|
|
1862
|
+
`, { nodeIds });
|
|
1863
|
+
for (const row of rows) {
|
|
1864
|
+
const sourceId = row.sourceId ?? row[0];
|
|
1865
|
+
const name = row.name ?? row[1];
|
|
1866
|
+
if (!name)
|
|
1867
|
+
continue;
|
|
1868
|
+
let list = result.get(sourceId);
|
|
1869
|
+
if (!list) {
|
|
1870
|
+
list = [];
|
|
1871
|
+
result.set(sourceId, list);
|
|
1872
|
+
}
|
|
1873
|
+
list.push(name);
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
catch { /* no ENTRY_POINT_OF edges yet */ }
|
|
1877
|
+
return result;
|
|
1878
|
+
}
|
|
1879
|
+
async routeMap(repo, params) {
|
|
1880
|
+
await this.ensureInitialized(repo.id);
|
|
1881
|
+
const routeFilter = params.route ? `AND n.name CONTAINS $route` : '';
|
|
1882
|
+
const queryParams = params.route ? { route: params.route } : {};
|
|
1883
|
+
const routes = await this.fetchRoutesWithConsumers(repo.id, routeFilter, queryParams);
|
|
1884
|
+
if (routes.length === 0) {
|
|
1885
|
+
return { routes: [], total: 0, message: params.route ? `No routes matching "${params.route}"` : 'No routes found in this project.' };
|
|
1886
|
+
}
|
|
1887
|
+
const flowMap = await this.fetchLinkedFlowsBatch(repo.id, routes.map(r => r.id));
|
|
1888
|
+
return {
|
|
1889
|
+
routes: routes.map(r => ({
|
|
1890
|
+
route: r.name, handler: r.filePath,
|
|
1891
|
+
middleware: r.middleware || [],
|
|
1892
|
+
consumers: r.consumers,
|
|
1893
|
+
flows: flowMap.get(r.id) || [],
|
|
1894
|
+
})),
|
|
1895
|
+
total: routes.length,
|
|
1896
|
+
};
|
|
1897
|
+
}
|
|
1898
|
+
async shapeCheck(repo, params) {
|
|
1899
|
+
await this.ensureInitialized(repo.id);
|
|
1900
|
+
const routeFilter = params.route ? `AND n.name CONTAINS $route` : '';
|
|
1901
|
+
const queryParams = params.route ? { route: params.route } : {};
|
|
1902
|
+
const allRoutes = await this.fetchRoutesWithConsumers(repo.id, routeFilter, queryParams);
|
|
1903
|
+
const results = allRoutes
|
|
1904
|
+
.filter(r => ((r.responseKeys && r.responseKeys.length > 0) || (r.errorKeys && r.errorKeys.length > 0)) && r.consumers.length > 0)
|
|
1905
|
+
.map(r => {
|
|
1906
|
+
// Keys already normalized by fetchRoutesWithConsumers (quotes stripped)
|
|
1907
|
+
const responseKeys = r.responseKeys ?? [];
|
|
1908
|
+
const errorKeys = r.errorKeys ?? [];
|
|
1909
|
+
// Combined set: consumer accessing either success or error keys is valid
|
|
1910
|
+
const allKnownKeys = new Set([...responseKeys, ...errorKeys]);
|
|
1911
|
+
// Check each consumer's accessed keys against the route's response shape
|
|
1912
|
+
const responseKeySet = new Set(responseKeys);
|
|
1913
|
+
const consumers = r.consumers.map(c => {
|
|
1914
|
+
if (!c.accessedKeys || c.accessedKeys.length === 0) {
|
|
1915
|
+
return { name: c.name, filePath: c.filePath };
|
|
1916
|
+
}
|
|
1917
|
+
const mismatched = c.accessedKeys.filter(k => !allKnownKeys.has(k));
|
|
1918
|
+
// Keys in allKnownKeys but not in responseKeys — error-path access (e.g., .error from errorKeys)
|
|
1919
|
+
const errorPathKeys = c.accessedKeys.filter(k => allKnownKeys.has(k) && !responseKeySet.has(k));
|
|
1920
|
+
const isMultiFetch = (c.fetchCount ?? 1) > 1;
|
|
1921
|
+
return {
|
|
1922
|
+
name: c.name,
|
|
1923
|
+
filePath: c.filePath,
|
|
1924
|
+
accessedKeys: c.accessedKeys,
|
|
1925
|
+
...(mismatched.length > 0 ? { mismatched, mismatchConfidence: isMultiFetch ? 'low' : 'high' } : {}),
|
|
1926
|
+
...(errorPathKeys.length > 0 ? { errorPathKeys } : {}),
|
|
1927
|
+
...(isMultiFetch ? { attributionNote: `This file fetches ${c.fetchCount} routes — accessed keys may belong to a different route.` } : {}),
|
|
1928
|
+
};
|
|
1929
|
+
});
|
|
1930
|
+
const hasMismatches = consumers.some(c => 'mismatched' in c && c.mismatched.length > 0);
|
|
1931
|
+
return {
|
|
1932
|
+
route: r.name,
|
|
1933
|
+
handler: r.filePath,
|
|
1934
|
+
...(responseKeys.length > 0 ? { responseKeys } : {}),
|
|
1935
|
+
...(errorKeys.length > 0 ? { errorKeys } : {}),
|
|
1936
|
+
consumers,
|
|
1937
|
+
...(hasMismatches ? { status: 'MISMATCH' } : {}),
|
|
1938
|
+
};
|
|
1939
|
+
});
|
|
1940
|
+
const mismatchCount = results.filter(r => r.status === 'MISMATCH').length;
|
|
1941
|
+
return {
|
|
1942
|
+
routes: results,
|
|
1943
|
+
total: results.length,
|
|
1944
|
+
routesWithShapes: results.length,
|
|
1945
|
+
...(mismatchCount > 0 ? { mismatches: mismatchCount } : {}),
|
|
1946
|
+
message: results.length === 0
|
|
1947
|
+
? 'No routes with both response shapes and consumers found.'
|
|
1948
|
+
: mismatchCount > 0
|
|
1949
|
+
? `Found ${results.length} route(s) with response shape data. ${mismatchCount} route(s) have consumer/shape mismatches.`
|
|
1950
|
+
: `Found ${results.length} route(s) with response shape data and consumers.`,
|
|
1951
|
+
};
|
|
1952
|
+
}
|
|
1953
|
+
async toolMap(repo, params) {
|
|
1954
|
+
await this.ensureInitialized(repo.id);
|
|
1955
|
+
const toolFilter = params.tool ? `AND n.name CONTAINS $tool` : '';
|
|
1956
|
+
const queryParams = params.tool ? { tool: params.tool } : {};
|
|
1957
|
+
const rows = await executeParameterized(repo.id, `
|
|
1958
|
+
MATCH (n:Tool)
|
|
1959
|
+
WHERE n.id STARTS WITH 'Tool:' ${toolFilter}
|
|
1960
|
+
RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.description AS description
|
|
1961
|
+
`, queryParams);
|
|
1962
|
+
if (rows.length === 0) {
|
|
1963
|
+
return { tools: [], total: 0, message: params.tool ? `No tools matching "${params.tool}"` : 'No tool definitions found.' };
|
|
1964
|
+
}
|
|
1965
|
+
const toolIds = rows.map((r) => r.id ?? r[0]);
|
|
1966
|
+
const flowMap = await this.fetchLinkedFlowsBatch(repo.id, toolIds);
|
|
1967
|
+
return {
|
|
1968
|
+
tools: rows.map((r) => {
|
|
1969
|
+
const id = r.id ?? r[0];
|
|
1970
|
+
return {
|
|
1971
|
+
name: r.name ?? r[1],
|
|
1972
|
+
filePath: r.filePath ?? r[2],
|
|
1973
|
+
description: (r.description ?? r[3] ?? '').slice(0, 200),
|
|
1974
|
+
flows: flowMap.get(id) || [],
|
|
1975
|
+
};
|
|
1976
|
+
}),
|
|
1977
|
+
total: rows.length,
|
|
1978
|
+
};
|
|
1979
|
+
}
|
|
1980
|
+
async apiImpact(repo, params) {
|
|
1981
|
+
await this.ensureInitialized(repo.id);
|
|
1982
|
+
if (!params.route && !params.file) {
|
|
1983
|
+
return { error: 'Either "route" or "file" parameter is required.' };
|
|
1984
|
+
}
|
|
1985
|
+
// If file is provided but route is not, look up the route by file path
|
|
1986
|
+
let routeFilter = '';
|
|
1987
|
+
const queryParams = {};
|
|
1988
|
+
if (params.route) {
|
|
1989
|
+
routeFilter = `AND n.name CONTAINS $route`;
|
|
1990
|
+
queryParams.route = params.route;
|
|
1991
|
+
}
|
|
1992
|
+
else if (params.file) {
|
|
1993
|
+
routeFilter = `AND n.filePath CONTAINS $file`;
|
|
1994
|
+
queryParams.file = params.file;
|
|
1995
|
+
}
|
|
1996
|
+
const routes = await this.fetchRoutesWithConsumers(repo.id, routeFilter, queryParams);
|
|
1997
|
+
if (routes.length === 0) {
|
|
1998
|
+
const target = params.route || params.file;
|
|
1999
|
+
return { error: `No routes found matching "${target}".` };
|
|
2000
|
+
}
|
|
2001
|
+
const flowMap = await this.fetchLinkedFlowsBatch(repo.id, routes.map(r => r.id));
|
|
2002
|
+
// Count how many routes share the same handler file (for middleware partial detection)
|
|
2003
|
+
const routeCountByHandler = new Map();
|
|
2004
|
+
for (const r of routes) {
|
|
2005
|
+
if (r.filePath) {
|
|
2006
|
+
routeCountByHandler.set(r.filePath, (routeCountByHandler.get(r.filePath) ?? 0) + 1);
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
const results = routes.map(r => {
|
|
2010
|
+
// Keys already normalized by fetchRoutesWithConsumers (quotes stripped)
|
|
2011
|
+
const responseKeys = r.responseKeys ?? [];
|
|
2012
|
+
const errorKeys = r.errorKeys ?? [];
|
|
2013
|
+
const allKnownKeys = new Set([...responseKeys, ...errorKeys]);
|
|
2014
|
+
// Build consumer list with mismatch detection
|
|
2015
|
+
const consumers = r.consumers.map(c => ({
|
|
2016
|
+
name: c.name,
|
|
2017
|
+
file: c.filePath,
|
|
2018
|
+
accesses: c.accessedKeys ?? [],
|
|
2019
|
+
...(c.fetchCount && c.fetchCount > 1 ? { attributionNote: `This file fetches ${c.fetchCount} routes — accessed keys may belong to a different route.` } : {}),
|
|
2020
|
+
}));
|
|
2021
|
+
// Detect mismatches: consumer accesses keys not in response shape
|
|
2022
|
+
const mismatches = [];
|
|
2023
|
+
if (allKnownKeys.size > 0) {
|
|
2024
|
+
for (const c of r.consumers) {
|
|
2025
|
+
if (!c.accessedKeys)
|
|
2026
|
+
continue;
|
|
2027
|
+
const isMultiFetch = (c.fetchCount ?? 1) > 1;
|
|
2028
|
+
for (const key of c.accessedKeys) {
|
|
2029
|
+
if (!allKnownKeys.has(key)) {
|
|
2030
|
+
mismatches.push({
|
|
2031
|
+
consumer: c.filePath,
|
|
2032
|
+
field: key,
|
|
2033
|
+
reason: 'accessed but not in response shape',
|
|
2034
|
+
confidence: isMultiFetch ? 'low' : 'high',
|
|
2035
|
+
});
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
const flows = flowMap.get(r.id) || [];
|
|
2041
|
+
const consumerCount = r.consumers.length;
|
|
2042
|
+
// Risk level heuristic
|
|
2043
|
+
let riskLevel;
|
|
2044
|
+
if (consumerCount >= 10) {
|
|
2045
|
+
riskLevel = 'HIGH';
|
|
2046
|
+
}
|
|
2047
|
+
else if (consumerCount >= 4) {
|
|
2048
|
+
riskLevel = 'MEDIUM';
|
|
2049
|
+
}
|
|
2050
|
+
else {
|
|
2051
|
+
riskLevel = 'LOW';
|
|
2052
|
+
}
|
|
2053
|
+
// Bump up one level if mismatches exist
|
|
2054
|
+
if (mismatches.length > 0) {
|
|
2055
|
+
if (riskLevel === 'LOW')
|
|
2056
|
+
riskLevel = 'MEDIUM';
|
|
2057
|
+
else if (riskLevel === 'MEDIUM')
|
|
2058
|
+
riskLevel = 'HIGH';
|
|
2059
|
+
}
|
|
2060
|
+
const warning = consumerCount > 0
|
|
2061
|
+
? `Changing response shape will affect ${consumerCount} component${consumerCount === 1 ? '' : 's'}`
|
|
2062
|
+
: undefined;
|
|
2063
|
+
// Flag when middleware was detected but handler exports multiple HTTP methods
|
|
2064
|
+
// (middleware chain may only reflect one export)
|
|
2065
|
+
const middlewareArr = r.middleware || [];
|
|
2066
|
+
const handlerRouteCount = r.filePath ? (routeCountByHandler.get(r.filePath) ?? 1) : 1;
|
|
2067
|
+
const middlewarePartial = middlewareArr.length > 0 && handlerRouteCount > 1;
|
|
2068
|
+
return {
|
|
2069
|
+
route: r.name,
|
|
2070
|
+
handler: r.filePath,
|
|
2071
|
+
responseShape: {
|
|
2072
|
+
success: responseKeys,
|
|
2073
|
+
error: errorKeys,
|
|
2074
|
+
},
|
|
2075
|
+
middleware: middlewareArr,
|
|
2076
|
+
...(middlewarePartial ? {
|
|
2077
|
+
middlewareDetection: 'partial',
|
|
2078
|
+
middlewareNote: 'Middleware captured from first HTTP method export only — other methods in this handler may use different middleware chains.',
|
|
2079
|
+
} : {}),
|
|
2080
|
+
consumers,
|
|
2081
|
+
...(mismatches.length > 0 ? { mismatches } : {}),
|
|
2082
|
+
executionFlows: flows,
|
|
2083
|
+
impactSummary: {
|
|
2084
|
+
directConsumers: consumerCount,
|
|
2085
|
+
affectedFlows: flows.length,
|
|
2086
|
+
riskLevel,
|
|
2087
|
+
...(warning ? { warning } : {}),
|
|
2088
|
+
},
|
|
2089
|
+
};
|
|
2090
|
+
});
|
|
2091
|
+
// If a single route was targeted, return it directly (not wrapped in array)
|
|
2092
|
+
if (results.length === 1) {
|
|
2093
|
+
return results[0];
|
|
2094
|
+
}
|
|
2095
|
+
return { routes: results, total: results.length };
|
|
2096
|
+
}
|
|
1460
2097
|
// ─── Direct Graph Queries (for resources.ts) ────────────────────
|
|
1461
2098
|
/**
|
|
1462
2099
|
* Query clusters (communities) directly from graph.
|