gitnexus 1.4.7 → 1.4.9
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 +29 -1
- package/dist/cli/ai-context.d.ts +1 -1
- package/dist/cli/ai-context.js +1 -1
- package/dist/cli/analyze.d.ts +2 -0
- package/dist/cli/analyze.js +54 -21
- package/dist/cli/index-repo.d.ts +15 -0
- package/dist/cli/index-repo.js +115 -0
- package/dist/cli/index.js +13 -3
- package/dist/cli/setup.js +90 -10
- package/dist/cli/wiki.d.ts +4 -0
- package/dist/cli/wiki.js +174 -53
- package/dist/config/supported-languages.d.ts +33 -1
- package/dist/config/supported-languages.js +32 -0
- package/dist/core/embeddings/embedder.d.ts +6 -1
- package/dist/core/embeddings/embedder.js +65 -5
- package/dist/core/embeddings/embedding-pipeline.js +11 -9
- package/dist/core/embeddings/http-client.d.ts +31 -0
- package/dist/core/embeddings/http-client.js +179 -0
- package/dist/core/embeddings/index.d.ts +1 -0
- package/dist/core/embeddings/index.js +1 -0
- package/dist/core/embeddings/types.d.ts +1 -1
- package/dist/core/graph/graph.js +9 -1
- package/dist/core/graph/types.d.ts +11 -2
- package/dist/core/ingestion/call-processor.d.ts +66 -2
- package/dist/core/ingestion/call-processor.js +650 -30
- package/dist/core/ingestion/call-routing.d.ts +9 -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 +52 -28
- 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 +97 -2
- package/dist/core/ingestion/framework-detection.js +114 -14
- package/dist/core/ingestion/heritage-processor.js +62 -66
- package/dist/core/ingestion/import-processor.d.ts +9 -10
- package/dist/core/ingestion/import-processor.js +150 -196
- 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 +10 -1
- package/dist/core/ingestion/import-resolvers/jvm.js +159 -0
- package/dist/core/ingestion/import-resolvers/php.d.ts +25 -0
- package/dist/core/ingestion/import-resolvers/php.js +80 -0
- 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 +2 -0
- package/dist/core/ingestion/{resolvers → import-resolvers}/utils.js +7 -0
- package/dist/core/ingestion/language-config.d.ts +6 -0
- package/dist/core/ingestion/language-config.js +13 -0
- 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/markdown-processor.d.ts +17 -0
- package/dist/core/ingestion/markdown-processor.js +124 -0
- package/dist/core/ingestion/mro-processor.js +22 -18
- package/dist/core/ingestion/named-binding-processor.d.ts +18 -0
- 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 +6 -2
- package/dist/core/ingestion/parsing-processor.js +125 -85
- package/dist/core/ingestion/pipeline.d.ts +10 -0
- package/dist/core/ingestion/pipeline.js +1235 -317
- package/dist/core/ingestion/resolution-context.d.ts +5 -0
- package/dist/core/ingestion/resolution-context.js +8 -5
- 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/symbol-table.d.ts +16 -0
- package/dist/core/ingestion/symbol-table.js +20 -6
- package/dist/core/ingestion/tree-sitter-queries.d.ts +10 -9
- package/dist/core/ingestion/tree-sitter-queries.js +274 -11
- package/dist/core/ingestion/type-env.d.ts +42 -18
- package/dist/core/ingestion/type-env.js +481 -106
- package/dist/core/ingestion/type-extractors/c-cpp.d.ts +5 -0
- package/dist/core/ingestion/type-extractors/c-cpp.js +119 -0
- package/dist/core/ingestion/type-extractors/csharp.js +149 -16
- 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 +169 -66
- package/dist/core/ingestion/type-extractors/rust.js +35 -1
- package/dist/core/ingestion/type-extractors/shared.d.ts +1 -15
- package/dist/core/ingestion/type-extractors/shared.js +14 -112
- package/dist/core/ingestion/type-extractors/swift.js +338 -7
- package/dist/core/ingestion/type-extractors/types.d.ts +40 -8
- package/dist/core/ingestion/type-extractors/typescript.js +141 -9
- package/dist/core/ingestion/utils/ast-helpers.d.ts +83 -0
- package/dist/core/ingestion/utils/ast-helpers.js +817 -0
- package/dist/core/ingestion/utils/call-analysis.d.ts +73 -0
- package/dist/core/ingestion/utils/call-analysis.js +527 -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 +55 -5
- package/dist/core/ingestion/workers/parse-worker.js +415 -225
- package/dist/core/lbug/csv-generator.js +51 -1
- package/dist/core/lbug/lbug-adapter.d.ts +10 -0
- package/dist/core/lbug/lbug-adapter.js +75 -4
- package/dist/core/lbug/schema.d.ts +8 -4
- package/dist/core/lbug/schema.js +65 -4
- 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/embedder.js +11 -3
- package/dist/mcp/core/lbug-adapter.d.ts +5 -0
- package/dist/mcp/core/lbug-adapter.js +23 -2
- package/dist/mcp/local/local-backend.d.ts +38 -5
- package/dist/mcp/local/local-backend.js +804 -63
- package/dist/mcp/resources.js +2 -0
- package/dist/mcp/tools.js +73 -4
- package/dist/server/api.d.ts +19 -1
- package/dist/server/api.js +66 -6
- package/dist/storage/git.d.ts +12 -0
- package/dist/storage/git.js +21 -0
- package/dist/storage/repo-manager.d.ts +3 -0
- package/package.json +25 -16
- package/dist/core/ingestion/named-binding-extraction.d.ts +0 -61
- package/dist/core/ingestion/named-binding-extraction.js +0 -363
- package/dist/core/ingestion/resolvers/index.d.ts +0 -18
- package/dist/core/ingestion/resolvers/index.js +0 -13
- package/dist/core/ingestion/resolvers/jvm.js +0 -87
- package/dist/core/ingestion/resolvers/php.d.ts +0 -15
- package/dist/core/ingestion/resolvers/php.js +0 -35
- 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 -138
- package/dist/core/ingestion/utils.js +0 -1290
- package/scripts/patch-tree-sitter-swift.cjs +0 -74
|
@@ -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,15 +36,46 @@ 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']);
|
|
41
|
-
/**
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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']);
|
|
44
|
+
/**
|
|
45
|
+
* Per-relation-type confidence floor for impact analysis.
|
|
46
|
+
*
|
|
47
|
+
* When the graph stores a relation with a confidence value, that stored
|
|
48
|
+
* value is used as-is (it reflects resolution-tier accuracy from analysis
|
|
49
|
+
* time). This map provides the floor for each edge type when no stored
|
|
50
|
+
* confidence is available, and is also used for display / tooltip hints.
|
|
51
|
+
*
|
|
52
|
+
* Rationale:
|
|
53
|
+
* CALLS / IMPORTS – direct, strongly-typed references → 0.9
|
|
54
|
+
* EXTENDS – class hierarchy, statically verifiable → 0.85
|
|
55
|
+
* IMPLEMENTS – interface contract, statically verifiable → 0.85
|
|
56
|
+
* OVERRIDES – method override, statically verifiable → 0.85
|
|
57
|
+
* HAS_METHOD – structural containment → 0.95
|
|
58
|
+
* HAS_PROPERTY – structural containment → 0.95
|
|
59
|
+
* ACCESSES – field read/write, may be indirect → 0.8
|
|
60
|
+
* CONTAINS – folder/file containment → 0.95
|
|
61
|
+
* (unknown type) – conservative fallback → 0.5
|
|
62
|
+
*/
|
|
63
|
+
export const IMPACT_RELATION_CONFIDENCE = {
|
|
64
|
+
CALLS: 0.9,
|
|
65
|
+
IMPORTS: 0.9,
|
|
66
|
+
EXTENDS: 0.85,
|
|
67
|
+
IMPLEMENTS: 0.85,
|
|
68
|
+
OVERRIDES: 0.85,
|
|
69
|
+
HAS_METHOD: 0.95,
|
|
70
|
+
HAS_PROPERTY: 0.95,
|
|
71
|
+
ACCESSES: 0.8,
|
|
72
|
+
CONTAINS: 0.95,
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* Return the confidence floor for a given relation type.
|
|
76
|
+
* Falls back to 0.5 for unknown types so they are not silently elevated.
|
|
77
|
+
*/
|
|
78
|
+
const confidenceForRelType = (relType) => IMPACT_RELATION_CONFIDENCE[relType ?? ''] ?? 0.5;
|
|
47
79
|
/** Structured error logging for query failures — replaces empty catch blocks */
|
|
48
80
|
function logQueryError(context, err) {
|
|
49
81
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -53,6 +85,8 @@ export class LocalBackend {
|
|
|
53
85
|
repos = new Map();
|
|
54
86
|
contextCache = new Map();
|
|
55
87
|
initializedRepos = new Set();
|
|
88
|
+
reinitPromises = new Map();
|
|
89
|
+
lastStalenessCheck = new Map();
|
|
56
90
|
// ─── Initialization ──────────────────────────────────────────────
|
|
57
91
|
/**
|
|
58
92
|
* Initialize from the global registry.
|
|
@@ -195,12 +229,53 @@ export class LocalBackend {
|
|
|
195
229
|
}
|
|
196
230
|
// ─── Lazy LadybugDB Init ────────────────────────────────────────────
|
|
197
231
|
async ensureInitialized(repoId) {
|
|
198
|
-
//
|
|
199
|
-
|
|
200
|
-
|
|
232
|
+
// If a reinit is already in progress for this repo, wait for it
|
|
233
|
+
const pending = this.reinitPromises.get(repoId);
|
|
234
|
+
if (pending)
|
|
235
|
+
return pending;
|
|
201
236
|
const handle = this.repos.get(repoId);
|
|
202
237
|
if (!handle)
|
|
203
238
|
throw new Error(`Unknown repo: ${repoId}`);
|
|
239
|
+
// Check if the index was rebuilt since we opened the connection (#297).
|
|
240
|
+
// Throttle staleness checks to at most once per 5 seconds per repo to
|
|
241
|
+
// avoid an fs.readFile round-trip on every tool invocation.
|
|
242
|
+
if (this.initializedRepos.has(repoId) && isLbugReady(repoId)) {
|
|
243
|
+
const now = Date.now();
|
|
244
|
+
const lastCheck = this.lastStalenessCheck.get(repoId) ?? 0;
|
|
245
|
+
if (now - lastCheck < 5000)
|
|
246
|
+
return; // Checked recently — skip
|
|
247
|
+
this.lastStalenessCheck.set(repoId, now);
|
|
248
|
+
try {
|
|
249
|
+
const metaPath = path.join(handle.storagePath, 'meta.json');
|
|
250
|
+
const metaRaw = await fs.readFile(metaPath, 'utf-8');
|
|
251
|
+
const meta = JSON.parse(metaRaw);
|
|
252
|
+
if (meta.indexedAt && meta.indexedAt !== handle.indexedAt) {
|
|
253
|
+
// Index was rebuilt — close stale connection and re-init.
|
|
254
|
+
// Wrap in reinitPromises to prevent TOCTOU race where concurrent
|
|
255
|
+
// callers both detect staleness and double-close the pool.
|
|
256
|
+
const reinit = (async () => {
|
|
257
|
+
try {
|
|
258
|
+
await closeLbug(repoId);
|
|
259
|
+
this.initializedRepos.delete(repoId);
|
|
260
|
+
handle.indexedAt = meta.indexedAt;
|
|
261
|
+
await initLbug(repoId, handle.lbugPath);
|
|
262
|
+
this.initializedRepos.add(repoId);
|
|
263
|
+
}
|
|
264
|
+
finally {
|
|
265
|
+
this.reinitPromises.delete(repoId);
|
|
266
|
+
}
|
|
267
|
+
})();
|
|
268
|
+
this.reinitPromises.set(repoId, reinit);
|
|
269
|
+
return reinit;
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
return; // Pool is current
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
return; // Can't read meta — assume pool is fine
|
|
277
|
+
}
|
|
278
|
+
}
|
|
204
279
|
try {
|
|
205
280
|
await initLbug(repoId, handle.lbugPath);
|
|
206
281
|
this.initializedRepos.add(repoId);
|
|
@@ -268,6 +343,14 @@ export class LocalBackend {
|
|
|
268
343
|
return this.context(repo, { name: params?.name, ...params });
|
|
269
344
|
case 'overview':
|
|
270
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);
|
|
271
354
|
default:
|
|
272
355
|
throw new Error(`Unknown tool: ${method}`);
|
|
273
356
|
}
|
|
@@ -292,10 +375,12 @@ export class LocalBackend {
|
|
|
292
375
|
const searchQuery = params.query.trim();
|
|
293
376
|
// Step 1: Run hybrid search to get matching symbols
|
|
294
377
|
const searchLimit = processLimit * maxSymbolsPerProcess; // fetch enough raw results
|
|
295
|
-
const [
|
|
378
|
+
const [bm25SearchResult, semanticResults] = await Promise.all([
|
|
296
379
|
this.bm25Search(repo, searchQuery, searchLimit),
|
|
297
380
|
this.semanticSearch(repo, searchQuery, searchLimit),
|
|
298
381
|
]);
|
|
382
|
+
const bm25Results = bm25SearchResult.results;
|
|
383
|
+
const ftsUsed = bm25SearchResult.ftsUsed;
|
|
299
384
|
// Merge via reciprocal rank fusion
|
|
300
385
|
const scoreMap = new Map();
|
|
301
386
|
for (let i = 0; i < bm25Results.length; i++) {
|
|
@@ -462,6 +547,7 @@ export class LocalBackend {
|
|
|
462
547
|
processes,
|
|
463
548
|
process_symbols: dedupedSymbols,
|
|
464
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.' }),
|
|
465
551
|
};
|
|
466
552
|
}
|
|
467
553
|
/**
|
|
@@ -475,8 +561,9 @@ export class LocalBackend {
|
|
|
475
561
|
}
|
|
476
562
|
catch (err) {
|
|
477
563
|
console.error('GitNexus: BM25/FTS search failed (FTS indexes may not exist) -', err.message);
|
|
478
|
-
return [];
|
|
564
|
+
return { results: [], ftsUsed: false };
|
|
479
565
|
}
|
|
566
|
+
const ftsUsed = bm25Results.length === 0 || (bm25Results[0]?.ftsUsed !== false);
|
|
480
567
|
const results = [];
|
|
481
568
|
for (const bm25Result of bm25Results) {
|
|
482
569
|
const fullPath = bm25Result.filePath;
|
|
@@ -520,7 +607,7 @@ export class LocalBackend {
|
|
|
520
607
|
});
|
|
521
608
|
}
|
|
522
609
|
}
|
|
523
|
-
return results;
|
|
610
|
+
return { results, ftsUsed };
|
|
524
611
|
}
|
|
525
612
|
/**
|
|
526
613
|
* Semantic vector search helper
|
|
@@ -593,7 +680,7 @@ export class LocalBackend {
|
|
|
593
680
|
return { error: 'LadybugDB not ready. Index may be corrupted.' };
|
|
594
681
|
}
|
|
595
682
|
// Block write operations (defense-in-depth — DB is already read-only)
|
|
596
|
-
if (
|
|
683
|
+
if (isWriteQuery(params.query)) {
|
|
597
684
|
return { error: 'Write operations (CREATE, DELETE, SET, MERGE, REMOVE, DROP, ALTER, COPY, DETACH) are not allowed. The knowledge graph is read-only.' };
|
|
598
685
|
}
|
|
599
686
|
try {
|
|
@@ -769,6 +856,47 @@ export class LocalBackend {
|
|
|
769
856
|
return { error: `Symbol '${name || uid}' not found` };
|
|
770
857
|
}
|
|
771
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
|
+
}
|
|
772
900
|
if (symbols.length > 1 && !uid) {
|
|
773
901
|
return {
|
|
774
902
|
status: 'ambiguous',
|
|
@@ -786,12 +914,74 @@ export class LocalBackend {
|
|
|
786
914
|
const sym = symbols[0];
|
|
787
915
|
const symId = sym.id || sym[0];
|
|
788
916
|
// Categorized incoming refs
|
|
789
|
-
|
|
917
|
+
let incomingRows = await executeParameterized(repo.id, `
|
|
790
918
|
MATCH (caller)-[r:CodeRelation]->(n {id: $symId})
|
|
791
919
|
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'HAS_PROPERTY', 'OVERRIDES', 'ACCESSES']
|
|
792
920
|
RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind
|
|
793
921
|
LIMIT 30
|
|
794
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
|
+
}
|
|
795
985
|
// Categorized outgoing refs
|
|
796
986
|
const outgoingRows = await executeParameterized(repo.id, `
|
|
797
987
|
MATCH (n {id: $symId})-[r:CodeRelation]->(target)
|
|
@@ -832,7 +1022,7 @@ export class LocalBackend {
|
|
|
832
1022
|
symbol: {
|
|
833
1023
|
uid: sym.id || sym[0],
|
|
834
1024
|
name: sym.name || sym[1],
|
|
835
|
-
kind: sym.type || sym[2],
|
|
1025
|
+
kind: isClassLike ? (resolvedLabel || 'Class') : (sym.type || sym[2]),
|
|
836
1026
|
filePath: sym.filePath || sym[3],
|
|
837
1027
|
startLine: sym.startLine || sym[4],
|
|
838
1028
|
endLine: sym.endLine || sym[5],
|
|
@@ -1216,20 +1406,100 @@ export class LocalBackend {
|
|
|
1216
1406
|
const minConfidence = params.minConfidence ?? 0;
|
|
1217
1407
|
const relTypeFilter = relationTypes.map(t => `'${t}'`).join(', ');
|
|
1218
1408
|
const confidenceFilter = minConfidence > 0 ? ` AND r.confidence >= ${minConfidence}` : '';
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
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)
|
|
1226
1454
|
return { error: `Target '${target}' not found` };
|
|
1227
|
-
const sym = targets[0];
|
|
1228
1455
|
const symId = sym.id || sym[0];
|
|
1229
1456
|
const impacted = [];
|
|
1230
1457
|
const visited = new Set([symId]);
|
|
1231
1458
|
let frontier = [symId];
|
|
1232
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
|
+
}
|
|
1233
1503
|
for (let depth = 1; depth <= maxDepth && frontier.length > 0; depth++) {
|
|
1234
1504
|
const nextFrontier = [];
|
|
1235
1505
|
// Batch frontier nodes into a single Cypher query per depth level
|
|
@@ -1247,14 +1517,21 @@ export class LocalBackend {
|
|
|
1247
1517
|
if (!visited.has(relId)) {
|
|
1248
1518
|
visited.add(relId);
|
|
1249
1519
|
nextFrontier.push(relId);
|
|
1520
|
+
const storedConfidence = rel.confidence ?? rel[6];
|
|
1521
|
+
const relationType = rel.relType || rel[5];
|
|
1522
|
+
// Prefer the stored confidence from the graph (set at analysis time);
|
|
1523
|
+
// fall back to the per-type floor for edges without a stored value.
|
|
1524
|
+
const effectiveConfidence = typeof storedConfidence === 'number' && storedConfidence > 0
|
|
1525
|
+
? storedConfidence
|
|
1526
|
+
: confidenceForRelType(relationType);
|
|
1250
1527
|
impacted.push({
|
|
1251
1528
|
depth,
|
|
1252
1529
|
id: relId,
|
|
1253
1530
|
name: rel.name || rel[2],
|
|
1254
1531
|
type: rel.type || rel[3],
|
|
1255
1532
|
filePath,
|
|
1256
|
-
relationType
|
|
1257
|
-
confidence:
|
|
1533
|
+
relationType,
|
|
1534
|
+
confidence: effectiveConfidence,
|
|
1258
1535
|
});
|
|
1259
1536
|
}
|
|
1260
1537
|
}
|
|
@@ -1279,43 +1556,201 @@ export class LocalBackend {
|
|
|
1279
1556
|
let affectedProcesses = [];
|
|
1280
1557
|
let affectedModules = [];
|
|
1281
1558
|
if (impacted.length > 0) {
|
|
1282
|
-
const
|
|
1283
|
-
|
|
1284
|
-
//
|
|
1285
|
-
const
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
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, `
|
|
1721
|
+
MATCH (s)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
|
|
1722
|
+
WHERE s.id IN $ids
|
|
1723
|
+
RETURN DISTINCT c.heuristicLabel AS name
|
|
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);
|
|
1738
|
+
}
|
|
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]));
|
|
1313
1747
|
affectedModules = moduleRows.map((r) => {
|
|
1314
|
-
const name = r.name
|
|
1748
|
+
const name = r.name ?? r[0];
|
|
1749
|
+
const hits = r.hits ?? r[1] ?? 0;
|
|
1315
1750
|
return {
|
|
1316
1751
|
name,
|
|
1317
|
-
hits
|
|
1318
|
-
impact:
|
|
1752
|
+
hits,
|
|
1753
|
+
impact: directModuleNameSet.has(name) ? 'direct' : 'indirect',
|
|
1319
1754
|
};
|
|
1320
1755
|
});
|
|
1321
1756
|
}
|
|
@@ -1336,8 +1771,8 @@ export class LocalBackend {
|
|
|
1336
1771
|
target: {
|
|
1337
1772
|
id: symId,
|
|
1338
1773
|
name: sym.name || sym[1],
|
|
1339
|
-
type:
|
|
1340
|
-
filePath: sym.filePath || sym[
|
|
1774
|
+
type: symType,
|
|
1775
|
+
filePath: sym.filePath || sym[2],
|
|
1341
1776
|
},
|
|
1342
1777
|
direction,
|
|
1343
1778
|
impactedCount: impacted.length,
|
|
@@ -1353,6 +1788,312 @@ export class LocalBackend {
|
|
|
1353
1788
|
byDepth: grouped,
|
|
1354
1789
|
};
|
|
1355
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
|
+
}
|
|
1356
2097
|
// ─── Direct Graph Queries (for resources.ts) ────────────────────
|
|
1357
2098
|
/**
|
|
1358
2099
|
* Query clusters (communities) directly from graph.
|