@duytransipher/gitnexus 1.4.6-sipher.0
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/LICENSE +73 -0
- package/README.md +261 -0
- package/dist/cli/ai-context.d.ts +23 -0
- package/dist/cli/ai-context.js +265 -0
- package/dist/cli/analyze.d.ts +12 -0
- package/dist/cli/analyze.js +345 -0
- package/dist/cli/augment.d.ts +13 -0
- package/dist/cli/augment.js +33 -0
- package/dist/cli/clean.d.ts +10 -0
- package/dist/cli/clean.js +60 -0
- package/dist/cli/eval-server.d.ts +37 -0
- package/dist/cli/eval-server.js +389 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +137 -0
- package/dist/cli/lazy-action.d.ts +6 -0
- package/dist/cli/lazy-action.js +18 -0
- package/dist/cli/list.d.ts +6 -0
- package/dist/cli/list.js +30 -0
- package/dist/cli/mcp.d.ts +8 -0
- package/dist/cli/mcp.js +36 -0
- package/dist/cli/serve.d.ts +4 -0
- package/dist/cli/serve.js +6 -0
- package/dist/cli/setup.d.ts +8 -0
- package/dist/cli/setup.js +367 -0
- package/dist/cli/sipher-patched.d.ts +2 -0
- package/dist/cli/sipher-patched.js +77 -0
- package/dist/cli/skill-gen.d.ts +26 -0
- package/dist/cli/skill-gen.js +549 -0
- package/dist/cli/status.d.ts +6 -0
- package/dist/cli/status.js +36 -0
- package/dist/cli/tool.d.ts +60 -0
- package/dist/cli/tool.js +180 -0
- package/dist/cli/wiki.d.ts +15 -0
- package/dist/cli/wiki.js +365 -0
- package/dist/config/ignore-service.d.ts +26 -0
- package/dist/config/ignore-service.js +284 -0
- package/dist/config/supported-languages.d.ts +15 -0
- package/dist/config/supported-languages.js +16 -0
- package/dist/core/augmentation/engine.d.ts +26 -0
- package/dist/core/augmentation/engine.js +240 -0
- package/dist/core/embeddings/embedder.d.ts +60 -0
- package/dist/core/embeddings/embedder.js +251 -0
- package/dist/core/embeddings/embedding-pipeline.d.ts +51 -0
- package/dist/core/embeddings/embedding-pipeline.js +356 -0
- package/dist/core/embeddings/index.d.ts +9 -0
- package/dist/core/embeddings/index.js +9 -0
- package/dist/core/embeddings/text-generator.d.ts +24 -0
- package/dist/core/embeddings/text-generator.js +182 -0
- package/dist/core/embeddings/types.d.ts +87 -0
- package/dist/core/embeddings/types.js +32 -0
- package/dist/core/graph/graph.d.ts +2 -0
- package/dist/core/graph/graph.js +66 -0
- package/dist/core/graph/types.d.ts +66 -0
- package/dist/core/graph/types.js +1 -0
- package/dist/core/ingestion/ast-cache.d.ts +11 -0
- package/dist/core/ingestion/ast-cache.js +35 -0
- package/dist/core/ingestion/call-processor.d.ts +23 -0
- package/dist/core/ingestion/call-processor.js +793 -0
- package/dist/core/ingestion/call-routing.d.ts +68 -0
- package/dist/core/ingestion/call-routing.js +129 -0
- package/dist/core/ingestion/cluster-enricher.d.ts +38 -0
- package/dist/core/ingestion/cluster-enricher.js +170 -0
- package/dist/core/ingestion/community-processor.d.ts +39 -0
- package/dist/core/ingestion/community-processor.js +312 -0
- package/dist/core/ingestion/constants.d.ts +16 -0
- package/dist/core/ingestion/constants.js +16 -0
- package/dist/core/ingestion/entry-point-scoring.d.ts +40 -0
- package/dist/core/ingestion/entry-point-scoring.js +353 -0
- package/dist/core/ingestion/export-detection.d.ts +18 -0
- package/dist/core/ingestion/export-detection.js +231 -0
- package/dist/core/ingestion/filesystem-walker.d.ts +28 -0
- package/dist/core/ingestion/filesystem-walker.js +81 -0
- package/dist/core/ingestion/framework-detection.d.ts +54 -0
- package/dist/core/ingestion/framework-detection.js +411 -0
- package/dist/core/ingestion/heritage-processor.d.ts +28 -0
- package/dist/core/ingestion/heritage-processor.js +251 -0
- package/dist/core/ingestion/import-processor.d.ts +34 -0
- package/dist/core/ingestion/import-processor.js +398 -0
- package/dist/core/ingestion/language-config.d.ts +46 -0
- package/dist/core/ingestion/language-config.js +167 -0
- package/dist/core/ingestion/mro-processor.d.ts +45 -0
- package/dist/core/ingestion/mro-processor.js +369 -0
- package/dist/core/ingestion/named-binding-extraction.d.ts +61 -0
- package/dist/core/ingestion/named-binding-extraction.js +363 -0
- package/dist/core/ingestion/parsing-processor.d.ts +19 -0
- package/dist/core/ingestion/parsing-processor.js +315 -0
- package/dist/core/ingestion/pipeline.d.ts +6 -0
- package/dist/core/ingestion/pipeline.js +401 -0
- package/dist/core/ingestion/process-processor.d.ts +51 -0
- package/dist/core/ingestion/process-processor.js +315 -0
- package/dist/core/ingestion/resolution-context.d.ts +53 -0
- package/dist/core/ingestion/resolution-context.js +132 -0
- package/dist/core/ingestion/resolvers/csharp.d.ts +22 -0
- package/dist/core/ingestion/resolvers/csharp.js +109 -0
- package/dist/core/ingestion/resolvers/go.d.ts +19 -0
- package/dist/core/ingestion/resolvers/go.js +42 -0
- package/dist/core/ingestion/resolvers/index.d.ts +18 -0
- package/dist/core/ingestion/resolvers/index.js +13 -0
- package/dist/core/ingestion/resolvers/jvm.d.ts +23 -0
- package/dist/core/ingestion/resolvers/jvm.js +87 -0
- package/dist/core/ingestion/resolvers/php.d.ts +15 -0
- package/dist/core/ingestion/resolvers/php.js +35 -0
- package/dist/core/ingestion/resolvers/python.d.ts +19 -0
- package/dist/core/ingestion/resolvers/python.js +52 -0
- package/dist/core/ingestion/resolvers/ruby.d.ts +12 -0
- package/dist/core/ingestion/resolvers/ruby.js +15 -0
- package/dist/core/ingestion/resolvers/rust.d.ts +15 -0
- package/dist/core/ingestion/resolvers/rust.js +73 -0
- package/dist/core/ingestion/resolvers/standard.d.ts +28 -0
- package/dist/core/ingestion/resolvers/standard.js +123 -0
- package/dist/core/ingestion/resolvers/utils.d.ts +33 -0
- package/dist/core/ingestion/resolvers/utils.js +122 -0
- package/dist/core/ingestion/structure-processor.d.ts +2 -0
- package/dist/core/ingestion/structure-processor.js +36 -0
- package/dist/core/ingestion/symbol-table.d.ts +63 -0
- package/dist/core/ingestion/symbol-table.js +85 -0
- package/dist/core/ingestion/tree-sitter-queries.d.ts +15 -0
- package/dist/core/ingestion/tree-sitter-queries.js +888 -0
- package/dist/core/ingestion/type-env.d.ts +49 -0
- package/dist/core/ingestion/type-env.js +613 -0
- package/dist/core/ingestion/type-extractors/c-cpp.d.ts +2 -0
- package/dist/core/ingestion/type-extractors/c-cpp.js +385 -0
- package/dist/core/ingestion/type-extractors/csharp.d.ts +2 -0
- package/dist/core/ingestion/type-extractors/csharp.js +383 -0
- package/dist/core/ingestion/type-extractors/go.d.ts +2 -0
- package/dist/core/ingestion/type-extractors/go.js +467 -0
- package/dist/core/ingestion/type-extractors/index.d.ts +22 -0
- package/dist/core/ingestion/type-extractors/index.js +31 -0
- package/dist/core/ingestion/type-extractors/jvm.d.ts +3 -0
- package/dist/core/ingestion/type-extractors/jvm.js +681 -0
- package/dist/core/ingestion/type-extractors/php.d.ts +2 -0
- package/dist/core/ingestion/type-extractors/php.js +549 -0
- package/dist/core/ingestion/type-extractors/python.d.ts +2 -0
- package/dist/core/ingestion/type-extractors/python.js +455 -0
- package/dist/core/ingestion/type-extractors/ruby.d.ts +2 -0
- package/dist/core/ingestion/type-extractors/ruby.js +389 -0
- package/dist/core/ingestion/type-extractors/rust.d.ts +2 -0
- package/dist/core/ingestion/type-extractors/rust.js +456 -0
- package/dist/core/ingestion/type-extractors/shared.d.ts +145 -0
- package/dist/core/ingestion/type-extractors/shared.js +810 -0
- package/dist/core/ingestion/type-extractors/swift.d.ts +2 -0
- package/dist/core/ingestion/type-extractors/swift.js +137 -0
- package/dist/core/ingestion/type-extractors/types.d.ts +127 -0
- package/dist/core/ingestion/type-extractors/types.js +1 -0
- package/dist/core/ingestion/type-extractors/typescript.d.ts +2 -0
- package/dist/core/ingestion/type-extractors/typescript.js +494 -0
- package/dist/core/ingestion/utils.d.ts +138 -0
- package/dist/core/ingestion/utils.js +1290 -0
- package/dist/core/ingestion/workers/parse-worker.d.ts +122 -0
- package/dist/core/ingestion/workers/parse-worker.js +1126 -0
- package/dist/core/ingestion/workers/worker-pool.d.ts +16 -0
- package/dist/core/ingestion/workers/worker-pool.js +128 -0
- package/dist/core/lbug/csv-generator.d.ts +33 -0
- package/dist/core/lbug/csv-generator.js +366 -0
- package/dist/core/lbug/lbug-adapter.d.ts +103 -0
- package/dist/core/lbug/lbug-adapter.js +769 -0
- package/dist/core/lbug/schema.d.ts +53 -0
- package/dist/core/lbug/schema.js +430 -0
- package/dist/core/search/bm25-index.d.ts +23 -0
- package/dist/core/search/bm25-index.js +96 -0
- package/dist/core/search/hybrid-search.d.ts +49 -0
- package/dist/core/search/hybrid-search.js +118 -0
- package/dist/core/tree-sitter/parser-loader.d.ts +5 -0
- package/dist/core/tree-sitter/parser-loader.js +63 -0
- package/dist/core/wiki/generator.d.ts +120 -0
- package/dist/core/wiki/generator.js +939 -0
- package/dist/core/wiki/graph-queries.d.ts +80 -0
- package/dist/core/wiki/graph-queries.js +238 -0
- package/dist/core/wiki/html-viewer.d.ts +10 -0
- package/dist/core/wiki/html-viewer.js +297 -0
- package/dist/core/wiki/llm-client.d.ts +43 -0
- package/dist/core/wiki/llm-client.js +186 -0
- package/dist/core/wiki/prompts.d.ts +53 -0
- package/dist/core/wiki/prompts.js +174 -0
- package/dist/lib/utils.d.ts +1 -0
- package/dist/lib/utils.js +3 -0
- package/dist/mcp/compatible-stdio-transport.d.ts +25 -0
- package/dist/mcp/compatible-stdio-transport.js +200 -0
- package/dist/mcp/core/embedder.d.ts +27 -0
- package/dist/mcp/core/embedder.js +108 -0
- package/dist/mcp/core/lbug-adapter.d.ts +57 -0
- package/dist/mcp/core/lbug-adapter.js +455 -0
- package/dist/mcp/local/local-backend.d.ts +181 -0
- package/dist/mcp/local/local-backend.js +1722 -0
- package/dist/mcp/resources.d.ts +31 -0
- package/dist/mcp/resources.js +411 -0
- package/dist/mcp/server.d.ts +23 -0
- package/dist/mcp/server.js +296 -0
- package/dist/mcp/staleness.d.ts +15 -0
- package/dist/mcp/staleness.js +29 -0
- package/dist/mcp/tools.d.ts +24 -0
- package/dist/mcp/tools.js +292 -0
- package/dist/server/api.d.ts +10 -0
- package/dist/server/api.js +344 -0
- package/dist/server/mcp-http.d.ts +13 -0
- package/dist/server/mcp-http.js +100 -0
- package/dist/storage/git.d.ts +6 -0
- package/dist/storage/git.js +35 -0
- package/dist/storage/repo-manager.d.ts +138 -0
- package/dist/storage/repo-manager.js +299 -0
- package/dist/types/pipeline.d.ts +32 -0
- package/dist/types/pipeline.js +18 -0
- package/dist/unreal/bridge.d.ts +4 -0
- package/dist/unreal/bridge.js +113 -0
- package/dist/unreal/config.d.ts +6 -0
- package/dist/unreal/config.js +55 -0
- package/dist/unreal/types.d.ts +105 -0
- package/dist/unreal/types.js +1 -0
- package/hooks/claude/gitnexus-hook.cjs +238 -0
- package/hooks/claude/pre-tool-use.sh +79 -0
- package/hooks/claude/session-start.sh +42 -0
- package/package.json +100 -0
- package/scripts/ensure-cli-executable.cjs +21 -0
- package/scripts/patch-tree-sitter-swift.cjs +74 -0
- package/scripts/setup-unreal-gitnexus.ps1 +191 -0
- package/skills/gitnexus-cli.md +82 -0
- package/skills/gitnexus-debugging.md +89 -0
- package/skills/gitnexus-exploring.md +78 -0
- package/skills/gitnexus-guide.md +64 -0
- package/skills/gitnexus-impact-analysis.md +97 -0
- package/skills/gitnexus-pr-review.md +163 -0
- package/skills/gitnexus-refactoring.md +121 -0
- package/vendor/leiden/index.cjs +355 -0
- package/vendor/leiden/utils.cjs +392 -0
|
@@ -0,0 +1,793 @@
|
|
|
1
|
+
import Parser from 'tree-sitter';
|
|
2
|
+
import { TIER_CONFIDENCE } from './resolution-context.js';
|
|
3
|
+
import { isLanguageAvailable, loadParser, loadLanguage } from '../tree-sitter/parser-loader.js';
|
|
4
|
+
import { LANGUAGE_QUERIES } from './tree-sitter-queries.js';
|
|
5
|
+
import { generateId } from '../../lib/utils.js';
|
|
6
|
+
import { getLanguageFromFilename, isVerboseIngestionEnabled, yieldToEventLoop, FUNCTION_NODE_TYPES, extractFunctionName, isBuiltInOrNoise, countCallArguments, inferCallForm, extractReceiverName, extractReceiverNode, findEnclosingClassId, extractMixedChain, } from './utils.js';
|
|
7
|
+
import { buildTypeEnv } from './type-env.js';
|
|
8
|
+
import { getTreeSitterBufferSize } from './constants.js';
|
|
9
|
+
import { callRouters } from './call-routing.js';
|
|
10
|
+
import { extractReturnTypeName, stripNullable } from './type-extractors/shared.js';
|
|
11
|
+
// Stdlib methods that preserve the receiver's type identity. When TypeEnv already
|
|
12
|
+
// strips nullable wrappers (Option<User> → User), these chain steps are no-ops
|
|
13
|
+
// for type resolution — the current type passes through unchanged.
|
|
14
|
+
const TYPE_PRESERVING_METHODS = new Set([
|
|
15
|
+
'unwrap', 'expect', 'unwrap_or', 'unwrap_or_default', 'unwrap_or_else', // Rust Option/Result
|
|
16
|
+
'clone', 'to_owned', 'as_ref', 'as_mut', 'borrow', 'borrow_mut', // Rust clone/borrow
|
|
17
|
+
'get', // Kotlin/Java Optional.get()
|
|
18
|
+
'orElseThrow', // Java Optional
|
|
19
|
+
]);
|
|
20
|
+
/**
|
|
21
|
+
* Walk up the AST from a node to find the enclosing function/method.
|
|
22
|
+
* Returns null if the call is at module/file level (top-level code).
|
|
23
|
+
*/
|
|
24
|
+
const findEnclosingFunction = (node, filePath, ctx) => {
|
|
25
|
+
let current = node.parent;
|
|
26
|
+
while (current) {
|
|
27
|
+
if (FUNCTION_NODE_TYPES.has(current.type)) {
|
|
28
|
+
const { funcName, label } = extractFunctionName(current);
|
|
29
|
+
if (funcName) {
|
|
30
|
+
const resolved = ctx.resolve(funcName, filePath);
|
|
31
|
+
if (resolved?.tier === 'same-file' && resolved.candidates.length > 0) {
|
|
32
|
+
return resolved.candidates[0].nodeId;
|
|
33
|
+
}
|
|
34
|
+
return generateId(label, `${filePath}:${funcName}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
current = current.parent;
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Verify constructor bindings against SymbolTable and infer receiver types.
|
|
43
|
+
* Shared between sequential (processCalls) and worker (processCallsFromExtracted) paths.
|
|
44
|
+
*/
|
|
45
|
+
const verifyConstructorBindings = (bindings, filePath, ctx, graph) => {
|
|
46
|
+
const verified = new Map();
|
|
47
|
+
for (const { scope, varName, calleeName, receiverClassName } of bindings) {
|
|
48
|
+
const tiered = ctx.resolve(calleeName, filePath);
|
|
49
|
+
const isClass = tiered?.candidates.some(def => def.type === 'Class') ?? false;
|
|
50
|
+
if (isClass) {
|
|
51
|
+
verified.set(receiverKey(scope, varName), calleeName);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
let callableDefs = tiered?.candidates.filter(d => d.type === 'Function' || d.type === 'Method');
|
|
55
|
+
// When receiver class is known (e.g. $this->method() in PHP), narrow
|
|
56
|
+
// candidates to methods owned by that class to avoid false disambiguation failures.
|
|
57
|
+
if (callableDefs && callableDefs.length > 1 && receiverClassName) {
|
|
58
|
+
if (graph) {
|
|
59
|
+
// Worker path: use graph.getNode (fast, already in-memory)
|
|
60
|
+
const narrowed = callableDefs.filter(d => {
|
|
61
|
+
if (!d.ownerId)
|
|
62
|
+
return false;
|
|
63
|
+
const owner = graph.getNode(d.ownerId);
|
|
64
|
+
return owner?.properties.name === receiverClassName;
|
|
65
|
+
});
|
|
66
|
+
if (narrowed.length > 0)
|
|
67
|
+
callableDefs = narrowed;
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
// Sequential path: use ctx.resolve (no graph available)
|
|
71
|
+
const classResolved = ctx.resolve(receiverClassName, filePath);
|
|
72
|
+
if (classResolved && classResolved.candidates.length > 0) {
|
|
73
|
+
const classNodeIds = new Set(classResolved.candidates.map(c => c.nodeId));
|
|
74
|
+
const narrowed = callableDefs.filter(d => d.ownerId && classNodeIds.has(d.ownerId));
|
|
75
|
+
if (narrowed.length > 0)
|
|
76
|
+
callableDefs = narrowed;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (callableDefs && callableDefs.length === 1 && callableDefs[0].returnType) {
|
|
81
|
+
const typeName = extractReturnTypeName(callableDefs[0].returnType);
|
|
82
|
+
if (typeName) {
|
|
83
|
+
verified.set(receiverKey(scope, varName), typeName);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return verified;
|
|
89
|
+
};
|
|
90
|
+
export const processCalls = async (graph, files, astCache, ctx, onProgress) => {
|
|
91
|
+
const parser = await loadParser();
|
|
92
|
+
const collectedHeritage = [];
|
|
93
|
+
const pendingWrites = [];
|
|
94
|
+
const logSkipped = isVerboseIngestionEnabled();
|
|
95
|
+
const skippedByLang = logSkipped ? new Map() : null;
|
|
96
|
+
for (let i = 0; i < files.length; i++) {
|
|
97
|
+
const file = files[i];
|
|
98
|
+
onProgress?.(i + 1, files.length);
|
|
99
|
+
if (i % 20 === 0)
|
|
100
|
+
await yieldToEventLoop();
|
|
101
|
+
const language = getLanguageFromFilename(file.path);
|
|
102
|
+
if (!language)
|
|
103
|
+
continue;
|
|
104
|
+
if (!isLanguageAvailable(language)) {
|
|
105
|
+
if (skippedByLang) {
|
|
106
|
+
skippedByLang.set(language, (skippedByLang.get(language) ?? 0) + 1);
|
|
107
|
+
}
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const queryStr = LANGUAGE_QUERIES[language];
|
|
111
|
+
if (!queryStr)
|
|
112
|
+
continue;
|
|
113
|
+
await loadLanguage(language, file.path);
|
|
114
|
+
let tree = astCache.get(file.path);
|
|
115
|
+
if (!tree) {
|
|
116
|
+
try {
|
|
117
|
+
tree = parser.parse(file.content, undefined, { bufferSize: getTreeSitterBufferSize(file.content.length) });
|
|
118
|
+
}
|
|
119
|
+
catch (parseError) {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
astCache.set(file.path, tree);
|
|
123
|
+
}
|
|
124
|
+
let query;
|
|
125
|
+
let matches;
|
|
126
|
+
try {
|
|
127
|
+
const language = parser.getLanguage();
|
|
128
|
+
query = new Parser.Query(language, queryStr);
|
|
129
|
+
matches = query.matches(tree.rootNode);
|
|
130
|
+
}
|
|
131
|
+
catch (queryError) {
|
|
132
|
+
console.warn(`Query error for ${file.path}:`, queryError);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const lang = getLanguageFromFilename(file.path);
|
|
136
|
+
const typeEnv = lang ? buildTypeEnv(tree, lang, ctx.symbols) : null;
|
|
137
|
+
const callRouter = callRouters[language];
|
|
138
|
+
const verifiedReceivers = typeEnv && typeEnv.constructorBindings.length > 0
|
|
139
|
+
? verifyConstructorBindings(typeEnv.constructorBindings, file.path, ctx)
|
|
140
|
+
: new Map();
|
|
141
|
+
const receiverIndex = buildReceiverTypeIndex(verifiedReceivers);
|
|
142
|
+
ctx.enableCache(file.path);
|
|
143
|
+
matches.forEach(match => {
|
|
144
|
+
const captureMap = {};
|
|
145
|
+
match.captures.forEach(c => captureMap[c.name] = c.node);
|
|
146
|
+
// ── Write access: emit ACCESSES {reason: 'write'} for assignments to member fields ──
|
|
147
|
+
if (captureMap['assignment'] && captureMap['assignment.receiver'] && captureMap['assignment.property']) {
|
|
148
|
+
const receiverNode = captureMap['assignment.receiver'];
|
|
149
|
+
const propertyName = captureMap['assignment.property'].text;
|
|
150
|
+
// Resolve receiver type: simple identifier → TypeEnv lookup or class resolution
|
|
151
|
+
let receiverTypeName;
|
|
152
|
+
const receiverText = receiverNode.text;
|
|
153
|
+
if (receiverText && typeEnv) {
|
|
154
|
+
receiverTypeName = typeEnv.lookup(receiverText, captureMap['assignment']);
|
|
155
|
+
}
|
|
156
|
+
// Fall back to verified constructor bindings (mirrors CALLS resolution tier 2)
|
|
157
|
+
if (!receiverTypeName && receiverText && receiverIndex.size > 0) {
|
|
158
|
+
const enclosing = findEnclosingFunction(captureMap['assignment'], file.path, ctx);
|
|
159
|
+
const funcName = enclosing ? extractFuncNameFromSourceId(enclosing) : '';
|
|
160
|
+
receiverTypeName = lookupReceiverType(receiverIndex, funcName, receiverText);
|
|
161
|
+
}
|
|
162
|
+
if (!receiverTypeName && receiverText) {
|
|
163
|
+
const resolved = ctx.resolve(receiverText, file.path);
|
|
164
|
+
if (resolved?.candidates.some(d => d.type === 'Class' || d.type === 'Struct' || d.type === 'Interface'
|
|
165
|
+
|| d.type === 'Enum' || d.type === 'Record' || d.type === 'Impl')) {
|
|
166
|
+
receiverTypeName = receiverText;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (receiverTypeName) {
|
|
170
|
+
const enclosing = findEnclosingFunction(captureMap['assignment'], file.path, ctx);
|
|
171
|
+
const srcId = enclosing || generateId('File', file.path);
|
|
172
|
+
// Defer resolution: Ruby attr_accessor properties are registered during
|
|
173
|
+
// this same loop, so cross-file lookups fail if the declaring file hasn't
|
|
174
|
+
// been processed yet. Collect now, resolve after all files are done.
|
|
175
|
+
pendingWrites.push({ receiverTypeName, propertyName, filePath: file.path, srcId });
|
|
176
|
+
}
|
|
177
|
+
// Assignment-only capture (no @call sibling): skip the rest of this
|
|
178
|
+
// forEach iteration — this acts as a `continue` in the match loop.
|
|
179
|
+
if (!captureMap['call'])
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (!captureMap['call'])
|
|
183
|
+
return;
|
|
184
|
+
const nameNode = captureMap['call.name'];
|
|
185
|
+
if (!nameNode)
|
|
186
|
+
return;
|
|
187
|
+
const calledName = nameNode.text;
|
|
188
|
+
const routed = callRouter(calledName, captureMap['call']);
|
|
189
|
+
if (routed) {
|
|
190
|
+
switch (routed.kind) {
|
|
191
|
+
case 'skip':
|
|
192
|
+
case 'import':
|
|
193
|
+
return;
|
|
194
|
+
case 'heritage':
|
|
195
|
+
for (const item of routed.items) {
|
|
196
|
+
collectedHeritage.push({
|
|
197
|
+
filePath: file.path,
|
|
198
|
+
className: item.enclosingClass,
|
|
199
|
+
parentName: item.mixinName,
|
|
200
|
+
kind: item.heritageKind,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
return;
|
|
204
|
+
case 'properties': {
|
|
205
|
+
const fileId = generateId('File', file.path);
|
|
206
|
+
const propEnclosingClassId = findEnclosingClassId(captureMap['call'], file.path);
|
|
207
|
+
for (const item of routed.items) {
|
|
208
|
+
const nodeId = generateId('Property', `${file.path}:${item.propName}`);
|
|
209
|
+
graph.addNode({
|
|
210
|
+
id: nodeId,
|
|
211
|
+
label: 'Property',
|
|
212
|
+
properties: {
|
|
213
|
+
name: item.propName, filePath: file.path,
|
|
214
|
+
startLine: item.startLine, endLine: item.endLine,
|
|
215
|
+
language, isExported: true,
|
|
216
|
+
description: item.accessorType,
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
ctx.symbols.add(file.path, item.propName, nodeId, 'Property', {
|
|
220
|
+
...(propEnclosingClassId ? { ownerId: propEnclosingClassId } : {}),
|
|
221
|
+
...(item.declaredType ? { declaredType: item.declaredType } : {}),
|
|
222
|
+
});
|
|
223
|
+
const relId = generateId('DEFINES', `${fileId}->${nodeId}`);
|
|
224
|
+
graph.addRelationship({
|
|
225
|
+
id: relId, sourceId: fileId, targetId: nodeId,
|
|
226
|
+
type: 'DEFINES', confidence: 1.0, reason: '',
|
|
227
|
+
});
|
|
228
|
+
if (propEnclosingClassId) {
|
|
229
|
+
graph.addRelationship({
|
|
230
|
+
id: generateId('HAS_PROPERTY', `${propEnclosingClassId}->${nodeId}`),
|
|
231
|
+
sourceId: propEnclosingClassId, targetId: nodeId,
|
|
232
|
+
type: 'HAS_PROPERTY', confidence: 1.0, reason: '',
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
case 'call':
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (isBuiltInOrNoise(calledName))
|
|
243
|
+
return;
|
|
244
|
+
const callNode = captureMap['call'];
|
|
245
|
+
const callForm = inferCallForm(callNode, nameNode);
|
|
246
|
+
const receiverName = callForm === 'member' ? extractReceiverName(nameNode) : undefined;
|
|
247
|
+
let receiverTypeName = receiverName && typeEnv ? typeEnv.lookup(receiverName, callNode) : undefined;
|
|
248
|
+
// Fall back to verified constructor bindings for return type inference
|
|
249
|
+
if (!receiverTypeName && receiverName && receiverIndex.size > 0) {
|
|
250
|
+
const enclosingFunc = findEnclosingFunction(callNode, file.path, ctx);
|
|
251
|
+
const funcName = enclosingFunc ? extractFuncNameFromSourceId(enclosingFunc) : '';
|
|
252
|
+
receiverTypeName = lookupReceiverType(receiverIndex, funcName, receiverName);
|
|
253
|
+
}
|
|
254
|
+
// Fall back to class-as-receiver for static method calls (e.g. UserService.find_user()).
|
|
255
|
+
// When the receiver name is not a variable in TypeEnv but resolves to a Class/Struct/Interface
|
|
256
|
+
// through the standard tiered resolution, use it directly as the receiver type.
|
|
257
|
+
if (!receiverTypeName && receiverName && callForm === 'member') {
|
|
258
|
+
const typeResolved = ctx.resolve(receiverName, file.path);
|
|
259
|
+
if (typeResolved && typeResolved.candidates.some(d => d.type === 'Class' || d.type === 'Interface' || d.type === 'Struct' || d.type === 'Enum')) {
|
|
260
|
+
receiverTypeName = receiverName;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// Hoist sourceId so it's available for ACCESSES edge emission during chain walk.
|
|
264
|
+
const enclosingFuncId = findEnclosingFunction(callNode, file.path, ctx);
|
|
265
|
+
const sourceId = enclosingFuncId || generateId('File', file.path);
|
|
266
|
+
// Fall back to mixed chain resolution when the receiver is a complex expression
|
|
267
|
+
// (field chain, call chain, or interleaved — e.g. user.address.city.save() or
|
|
268
|
+
// svc.getUser().address.save()). Handles all cases with a single unified walk.
|
|
269
|
+
if (callForm === 'member' && !receiverTypeName && !receiverName) {
|
|
270
|
+
const receiverNode = extractReceiverNode(nameNode);
|
|
271
|
+
if (receiverNode) {
|
|
272
|
+
const extracted = extractMixedChain(receiverNode);
|
|
273
|
+
if (extracted && extracted.chain.length > 0) {
|
|
274
|
+
let currentType = extracted.baseReceiverName && typeEnv
|
|
275
|
+
? typeEnv.lookup(extracted.baseReceiverName, callNode)
|
|
276
|
+
: undefined;
|
|
277
|
+
if (!currentType && extracted.baseReceiverName && receiverIndex.size > 0) {
|
|
278
|
+
const funcName = enclosingFuncId ? extractFuncNameFromSourceId(enclosingFuncId) : '';
|
|
279
|
+
currentType = lookupReceiverType(receiverIndex, funcName, extracted.baseReceiverName);
|
|
280
|
+
}
|
|
281
|
+
if (!currentType && extracted.baseReceiverName) {
|
|
282
|
+
const cr = ctx.resolve(extracted.baseReceiverName, file.path);
|
|
283
|
+
if (cr?.candidates.some(d => d.type === 'Class' || d.type === 'Interface' || d.type === 'Struct' || d.type === 'Enum')) {
|
|
284
|
+
currentType = extracted.baseReceiverName;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (currentType) {
|
|
288
|
+
receiverTypeName = walkMixedChain(extracted.chain, currentType, file.path, ctx, makeAccessEmitter(graph, sourceId));
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
const resolved = resolveCallTarget({
|
|
294
|
+
calledName,
|
|
295
|
+
argCount: countCallArguments(callNode),
|
|
296
|
+
callForm,
|
|
297
|
+
receiverTypeName,
|
|
298
|
+
}, file.path, ctx);
|
|
299
|
+
if (!resolved)
|
|
300
|
+
return;
|
|
301
|
+
const relId = generateId('CALLS', `${sourceId}:${calledName}->${resolved.nodeId}`);
|
|
302
|
+
graph.addRelationship({
|
|
303
|
+
id: relId,
|
|
304
|
+
sourceId,
|
|
305
|
+
targetId: resolved.nodeId,
|
|
306
|
+
type: 'CALLS',
|
|
307
|
+
confidence: resolved.confidence,
|
|
308
|
+
reason: resolved.reason,
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
ctx.clearCache();
|
|
312
|
+
}
|
|
313
|
+
// ── Resolve deferred write-access edges ──
|
|
314
|
+
// All properties (including Ruby attr_accessor) are now registered.
|
|
315
|
+
for (const pw of pendingWrites) {
|
|
316
|
+
const fieldOwner = resolveFieldOwnership(pw.receiverTypeName, pw.propertyName, pw.filePath, ctx);
|
|
317
|
+
if (fieldOwner) {
|
|
318
|
+
graph.addRelationship({
|
|
319
|
+
id: generateId('ACCESSES', `${pw.srcId}:${fieldOwner.nodeId}:write`),
|
|
320
|
+
sourceId: pw.srcId,
|
|
321
|
+
targetId: fieldOwner.nodeId,
|
|
322
|
+
type: 'ACCESSES',
|
|
323
|
+
confidence: 1.0,
|
|
324
|
+
reason: 'write',
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
if (skippedByLang && skippedByLang.size > 0) {
|
|
329
|
+
for (const [lang, count] of skippedByLang.entries()) {
|
|
330
|
+
console.warn(`[ingestion] Skipped ${count} ${lang} file(s) in call processing — ${lang} parser not available.`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return collectedHeritage;
|
|
334
|
+
};
|
|
335
|
+
const CALLABLE_SYMBOL_TYPES = new Set([
|
|
336
|
+
'Function',
|
|
337
|
+
'Method',
|
|
338
|
+
'Constructor',
|
|
339
|
+
'Macro',
|
|
340
|
+
'Delegate',
|
|
341
|
+
]);
|
|
342
|
+
const CONSTRUCTOR_TARGET_TYPES = new Set(['Constructor', 'Class', 'Struct', 'Record']);
|
|
343
|
+
const filterCallableCandidates = (candidates, argCount, callForm) => {
|
|
344
|
+
let kindFiltered;
|
|
345
|
+
if (callForm === 'constructor') {
|
|
346
|
+
const constructors = candidates.filter(c => c.type === 'Constructor');
|
|
347
|
+
if (constructors.length > 0) {
|
|
348
|
+
kindFiltered = constructors;
|
|
349
|
+
}
|
|
350
|
+
else {
|
|
351
|
+
const types = candidates.filter(c => CONSTRUCTOR_TARGET_TYPES.has(c.type));
|
|
352
|
+
kindFiltered = types.length > 0 ? types : candidates.filter(c => CALLABLE_SYMBOL_TYPES.has(c.type));
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
else {
|
|
356
|
+
kindFiltered = candidates.filter(c => CALLABLE_SYMBOL_TYPES.has(c.type));
|
|
357
|
+
}
|
|
358
|
+
if (kindFiltered.length === 0)
|
|
359
|
+
return [];
|
|
360
|
+
if (argCount === undefined)
|
|
361
|
+
return kindFiltered;
|
|
362
|
+
const hasParameterMetadata = kindFiltered.some(candidate => candidate.parameterCount !== undefined);
|
|
363
|
+
if (!hasParameterMetadata)
|
|
364
|
+
return kindFiltered;
|
|
365
|
+
return kindFiltered.filter(candidate => candidate.parameterCount === undefined || candidate.parameterCount === argCount);
|
|
366
|
+
};
|
|
367
|
+
const toResolveResult = (definition, tier) => ({
|
|
368
|
+
nodeId: definition.nodeId,
|
|
369
|
+
confidence: TIER_CONFIDENCE[tier],
|
|
370
|
+
reason: tier === 'same-file' ? 'same-file' : tier === 'import-scoped' ? 'import-resolved' : 'global',
|
|
371
|
+
returnType: definition.returnType,
|
|
372
|
+
});
|
|
373
|
+
/**
|
|
374
|
+
* Resolve a function call to its target node ID using priority strategy:
|
|
375
|
+
* A. Narrow candidates by scope tier via ctx.resolve()
|
|
376
|
+
* B. Filter to callable symbol kinds (constructor-aware when callForm is set)
|
|
377
|
+
* C. Apply arity filtering when parameter metadata is available
|
|
378
|
+
* D. Apply receiver-type filtering for member calls with typed receivers
|
|
379
|
+
*
|
|
380
|
+
* If filtering still leaves multiple candidates, refuse to emit a CALLS edge.
|
|
381
|
+
*/
|
|
382
|
+
const resolveCallTarget = (call, currentFile, ctx) => {
|
|
383
|
+
const tiered = ctx.resolve(call.calledName, currentFile);
|
|
384
|
+
if (!tiered)
|
|
385
|
+
return null;
|
|
386
|
+
const filteredCandidates = filterCallableCandidates(tiered.candidates, call.argCount, call.callForm);
|
|
387
|
+
// D. Receiver-type filtering: for member calls with a known receiver type,
|
|
388
|
+
// resolve the type through the same tiered import infrastructure, then
|
|
389
|
+
// filter method candidates to the type's defining file. Fall back to
|
|
390
|
+
// fuzzy ownerId matching only when file-based narrowing is inconclusive.
|
|
391
|
+
//
|
|
392
|
+
// Applied regardless of candidate count — the sole same-file candidate may
|
|
393
|
+
// belong to the wrong class (e.g. super.save() should hit the parent's save,
|
|
394
|
+
// not the child's own save method in the same file).
|
|
395
|
+
if (call.callForm === 'member' && call.receiverTypeName) {
|
|
396
|
+
// D1. Resolve the receiver type
|
|
397
|
+
const typeResolved = ctx.resolve(call.receiverTypeName, currentFile);
|
|
398
|
+
if (typeResolved && typeResolved.candidates.length > 0) {
|
|
399
|
+
const typeNodeIds = new Set(typeResolved.candidates.map(d => d.nodeId));
|
|
400
|
+
const typeFiles = new Set(typeResolved.candidates.map(d => d.filePath));
|
|
401
|
+
// D2. Widen candidates: same-file tier may miss the parent's method when
|
|
402
|
+
// it lives in another file. Query the symbol table directly for all
|
|
403
|
+
// global methods with this name, then apply arity/kind filtering.
|
|
404
|
+
const methodPool = filteredCandidates.length <= 1
|
|
405
|
+
? filterCallableCandidates(ctx.symbols.lookupFuzzy(call.calledName), call.argCount, call.callForm)
|
|
406
|
+
: filteredCandidates;
|
|
407
|
+
// D3. File-based: prefer candidates whose filePath matches the resolved type's file
|
|
408
|
+
const fileFiltered = methodPool.filter(c => typeFiles.has(c.filePath));
|
|
409
|
+
if (fileFiltered.length === 1) {
|
|
410
|
+
return toResolveResult(fileFiltered[0], tiered.tier);
|
|
411
|
+
}
|
|
412
|
+
// D4. ownerId fallback: narrow by ownerId matching the type's nodeId
|
|
413
|
+
const pool = fileFiltered.length > 0 ? fileFiltered : methodPool;
|
|
414
|
+
const ownerFiltered = pool.filter(c => c.ownerId && typeNodeIds.has(c.ownerId));
|
|
415
|
+
if (ownerFiltered.length === 1) {
|
|
416
|
+
return toResolveResult(ownerFiltered[0], tiered.tier);
|
|
417
|
+
}
|
|
418
|
+
if (fileFiltered.length > 1 || ownerFiltered.length > 1)
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
if (filteredCandidates.length !== 1)
|
|
423
|
+
return null;
|
|
424
|
+
return toResolveResult(filteredCandidates[0], tiered.tier);
|
|
425
|
+
};
|
|
426
|
+
// ── Scope key helpers ────────────────────────────────────────────────────
|
|
427
|
+
// Scope keys use the format "funcName@startIndex" (produced by type-env.ts).
|
|
428
|
+
// Source IDs use "Label:filepath:funcName" (produced by parse-worker.ts).
|
|
429
|
+
// NUL (\0) is used as a composite-key separator because it cannot appear
|
|
430
|
+
// in source-code identifiers, preventing ambiguous concatenation.
|
|
431
|
+
//
|
|
432
|
+
// receiverKey stores the FULL scope (funcName@startIndex) to prevent
|
|
433
|
+
// collisions between overloaded methods with the same name in different
|
|
434
|
+
// classes (e.g. User.save@100 and Repo.save@200 are distinct keys).
|
|
435
|
+
// Lookup uses a secondary funcName-only index built in lookupReceiverType.
|
|
436
|
+
/** Extract the function name from a scope key ("funcName@startIndex" → "funcName"). */
|
|
437
|
+
const extractFuncNameFromScope = (scope) => scope.slice(0, scope.indexOf('@'));
|
|
438
|
+
/** Extract the trailing function name from a sourceId ("Function:filepath:funcName" → "funcName"). */
|
|
439
|
+
const extractFuncNameFromSourceId = (sourceId) => {
|
|
440
|
+
const lastColon = sourceId.lastIndexOf(':');
|
|
441
|
+
return lastColon >= 0 ? sourceId.slice(lastColon + 1) : '';
|
|
442
|
+
};
|
|
443
|
+
/**
|
|
444
|
+
* Build a composite key for receiver type storage.
|
|
445
|
+
* Uses the full scope string (e.g. "save@100") to distinguish overloaded
|
|
446
|
+
* methods with the same name in different classes.
|
|
447
|
+
*/
|
|
448
|
+
const receiverKey = (scope, varName) => `${scope}\0${varName}`;
|
|
449
|
+
/**
|
|
450
|
+
* Build a two-level secondary index from the verified receiver map.
|
|
451
|
+
* The verified map is keyed by `scope\0varName` where scope is either
|
|
452
|
+
* "funcName@startIndex" (inside a function) or "" (file level).
|
|
453
|
+
* Index structure: Map<funcName, Map<varName, ReceiverTypeEntry>>
|
|
454
|
+
*/
|
|
455
|
+
const buildReceiverTypeIndex = (map) => {
|
|
456
|
+
const index = new Map();
|
|
457
|
+
for (const [key, typeName] of map) {
|
|
458
|
+
const nul = key.indexOf('\0');
|
|
459
|
+
if (nul < 0)
|
|
460
|
+
continue;
|
|
461
|
+
const scope = key.slice(0, nul);
|
|
462
|
+
const varName = key.slice(nul + 1);
|
|
463
|
+
if (!varName)
|
|
464
|
+
continue;
|
|
465
|
+
if (scope !== '' && !scope.includes('@'))
|
|
466
|
+
continue;
|
|
467
|
+
const funcName = scope === '' ? '' : scope.slice(0, scope.indexOf('@'));
|
|
468
|
+
let varMap = index.get(funcName);
|
|
469
|
+
if (!varMap) {
|
|
470
|
+
varMap = new Map();
|
|
471
|
+
index.set(funcName, varMap);
|
|
472
|
+
}
|
|
473
|
+
const existing = varMap.get(varName);
|
|
474
|
+
if (existing === undefined) {
|
|
475
|
+
varMap.set(varName, { kind: 'resolved', value: typeName });
|
|
476
|
+
}
|
|
477
|
+
else if (existing.kind === 'resolved' && existing.value !== typeName) {
|
|
478
|
+
varMap.set(varName, { kind: 'ambiguous' });
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return index;
|
|
482
|
+
};
|
|
483
|
+
/**
|
|
484
|
+
* O(1) receiver type lookup using the pre-built secondary index.
|
|
485
|
+
* Returns the unique type name if unambiguous. Falls back to file-level scope.
|
|
486
|
+
*/
|
|
487
|
+
const lookupReceiverType = (index, funcName, varName) => {
|
|
488
|
+
const funcBucket = index.get(funcName);
|
|
489
|
+
if (funcBucket) {
|
|
490
|
+
const entry = funcBucket.get(varName);
|
|
491
|
+
if (entry?.kind === 'resolved')
|
|
492
|
+
return entry.value;
|
|
493
|
+
if (entry?.kind === 'ambiguous') {
|
|
494
|
+
// Ambiguous in this function scope — try file-level fallback
|
|
495
|
+
const fileEntry = index.get('')?.get(varName);
|
|
496
|
+
return fileEntry?.kind === 'resolved' ? fileEntry.value : undefined;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
// Fallback: file-level scope (funcName "")
|
|
500
|
+
if (funcName !== '') {
|
|
501
|
+
const fileEntry = index.get('')?.get(varName);
|
|
502
|
+
if (fileEntry?.kind === 'resolved')
|
|
503
|
+
return fileEntry.value;
|
|
504
|
+
}
|
|
505
|
+
return undefined;
|
|
506
|
+
};
|
|
507
|
+
/**
|
|
508
|
+
* Resolve the type that results from accessing `receiverName.fieldName`.
|
|
509
|
+
* Requires declaredType on the Property node (needed for chain walking continuation).
|
|
510
|
+
*/
|
|
511
|
+
const resolveFieldAccessType = (receiverName, fieldName, filePath, ctx) => {
|
|
512
|
+
const fieldDef = resolveFieldOwnership(receiverName, fieldName, filePath, ctx);
|
|
513
|
+
if (!fieldDef?.declaredType)
|
|
514
|
+
return undefined;
|
|
515
|
+
// Use stripNullable (not extractReturnTypeName) — field types like List<User>
|
|
516
|
+
// should be preserved as-is, not unwrapped to User. Only strip nullable wrappers.
|
|
517
|
+
return {
|
|
518
|
+
typeName: stripNullable(fieldDef.declaredType),
|
|
519
|
+
fieldNodeId: fieldDef.nodeId,
|
|
520
|
+
};
|
|
521
|
+
};
|
|
522
|
+
/**
|
|
523
|
+
* Resolve a field's Property node given a receiver type name and field name.
|
|
524
|
+
* Does NOT require declaredType — used by write-access tracking where only the
|
|
525
|
+
* fieldNodeId is needed (no chain continuation).
|
|
526
|
+
*/
|
|
527
|
+
const resolveFieldOwnership = (receiverName, fieldName, filePath, ctx) => {
|
|
528
|
+
const typeResolved = ctx.resolve(receiverName, filePath);
|
|
529
|
+
if (!typeResolved)
|
|
530
|
+
return undefined;
|
|
531
|
+
const classDef = typeResolved.candidates.find(d => d.type === 'Class' || d.type === 'Struct' || d.type === 'Interface'
|
|
532
|
+
|| d.type === 'Enum' || d.type === 'Record' || d.type === 'Impl');
|
|
533
|
+
if (!classDef)
|
|
534
|
+
return undefined;
|
|
535
|
+
return ctx.symbols.lookupFieldByOwner(classDef.nodeId, fieldName) ?? undefined;
|
|
536
|
+
};
|
|
537
|
+
/**
|
|
538
|
+
* Create a deduplicated ACCESSES edge emitter for a single source node.
|
|
539
|
+
* Each (sourceId, fieldNodeId) pair is emitted at most once per source.
|
|
540
|
+
*/
|
|
541
|
+
const makeAccessEmitter = (graph, sourceId) => {
|
|
542
|
+
const emitted = new Set();
|
|
543
|
+
return (fieldNodeId) => {
|
|
544
|
+
const key = `${sourceId}\0${fieldNodeId}`;
|
|
545
|
+
if (emitted.has(key))
|
|
546
|
+
return;
|
|
547
|
+
emitted.add(key);
|
|
548
|
+
graph.addRelationship({
|
|
549
|
+
id: generateId('ACCESSES', `${sourceId}:${fieldNodeId}:read`),
|
|
550
|
+
sourceId,
|
|
551
|
+
targetId: fieldNodeId,
|
|
552
|
+
type: 'ACCESSES',
|
|
553
|
+
confidence: 1.0,
|
|
554
|
+
reason: 'read',
|
|
555
|
+
});
|
|
556
|
+
};
|
|
557
|
+
};
|
|
558
|
+
const walkMixedChain = (chain, startType, filePath, ctx, onFieldResolved) => {
|
|
559
|
+
let currentType = startType;
|
|
560
|
+
for (const step of chain) {
|
|
561
|
+
if (!currentType)
|
|
562
|
+
break;
|
|
563
|
+
if (step.kind === 'field') {
|
|
564
|
+
const resolved = resolveFieldAccessType(currentType, step.name, filePath, ctx);
|
|
565
|
+
if (!resolved) {
|
|
566
|
+
currentType = undefined;
|
|
567
|
+
break;
|
|
568
|
+
}
|
|
569
|
+
onFieldResolved?.(resolved.fieldNodeId);
|
|
570
|
+
currentType = resolved.typeName;
|
|
571
|
+
}
|
|
572
|
+
else {
|
|
573
|
+
// Ruby/Python: property access is syntactically identical to method calls.
|
|
574
|
+
// Try field resolution first — if the name is a known property with declaredType,
|
|
575
|
+
// use that type directly. Otherwise fall back to method call resolution.
|
|
576
|
+
const fieldResolved = resolveFieldAccessType(currentType, step.name, filePath, ctx);
|
|
577
|
+
if (fieldResolved) {
|
|
578
|
+
onFieldResolved?.(fieldResolved.fieldNodeId);
|
|
579
|
+
currentType = fieldResolved.typeName;
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
const resolved = resolveCallTarget({ calledName: step.name, callForm: 'member', receiverTypeName: currentType }, filePath, ctx);
|
|
583
|
+
if (!resolved) {
|
|
584
|
+
// Stdlib passthrough: unwrap(), clone(), etc. preserve the receiver type
|
|
585
|
+
if (TYPE_PRESERVING_METHODS.has(step.name))
|
|
586
|
+
continue;
|
|
587
|
+
currentType = undefined;
|
|
588
|
+
break;
|
|
589
|
+
}
|
|
590
|
+
if (!resolved.returnType) {
|
|
591
|
+
currentType = undefined;
|
|
592
|
+
break;
|
|
593
|
+
}
|
|
594
|
+
const retType = extractReturnTypeName(resolved.returnType);
|
|
595
|
+
if (!retType) {
|
|
596
|
+
currentType = undefined;
|
|
597
|
+
break;
|
|
598
|
+
}
|
|
599
|
+
currentType = retType;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
return currentType;
|
|
603
|
+
};
|
|
604
|
+
/**
|
|
605
|
+
* Fast path: resolve pre-extracted call sites from workers.
|
|
606
|
+
* No AST parsing — workers already extracted calledName + sourceId.
|
|
607
|
+
*/
|
|
608
|
+
export const processCallsFromExtracted = async (graph, extractedCalls, ctx, onProgress, constructorBindings) => {
|
|
609
|
+
// Scope-aware receiver types: keyed by filePath → "funcName\0varName" → typeName.
|
|
610
|
+
// The scope dimension prevents collisions when two functions in the same file
|
|
611
|
+
// have same-named locals pointing to different constructor types.
|
|
612
|
+
const fileReceiverTypes = new Map();
|
|
613
|
+
if (constructorBindings) {
|
|
614
|
+
for (const { filePath, bindings } of constructorBindings) {
|
|
615
|
+
const verified = verifyConstructorBindings(bindings, filePath, ctx, graph);
|
|
616
|
+
if (verified.size > 0) {
|
|
617
|
+
fileReceiverTypes.set(filePath, buildReceiverTypeIndex(verified));
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
const byFile = new Map();
|
|
622
|
+
for (const call of extractedCalls) {
|
|
623
|
+
let list = byFile.get(call.filePath);
|
|
624
|
+
if (!list) {
|
|
625
|
+
list = [];
|
|
626
|
+
byFile.set(call.filePath, list);
|
|
627
|
+
}
|
|
628
|
+
list.push(call);
|
|
629
|
+
}
|
|
630
|
+
const totalFiles = byFile.size;
|
|
631
|
+
let filesProcessed = 0;
|
|
632
|
+
for (const [filePath, calls] of byFile) {
|
|
633
|
+
filesProcessed++;
|
|
634
|
+
if (filesProcessed % 100 === 0) {
|
|
635
|
+
onProgress?.(filesProcessed, totalFiles);
|
|
636
|
+
await yieldToEventLoop();
|
|
637
|
+
}
|
|
638
|
+
ctx.enableCache(filePath);
|
|
639
|
+
const receiverMap = fileReceiverTypes.get(filePath);
|
|
640
|
+
for (const call of calls) {
|
|
641
|
+
let effectiveCall = call;
|
|
642
|
+
// Step 1: resolve receiver type from constructor bindings
|
|
643
|
+
if (!call.receiverTypeName && call.receiverName && receiverMap) {
|
|
644
|
+
const callFuncName = extractFuncNameFromSourceId(call.sourceId);
|
|
645
|
+
const resolvedType = lookupReceiverType(receiverMap, callFuncName, call.receiverName);
|
|
646
|
+
if (resolvedType) {
|
|
647
|
+
effectiveCall = { ...call, receiverTypeName: resolvedType };
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
// Step 1b: class-as-receiver for static method calls (e.g. UserService.find_user())
|
|
651
|
+
if (!effectiveCall.receiverTypeName && effectiveCall.receiverName && effectiveCall.callForm === 'member') {
|
|
652
|
+
const typeResolved = ctx.resolve(effectiveCall.receiverName, effectiveCall.filePath);
|
|
653
|
+
if (typeResolved && typeResolved.candidates.some(d => d.type === 'Class' || d.type === 'Interface' || d.type === 'Struct' || d.type === 'Enum')) {
|
|
654
|
+
effectiveCall = { ...effectiveCall, receiverTypeName: effectiveCall.receiverName };
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
// Step 1c: mixed chain resolution (field, call, or interleaved — e.g. svc.getUser().address.save()).
|
|
658
|
+
// Runs whenever receiverMixedChain is present. Steps 1/1b may have resolved the base receiver
|
|
659
|
+
// type already; that type is used as the chain's starting point.
|
|
660
|
+
if (effectiveCall.receiverMixedChain?.length) {
|
|
661
|
+
// Use the already-resolved base type (from Steps 1/1b) or look it up now.
|
|
662
|
+
let currentType = effectiveCall.receiverTypeName;
|
|
663
|
+
if (!currentType && effectiveCall.receiverName && receiverMap) {
|
|
664
|
+
const callFuncName = extractFuncNameFromSourceId(effectiveCall.sourceId);
|
|
665
|
+
currentType = lookupReceiverType(receiverMap, callFuncName, effectiveCall.receiverName);
|
|
666
|
+
}
|
|
667
|
+
if (!currentType && effectiveCall.receiverName) {
|
|
668
|
+
const typeResolved = ctx.resolve(effectiveCall.receiverName, effectiveCall.filePath);
|
|
669
|
+
if (typeResolved?.candidates.some(d => d.type === 'Class' || d.type === 'Interface' || d.type === 'Struct' || d.type === 'Enum')) {
|
|
670
|
+
currentType = effectiveCall.receiverName;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
if (currentType) {
|
|
674
|
+
const walkedType = walkMixedChain(effectiveCall.receiverMixedChain, currentType, effectiveCall.filePath, ctx, makeAccessEmitter(graph, effectiveCall.sourceId));
|
|
675
|
+
if (walkedType) {
|
|
676
|
+
effectiveCall = { ...effectiveCall, receiverTypeName: walkedType };
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
const resolved = resolveCallTarget(effectiveCall, effectiveCall.filePath, ctx);
|
|
681
|
+
if (!resolved)
|
|
682
|
+
continue;
|
|
683
|
+
const relId = generateId('CALLS', `${effectiveCall.sourceId}:${effectiveCall.calledName}->${resolved.nodeId}`);
|
|
684
|
+
graph.addRelationship({
|
|
685
|
+
id: relId,
|
|
686
|
+
sourceId: effectiveCall.sourceId,
|
|
687
|
+
targetId: resolved.nodeId,
|
|
688
|
+
type: 'CALLS',
|
|
689
|
+
confidence: resolved.confidence,
|
|
690
|
+
reason: resolved.reason,
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
ctx.clearCache();
|
|
694
|
+
}
|
|
695
|
+
onProgress?.(totalFiles, totalFiles);
|
|
696
|
+
};
|
|
697
|
+
/**
|
|
698
|
+
* Resolve pre-extracted field write assignments to ACCESSES {reason: 'write'} edges.
|
|
699
|
+
* Accepts optional constructorBindings for return-type-aware receiver inference,
|
|
700
|
+
* mirroring processCallsFromExtracted's verified binding lookup.
|
|
701
|
+
*/
|
|
702
|
+
export const processAssignmentsFromExtracted = (graph, assignments, ctx, constructorBindings) => {
|
|
703
|
+
// Build per-file receiver type indexes from verified constructor bindings
|
|
704
|
+
const fileReceiverTypes = new Map();
|
|
705
|
+
if (constructorBindings) {
|
|
706
|
+
for (const { filePath, bindings } of constructorBindings) {
|
|
707
|
+
const verified = verifyConstructorBindings(bindings, filePath, ctx, graph);
|
|
708
|
+
if (verified.size > 0) {
|
|
709
|
+
fileReceiverTypes.set(filePath, buildReceiverTypeIndex(verified));
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
for (const asn of assignments) {
|
|
714
|
+
// Resolve the receiver type
|
|
715
|
+
let receiverTypeName = asn.receiverTypeName;
|
|
716
|
+
// Tier 2: verified constructor bindings (return-type inference)
|
|
717
|
+
if (!receiverTypeName && fileReceiverTypes.size > 0) {
|
|
718
|
+
const receiverMap = fileReceiverTypes.get(asn.filePath);
|
|
719
|
+
if (receiverMap) {
|
|
720
|
+
const funcName = extractFuncNameFromSourceId(asn.sourceId);
|
|
721
|
+
receiverTypeName = lookupReceiverType(receiverMap, funcName, asn.receiverText);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
// Tier 3: static class-as-receiver fallback
|
|
725
|
+
if (!receiverTypeName) {
|
|
726
|
+
const resolved = ctx.resolve(asn.receiverText, asn.filePath);
|
|
727
|
+
if (resolved?.candidates.some(d => d.type === 'Class' || d.type === 'Struct' || d.type === 'Interface'
|
|
728
|
+
|| d.type === 'Enum' || d.type === 'Record' || d.type === 'Impl')) {
|
|
729
|
+
receiverTypeName = asn.receiverText;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
if (!receiverTypeName)
|
|
733
|
+
continue;
|
|
734
|
+
const fieldOwner = resolveFieldOwnership(receiverTypeName, asn.propertyName, asn.filePath, ctx);
|
|
735
|
+
if (!fieldOwner)
|
|
736
|
+
continue;
|
|
737
|
+
graph.addRelationship({
|
|
738
|
+
id: generateId('ACCESSES', `${asn.sourceId}:${fieldOwner.nodeId}:write`),
|
|
739
|
+
sourceId: asn.sourceId,
|
|
740
|
+
targetId: fieldOwner.nodeId,
|
|
741
|
+
type: 'ACCESSES',
|
|
742
|
+
confidence: 1.0,
|
|
743
|
+
reason: 'write',
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
};
|
|
747
|
+
/**
|
|
748
|
+
* Resolve pre-extracted Laravel routes to CALLS edges from route files to controller methods.
|
|
749
|
+
*/
|
|
750
|
+
export const processRoutesFromExtracted = async (graph, extractedRoutes, ctx, onProgress) => {
|
|
751
|
+
for (let i = 0; i < extractedRoutes.length; i++) {
|
|
752
|
+
const route = extractedRoutes[i];
|
|
753
|
+
if (i % 50 === 0) {
|
|
754
|
+
onProgress?.(i, extractedRoutes.length);
|
|
755
|
+
await yieldToEventLoop();
|
|
756
|
+
}
|
|
757
|
+
if (!route.controllerName || !route.methodName)
|
|
758
|
+
continue;
|
|
759
|
+
const controllerResolved = ctx.resolve(route.controllerName, route.filePath);
|
|
760
|
+
if (!controllerResolved || controllerResolved.candidates.length === 0)
|
|
761
|
+
continue;
|
|
762
|
+
if (controllerResolved.tier === 'global' && controllerResolved.candidates.length > 1)
|
|
763
|
+
continue;
|
|
764
|
+
const controllerDef = controllerResolved.candidates[0];
|
|
765
|
+
const confidence = TIER_CONFIDENCE[controllerResolved.tier];
|
|
766
|
+
const methodResolved = ctx.resolve(route.methodName, controllerDef.filePath);
|
|
767
|
+
const methodId = methodResolved?.tier === 'same-file' ? methodResolved.candidates[0]?.nodeId : undefined;
|
|
768
|
+
const sourceId = generateId('File', route.filePath);
|
|
769
|
+
if (!methodId) {
|
|
770
|
+
const guessedId = generateId('Method', `${controllerDef.filePath}:${route.methodName}`);
|
|
771
|
+
const relId = generateId('CALLS', `${sourceId}:route->${guessedId}`);
|
|
772
|
+
graph.addRelationship({
|
|
773
|
+
id: relId,
|
|
774
|
+
sourceId,
|
|
775
|
+
targetId: guessedId,
|
|
776
|
+
type: 'CALLS',
|
|
777
|
+
confidence: confidence * 0.8,
|
|
778
|
+
reason: 'laravel-route',
|
|
779
|
+
});
|
|
780
|
+
continue;
|
|
781
|
+
}
|
|
782
|
+
const relId = generateId('CALLS', `${sourceId}:route->${methodId}`);
|
|
783
|
+
graph.addRelationship({
|
|
784
|
+
id: relId,
|
|
785
|
+
sourceId,
|
|
786
|
+
targetId: methodId,
|
|
787
|
+
type: 'CALLS',
|
|
788
|
+
confidence,
|
|
789
|
+
reason: 'laravel-route',
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
onProgress?.(extractedRoutes.length, extractedRoutes.length);
|
|
793
|
+
};
|