devlensio 1.0.4 → 1.1.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/dist/graph/edges/callEdges.js +97 -5
- package/dist/graph/edges/callEdges.test.js +154 -0
- package/dist/parser/extractors/classes.js +23 -4
- package/dist/parser/extractors/functions.d.ts +10 -3
- package/dist/parser/extractors/functions.js +135 -65
- package/dist/parser/extractors/hooks.js +5 -29
- package/dist/parser/extractors/objectMethods.js +3 -4
- package/dist/parser/index.js +11 -6
- package/dist/parser/overloads.d.ts +2 -0
- package/dist/parser/overloads.js +96 -0
- package/dist/parser/overloads.test.d.ts +1 -0
- package/dist/parser/overloads.test.js +175 -0
- package/dist/parser/typeUtils.d.ts +2 -0
- package/dist/parser/typeUtils.js +2 -0
- package/dist/types.d.ts +6 -0
- package/extractors/go/bin/darwin-amd64/devlens_go_extractor +0 -0
- package/extractors/go/bin/darwin-arm64/devlens_go_extractor +0 -0
- package/extractors/go/bin/linux-amd64/devlens_go_extractor +0 -0
- package/extractors/go/bin/linux-arm64/devlens_go_extractor +0 -0
- package/extractors/go/bin/windows-amd64/devlens_go_extractor.exe +0 -0
- package/extractors/go/calls.go +134 -2
- package/extractors/go/contract.go +1 -0
- package/extractors/go/exports.go +107 -0
- package/extractors/go/exports_test.go +64 -0
- package/extractors/go/extractor.go +1 -0
- package/extractors/go/lookup.go +63 -7
- package/extractors/go/nodes.go +28 -0
- package/extractors/go/parser.go +51 -8
- package/extractors/java/devlens_java_extractor.jar +0 -0
- package/extractors/java/src/devlens/extractor/ExportsMapBuilder.java +148 -0
- package/extractors/java/src/devlens/extractor/Extractor.java +34 -0
- package/extractors/java/src/devlens/extractor/LookupMaps.java +12 -0
- package/extractors/java/src/devlens/extractor/Overloads.java +290 -0
- package/extractors/java/src/devlens/extractor/Parser.java +39 -0
- package/extractors/java/src/devlens/extractor/TypeSolverFactory.java +9 -0
- package/extractors/java/src/devlens/extractor/edges/Calls.java +42 -36
- package/extractors/java/src/devlens/extractor/edges/Routes.java +10 -3
- package/extractors/python/devlens_extractors_python/edges/calls.py +127 -26
- package/extractors/python/devlens_extractors_python/exports_map.py +263 -0
- package/extractors/python/devlens_extractors_python/extractor.py +7 -1
- package/extractors/python/devlens_extractors_python/lookup.py +6 -0
- package/extractors/python/devlens_extractors_python/parser/__init__.py +6 -0
- package/extractors/python/devlens_extractors_python/parser/functions.py +103 -6
- package/extractors/python/devlens_extractors_python/parser/overloads.py +105 -0
- package/extractors/rust/bin/darwin-amd64/devlens_rust_extractor +0 -0
- package/extractors/rust/bin/darwin-arm64/devlens_rust_extractor +0 -0
- package/extractors/rust/bin/linux-amd64/devlens_rust_extractor +0 -0
- package/extractors/rust/bin/linux-arm64/devlens_rust_extractor +0 -0
- package/extractors/rust/bin/windows-amd64/devlens_rust_extractor.exe +0 -0
- package/extractors/rust/src/calls.rs +27 -16
- package/extractors/rust/src/contract.rs +2 -0
- package/extractors/rust/src/exports.rs +387 -0
- package/extractors/rust/src/exports_test.rs +105 -0
- package/extractors/rust/src/extractor.rs +2 -0
- package/extractors/rust/src/lookup.rs +60 -0
- package/extractors/rust/src/main.rs +4 -0
- package/extractors/rust/src/nodes.rs +19 -0
- package/extractors/rust/src/parser.rs +66 -1
- package/extractors/rust/src/routes.rs +2 -1
- package/package.json +1 -1
|
@@ -1,32 +1,10 @@
|
|
|
1
1
|
import { SyntaxKind } from "ts-morph";
|
|
2
2
|
import { detectFunctionDirective } from "../directives.js";
|
|
3
3
|
import { extractParams, extractBareTypeNames, extractReferencedInterfaces, } from "../typeUtils.js";
|
|
4
|
-
import {
|
|
4
|
+
import { extractCallsWithSites } from "./functions.js";
|
|
5
5
|
function makeId(filePath, name) {
|
|
6
6
|
return `${filePath}::${name}`;
|
|
7
7
|
}
|
|
8
|
-
function extractDependencies(node) {
|
|
9
|
-
const calls = node.getDescendantsOfKind(SyntaxKind.CallExpression);
|
|
10
|
-
const deps = [];
|
|
11
|
-
for (const call of calls) {
|
|
12
|
-
const name = call.getExpression().getText();
|
|
13
|
-
if (name.startsWith("use")) {
|
|
14
|
-
deps.push(name);
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
return [...new Set(deps)];
|
|
18
|
-
}
|
|
19
|
-
function extractContextRefs(node) {
|
|
20
|
-
const refs = [];
|
|
21
|
-
for (const call of node.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
22
|
-
if (call.getExpression().getText() === "useContext") {
|
|
23
|
-
const arg = call.getArguments()[0];
|
|
24
|
-
if (arg)
|
|
25
|
-
refs.push(arg.getText());
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
return [...new Set(refs)];
|
|
29
|
-
}
|
|
30
8
|
// Try explicit annotation first; fall back to shape heuristic.
|
|
31
9
|
function extractReturnType(node) {
|
|
32
10
|
const explicit = node.getReturnTypeNode?.()?.getText();
|
|
@@ -58,11 +36,9 @@ export function extractHooks(file, fileDirective = null) {
|
|
|
58
36
|
// Hooks must start with "use" followed by uppercase
|
|
59
37
|
if (!/^use[A-Z]/.test(name))
|
|
60
38
|
continue;
|
|
61
|
-
const dependencies =
|
|
62
|
-
const calls = extractFunctionCalls(fn);
|
|
39
|
+
const { calls, callSites, dependencyNames: dependencies, contextRefs } = extractCallsWithSites(fn);
|
|
63
40
|
const returnType = extractReturnType(fn);
|
|
64
41
|
const isAsync = fn.isAsync();
|
|
65
|
-
const contextRefs = extractContextRefs(fn);
|
|
66
42
|
const renderingBoundary = detectFunctionDirective(fn.getBody()) ?? fileDirective;
|
|
67
43
|
const typedParams = extractParams(fn);
|
|
68
44
|
const bareTypeNames = extractBareTypeNames([...typedParams.map((p) => p.type), returnType]);
|
|
@@ -78,6 +54,7 @@ export function extractHooks(file, fileDirective = null) {
|
|
|
78
54
|
metadata: {
|
|
79
55
|
dependencies,
|
|
80
56
|
calls,
|
|
57
|
+
callSites,
|
|
81
58
|
contextRefs,
|
|
82
59
|
returnType,
|
|
83
60
|
parameters: typedParams,
|
|
@@ -100,11 +77,9 @@ export function extractHooks(file, fileDirective = null) {
|
|
|
100
77
|
const isArrow = initializer.getKind() === SyntaxKind.ArrowFunction;
|
|
101
78
|
if (!isArrow)
|
|
102
79
|
continue;
|
|
103
|
-
const dependencies =
|
|
104
|
-
const calls = extractFunctionCalls(initializer);
|
|
80
|
+
const { calls, callSites, dependencyNames: dependencies, contextRefs } = extractCallsWithSites(initializer);
|
|
105
81
|
const returnType = extractReturnType(initializer);
|
|
106
82
|
const isAsync = initializer.asKind(SyntaxKind.ArrowFunction)?.isAsync() ?? false;
|
|
107
|
-
const contextRefs = extractContextRefs(initializer);
|
|
108
83
|
const renderingBoundary = detectFunctionDirective(initializer.getBody?.()) ?? fileDirective;
|
|
109
84
|
const typedParams = extractParams(initializer);
|
|
110
85
|
const bareTypeNames = extractBareTypeNames([...typedParams.map((p) => p.type), returnType]);
|
|
@@ -120,6 +95,7 @@ export function extractHooks(file, fileDirective = null) {
|
|
|
120
95
|
metadata: {
|
|
121
96
|
dependencies,
|
|
122
97
|
calls,
|
|
98
|
+
callSites,
|
|
123
99
|
contextRefs,
|
|
124
100
|
returnType,
|
|
125
101
|
parameters: typedParams,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { SyntaxKind } from "ts-morph";
|
|
2
2
|
import { detectFunctionDirective } from "../directives.js";
|
|
3
|
-
import {
|
|
3
|
+
import { extractCallsWithSites, hasErrorHandling, extractThrowStatements, } from "./functions.js";
|
|
4
4
|
import { extractParams, extractReturnTypeAnnotation, extractBareTypeNames, extractReferencedInterfaces, } from "../typeUtils.js";
|
|
5
5
|
// Extracts function-valued properties of object literals as FUNCTION nodes.
|
|
6
6
|
//
|
|
@@ -42,9 +42,7 @@ function isAsyncFn(fnNode) {
|
|
|
42
42
|
function buildNode(file, dottedName, fnNode, spanNode, fileDirective) {
|
|
43
43
|
const filePath = file.getFilePath();
|
|
44
44
|
const typedParams = extractParams(fnNode);
|
|
45
|
-
const calls =
|
|
46
|
-
const hookCalls = extractHookCalls(fnNode);
|
|
47
|
-
const apiCalls = extractApiCalls(fnNode);
|
|
45
|
+
const { calls, callSites, hookCalls, apiCalls } = extractCallsWithSites(fnNode);
|
|
48
46
|
const isAsync = isAsyncFn(fnNode);
|
|
49
47
|
const hasErrors = hasErrorHandling(fnNode);
|
|
50
48
|
const throws = extractThrowStatements(fnNode);
|
|
@@ -66,6 +64,7 @@ function buildNode(file, dottedName, fnNode, spanNode, fileDirective) {
|
|
|
66
64
|
returnType,
|
|
67
65
|
referencedTypes,
|
|
68
66
|
calls,
|
|
67
|
+
callSites,
|
|
69
68
|
hookCalls,
|
|
70
69
|
apiCalls,
|
|
71
70
|
isAsync,
|
package/dist/parser/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import { extractStores } from "./extractors/stores.js";
|
|
|
8
8
|
import { extractObjectMethods } from "./extractors/objectMethods.js";
|
|
9
9
|
import { extractClasses } from "./extractors/classes.js";
|
|
10
10
|
import { detectFileDirective } from "./directives.js";
|
|
11
|
+
import { applyOverloadDisambiguation } from "./overloads.js";
|
|
11
12
|
import { createHash } from "crypto";
|
|
12
13
|
// Directories to skip entirely while walking
|
|
13
14
|
const IGNORE_DIRS = [
|
|
@@ -123,13 +124,17 @@ export function parseRepo(repoPath) {
|
|
|
123
124
|
node.codeHash = createHash("sha256").update(node.rawCode).digest("hex").slice(0, 16);
|
|
124
125
|
}
|
|
125
126
|
}
|
|
127
|
+
// Same-name function-likes in one file (overloads) get arity/signature
|
|
128
|
+
// discriminator suffixes; signature-identical declarations collapse.
|
|
129
|
+
applyOverloadDisambiguation(extracted, relativePath);
|
|
130
|
+
const overloads = extracted.filter((n) => !n.metadata.isDuplicateOverload);
|
|
126
131
|
if (fileType === "TEST" || fileType === "STORY") {
|
|
127
132
|
// Do not add child nodes for test/story files — they are test helpers,
|
|
128
|
-
fileNode.metadata.testCases =
|
|
133
|
+
fileNode.metadata.testCases = overloads.map(n => n.name);
|
|
129
134
|
fileNode.metadata.nodeCount = 0;
|
|
130
135
|
fileNode.metadata.childNodeIds = [];
|
|
131
136
|
// File hash based on all child code combined
|
|
132
|
-
const fileRawCode =
|
|
137
|
+
const fileRawCode = overloads.map(n => n.rawCode ?? "").join("\n");
|
|
133
138
|
if (fileRawCode.trim()) {
|
|
134
139
|
fileNode.codeHash = createHash("sha256")
|
|
135
140
|
.update(fileRawCode).digest("hex").slice(0, 16);
|
|
@@ -137,14 +142,14 @@ export function parseRepo(repoPath) {
|
|
|
137
142
|
allNodes.push(fileNode); // only the file node, no children
|
|
138
143
|
}
|
|
139
144
|
else {
|
|
140
|
-
fileNode.metadata.nodeCount =
|
|
141
|
-
fileNode.metadata.childNodeIds =
|
|
145
|
+
fileNode.metadata.nodeCount = overloads.length;
|
|
146
|
+
fileNode.metadata.childNodeIds = overloads.map(n => n.id);
|
|
142
147
|
// File node hash — based on all child code combined
|
|
143
|
-
const fileRawCode =
|
|
148
|
+
const fileRawCode = overloads.map(n => n.rawCode ?? "").join("\n");
|
|
144
149
|
if (fileRawCode.trim()) {
|
|
145
150
|
fileNode.codeHash = createHash("sha256").update(fileRawCode).digest("hex").slice(0, 16);
|
|
146
151
|
}
|
|
147
|
-
allNodes.push(fileNode, ...
|
|
152
|
+
allNodes.push(fileNode, ...overloads);
|
|
148
153
|
}
|
|
149
154
|
}
|
|
150
155
|
catch (error) {
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Overload disambiguation — same-name function-likes in the same file.
|
|
2
|
+
//
|
|
3
|
+
// Node ids are `${relativePath}::${name}`, so a file with two `formatValue`
|
|
4
|
+
// functions (or an overloaded `Logger.log` method pair) produced two nodes
|
|
5
|
+
// with ONE id; downstream dedupe (buildGraphResponse, summarizer, storage)
|
|
6
|
+
// silently dropped all but the first and every CALLS edge picked arbitrarily.
|
|
7
|
+
//
|
|
8
|
+
// Convention (mirrored by the Go/Java/Python/Rust extractors — keep in sync,
|
|
9
|
+
// see MULTI_LANGUAGE_EXPANSION.md):
|
|
10
|
+
// - Unique name in file → id unchanged (`utils.ts::formatValue`)
|
|
11
|
+
// - Collision, distinct arities → `#arity` suffix (`utils.ts::formatValue#2`)
|
|
12
|
+
// - Collision, same arity, different param types → `#arity#<sig8>` where
|
|
13
|
+
// sig8 = first 8 hex of sha256 over the normalized param-type list
|
|
14
|
+
// - Arity-suffix never uses ordinals: the suffix is the declaration's own
|
|
15
|
+
// parameter count, so reordering functions in a file cannot change ids
|
|
16
|
+
//
|
|
17
|
+
// Signature-identical declarations (a TS overload signature plus an
|
|
18
|
+
// implementation with the same param list) collapse into ONE node, keeping
|
|
19
|
+
// the one with a body — they are the same callable.
|
|
20
|
+
import { createHash } from "crypto";
|
|
21
|
+
const OVERLOADABLE_TYPES = new Set(["FUNCTION", "METHOD"]);
|
|
22
|
+
function paramTypes(node) {
|
|
23
|
+
const parameters = node.metadata?.parameters;
|
|
24
|
+
if (!Array.isArray(parameters))
|
|
25
|
+
return [];
|
|
26
|
+
return parameters.map((p) => {
|
|
27
|
+
const t = typeof p?.type === "string" && p.type.length > 0 ? p.type : "unknown";
|
|
28
|
+
if (p?.isRest)
|
|
29
|
+
return `...${t}`;
|
|
30
|
+
if (p?.isOptional)
|
|
31
|
+
return `${t}?`;
|
|
32
|
+
return t;
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
function signatureKey(node) {
|
|
36
|
+
return `(${paramTypes(node).join(",")})`;
|
|
37
|
+
}
|
|
38
|
+
function hasBody(node) {
|
|
39
|
+
return node.metadata?.isOverloadSignature !== true;
|
|
40
|
+
}
|
|
41
|
+
export function applyOverloadDisambiguation(nodes, relativePath) {
|
|
42
|
+
// Group function-likes by name (METHOD names are dotted `Class.method`, so
|
|
43
|
+
// same-name methods on different classes never collide here).
|
|
44
|
+
const groups = new Map();
|
|
45
|
+
for (const node of nodes) {
|
|
46
|
+
if (!OVERLOADABLE_TYPES.has(node.type))
|
|
47
|
+
continue;
|
|
48
|
+
if (!groups.has(node.name))
|
|
49
|
+
groups.set(node.name, []);
|
|
50
|
+
groups.get(node.name).push(node);
|
|
51
|
+
}
|
|
52
|
+
for (const [name, group] of groups) {
|
|
53
|
+
if (group.length < 2)
|
|
54
|
+
continue;
|
|
55
|
+
group.sort((a, b) => a.startLine - b.startLine);
|
|
56
|
+
// Collapse signature-identical declarations, preferring the one with a body.
|
|
57
|
+
// Single pass — each node's signature key is computed exactly once.
|
|
58
|
+
const bySignature = new Map();
|
|
59
|
+
const keys = new Map();
|
|
60
|
+
for (const node of group) {
|
|
61
|
+
const key = signatureKey(node);
|
|
62
|
+
keys.set(node, key);
|
|
63
|
+
const existing = bySignature.get(key);
|
|
64
|
+
if (!existing) {
|
|
65
|
+
bySignature.set(key, node);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
// Keep the implementation; drop the bare signature duplicate.
|
|
69
|
+
if (!hasBody(existing) && hasBody(node))
|
|
70
|
+
bySignature.set(key, node);
|
|
71
|
+
}
|
|
72
|
+
const survivors = [...bySignature.values()].sort((a, b) => a.startLine - b.startLine);
|
|
73
|
+
if (survivors.length < group.length) {
|
|
74
|
+
const kept = new Set(survivors);
|
|
75
|
+
for (const node of group) {
|
|
76
|
+
if (!kept.has(node))
|
|
77
|
+
node.metadata.isDuplicateOverload = true;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// Single survivor after collapse → the name is unique again, no suffix.
|
|
81
|
+
if (survivors.length < 2)
|
|
82
|
+
continue;
|
|
83
|
+
// Distinct arities within the group → readable `#arity` suffix.
|
|
84
|
+
const arities = new Set(survivors.map((n) => n.metadata?.params?.length ?? 0));
|
|
85
|
+
const useArityOnly = arities.size === survivors.length;
|
|
86
|
+
for (const node of survivors) {
|
|
87
|
+
const arity = node.metadata?.params?.length ?? 0;
|
|
88
|
+
let suffix = `#${arity}`;
|
|
89
|
+
if (!useArityOnly) {
|
|
90
|
+
const sig8 = createHash("sha256").update(keys.get(node)).digest("hex").slice(0, 8);
|
|
91
|
+
suffix = `#${arity}#${sig8}`;
|
|
92
|
+
}
|
|
93
|
+
node.id = `${relativePath}::${name}${suffix}`;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { parseRepo } from "./index.js";
|
|
5
|
+
function createFakeRepo(files) {
|
|
6
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "devlens-overloads-test-"));
|
|
7
|
+
for (const [filePath, content] of Object.entries(files)) {
|
|
8
|
+
const fullPath = path.join(tmpDir, filePath);
|
|
9
|
+
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
10
|
+
fs.writeFileSync(fullPath, content);
|
|
11
|
+
}
|
|
12
|
+
return tmpDir;
|
|
13
|
+
}
|
|
14
|
+
function deleteFakeRepo(repoPath) {
|
|
15
|
+
fs.rmSync(repoPath, { recursive: true, force: true });
|
|
16
|
+
}
|
|
17
|
+
describe("overload disambiguation (same-name function-likes in one file)", () => {
|
|
18
|
+
it("keeps the clean unsuffixed id when the name is unique in the file", () => {
|
|
19
|
+
const repo = createFakeRepo({
|
|
20
|
+
"src/unique.ts": `
|
|
21
|
+
export function formatValue(v: string) { return v.trim(); }
|
|
22
|
+
`,
|
|
23
|
+
});
|
|
24
|
+
const { nodes } = parseRepo(repo);
|
|
25
|
+
const fn = nodes.find((n) => n.name === "formatValue");
|
|
26
|
+
expect(fn?.id).toBe("src/unique.ts::formatValue");
|
|
27
|
+
expect(fn?.metadata.isOverloadSignature).toBeUndefined();
|
|
28
|
+
deleteFakeRepo(repo);
|
|
29
|
+
});
|
|
30
|
+
it("suffixes each same-name function with its OWN arity (no ordinals)", () => {
|
|
31
|
+
const repo = createFakeRepo({
|
|
32
|
+
"src/arity.ts": `
|
|
33
|
+
export function formatValue(v: string) { return v.trim(); }
|
|
34
|
+
export function formatValue(v: string, n: number) { return v.repeat(n); }
|
|
35
|
+
export function formatValue(v: string, n: number, flag: boolean) { return v; }
|
|
36
|
+
`,
|
|
37
|
+
});
|
|
38
|
+
const { nodes } = parseRepo(repo);
|
|
39
|
+
const ids = nodes.filter((n) => n.name === "formatValue").map((n) => n.id).sort();
|
|
40
|
+
expect(ids).toEqual([
|
|
41
|
+
"src/arity.ts::formatValue#1",
|
|
42
|
+
"src/arity.ts::formatValue#2",
|
|
43
|
+
"src/arity.ts::formatValue#3",
|
|
44
|
+
]);
|
|
45
|
+
deleteFakeRepo(repo);
|
|
46
|
+
});
|
|
47
|
+
it("suffixes same-arity different-type overloads with an arity+signature hash", () => {
|
|
48
|
+
const repo = createFakeRepo({
|
|
49
|
+
"src/types.ts": `
|
|
50
|
+
export function decode(input: string, mode: string) { return input; }
|
|
51
|
+
export function decode(input: number, mode: string) { return String(input); }
|
|
52
|
+
`,
|
|
53
|
+
});
|
|
54
|
+
const { nodes } = parseRepo(repo);
|
|
55
|
+
const fns = nodes.filter((n) => n.name === "decode");
|
|
56
|
+
expect(fns.length).toBe(2);
|
|
57
|
+
const ids = fns.map((n) => n.id).sort();
|
|
58
|
+
// both share arity 2, differ only by types → distinct sig hashes
|
|
59
|
+
expect(ids[0]).toMatch(/^src\/types\.ts::decode#2#[0-9a-f]{8}$/);
|
|
60
|
+
expect(ids[1]).toMatch(/^src\/types\.ts::decode#2#[0-9a-f]{8}$/);
|
|
61
|
+
expect(ids[0]).not.toBe(ids[1]);
|
|
62
|
+
// deterministic: same signature → same hash across runs
|
|
63
|
+
expect(ids).toEqual([...ids].sort());
|
|
64
|
+
deleteFakeRepo(repo);
|
|
65
|
+
});
|
|
66
|
+
it("collapses an overload signature into a signature-identical implementation", () => {
|
|
67
|
+
const repo = createFakeRepo({
|
|
68
|
+
"src/collapse.ts": `
|
|
69
|
+
export function greet(name: string): string;
|
|
70
|
+
export function greet(name: string) { return "hi " + name; }
|
|
71
|
+
`,
|
|
72
|
+
});
|
|
73
|
+
const { nodes } = parseRepo(repo);
|
|
74
|
+
const greets = nodes.filter((n) => n.name === "greet");
|
|
75
|
+
expect(greets.length).toBe(1);
|
|
76
|
+
expect(greets[0].id).toBe("src/collapse.ts::greet");
|
|
77
|
+
expect(greets[0].rawCode).toContain("return");
|
|
78
|
+
deleteFakeRepo(repo);
|
|
79
|
+
});
|
|
80
|
+
it("keeps an overload signature separate when the implementation has different params", () => {
|
|
81
|
+
const repo = createFakeRepo({
|
|
82
|
+
"src/keepboth.ts": `
|
|
83
|
+
export function parse(input: string): string;
|
|
84
|
+
export function parse(input: string | number) { return String(input); }
|
|
85
|
+
`,
|
|
86
|
+
});
|
|
87
|
+
const { nodes } = parseRepo(repo);
|
|
88
|
+
const parses = nodes.filter((n) => n.name === "parse");
|
|
89
|
+
expect(parses.length).toBe(2);
|
|
90
|
+
// one of them is the bare signature
|
|
91
|
+
const sig = parses.find((n) => n.metadata.isOverloadSignature === true);
|
|
92
|
+
const impl = parses.find((n) => n.metadata.isOverloadSignature === undefined);
|
|
93
|
+
expect(sig).toBeDefined();
|
|
94
|
+
expect(impl).toBeDefined();
|
|
95
|
+
// distinct ids, both arity 1 with different type shapes → hash suffixes
|
|
96
|
+
expect(sig.id).not.toBe(impl.id);
|
|
97
|
+
expect(sig.id).toMatch(/^src\/keepboth\.ts::parse#1#[0-9a-f]{8}$/);
|
|
98
|
+
deleteFakeRepo(repo);
|
|
99
|
+
});
|
|
100
|
+
it("suffixes class method overloads with dotted names, not cross-class names", () => {
|
|
101
|
+
const repo = createFakeRepo({
|
|
102
|
+
"src/logger.ts": `
|
|
103
|
+
export class Logger {
|
|
104
|
+
log(msg: string) { console.log(msg); }
|
|
105
|
+
log(msg: string, level: number) { console.log(level, msg); }
|
|
106
|
+
}
|
|
107
|
+
export class Auditor {
|
|
108
|
+
log(entry: string) { console.log(entry); }
|
|
109
|
+
}
|
|
110
|
+
`,
|
|
111
|
+
});
|
|
112
|
+
const { nodes } = parseRepo(repo);
|
|
113
|
+
const logMethods = nodes.filter((n) => n.type === "METHOD" && n.name.endsWith(".log"));
|
|
114
|
+
const ids = logMethods.map((n) => n.id).sort();
|
|
115
|
+
expect(ids).toEqual([
|
|
116
|
+
"src/logger.ts::Auditor.log", // unique within its class → no suffix
|
|
117
|
+
"src/logger.ts::Logger.log#1",
|
|
118
|
+
"src/logger.ts::Logger.log#2",
|
|
119
|
+
]);
|
|
120
|
+
deleteFakeRepo(repo);
|
|
121
|
+
});
|
|
122
|
+
it("never changes ids of different-name or different-file nodes", () => {
|
|
123
|
+
const repo = createFakeRepo({
|
|
124
|
+
"src/a.ts": `
|
|
125
|
+
export function helper() { return 1; }
|
|
126
|
+
`,
|
|
127
|
+
"src/b.ts": `
|
|
128
|
+
export function helper() { return 2; }
|
|
129
|
+
`,
|
|
130
|
+
});
|
|
131
|
+
const { nodes } = parseRepo(repo);
|
|
132
|
+
const helpers = nodes.filter((n) => n.name === "helper").map((n) => n.id).sort();
|
|
133
|
+
expect(helpers).toEqual(["src/a.ts::helper", "src/b.ts::helper"]);
|
|
134
|
+
deleteFakeRepo(repo);
|
|
135
|
+
});
|
|
136
|
+
it("captures callSites with argCount, argTypes and hasSpread", () => {
|
|
137
|
+
const repo = createFakeRepo({
|
|
138
|
+
"src/caller.ts": `
|
|
139
|
+
export function run() {
|
|
140
|
+
formatValue("abc");
|
|
141
|
+
formatValue("abc", 3);
|
|
142
|
+
logAll("a", "b", ...rest);
|
|
143
|
+
}
|
|
144
|
+
`,
|
|
145
|
+
});
|
|
146
|
+
const { nodes } = parseRepo(repo);
|
|
147
|
+
const run = nodes.find((n) => n.name === "run");
|
|
148
|
+
const sites = run?.metadata.callSites;
|
|
149
|
+
expect(sites).toBeDefined();
|
|
150
|
+
const byName = new Map(sites.map((s) => [`${s.name}/${s.argCount}`, s]));
|
|
151
|
+
expect(byName.get("formatValue/1")?.argTypes).toEqual(["string"]);
|
|
152
|
+
expect(byName.get("formatValue/2")?.argTypes).toEqual(["string", "number"]);
|
|
153
|
+
expect(byName.get("logAll/3")?.hasSpread).toBe(true);
|
|
154
|
+
// legacy calls list stays in sync
|
|
155
|
+
expect(run?.metadata.calls).toContain("formatValue");
|
|
156
|
+
deleteFakeRepo(repo);
|
|
157
|
+
});
|
|
158
|
+
it("does not treat same-name functions in different files as overloads (per-file scope)", () => {
|
|
159
|
+
const repo = createFakeRepo({
|
|
160
|
+
"src/x.ts": `
|
|
161
|
+
export function handle(a: string) { return a; }
|
|
162
|
+
`,
|
|
163
|
+
"src/y.ts": `
|
|
164
|
+
export function handle(a: string, b: string) { return a + b; }
|
|
165
|
+
`,
|
|
166
|
+
});
|
|
167
|
+
const { nodes } = parseRepo(repo);
|
|
168
|
+
const handlers = nodes.filter((n) => n.name === "handle");
|
|
169
|
+
expect(handlers.map((n) => n.id).sort()).toEqual([
|
|
170
|
+
"src/x.ts::handle",
|
|
171
|
+
"src/y.ts::handle",
|
|
172
|
+
]);
|
|
173
|
+
deleteFakeRepo(repo);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
@@ -2,6 +2,8 @@ import { SourceFile } from "ts-morph";
|
|
|
2
2
|
export interface ParamInfo {
|
|
3
3
|
name: string;
|
|
4
4
|
type?: string;
|
|
5
|
+
isOptional?: boolean;
|
|
6
|
+
isRest?: boolean;
|
|
5
7
|
}
|
|
6
8
|
export declare function extractParams(node: any): ParamInfo[];
|
|
7
9
|
export declare function extractReturnTypeAnnotation(node: any): string | undefined;
|
package/dist/parser/typeUtils.js
CHANGED
|
@@ -3,6 +3,8 @@ export function extractParams(node) {
|
|
|
3
3
|
return params.map((p) => ({
|
|
4
4
|
name: p.getName(),
|
|
5
5
|
type: p.getTypeNode()?.getText() ?? undefined,
|
|
6
|
+
isOptional: typeof p.isOptional === "function" ? p.isOptional() : undefined,
|
|
7
|
+
isRest: typeof p.isRestParameter === "function" ? p.isRestParameter() : undefined,
|
|
6
8
|
}));
|
|
7
9
|
}
|
|
8
10
|
export function extractReturnTypeAnnotation(node) {
|
package/dist/types.d.ts
CHANGED
|
@@ -48,6 +48,12 @@ export interface BackendRouteNode {
|
|
|
48
48
|
};
|
|
49
49
|
}
|
|
50
50
|
export type NodeType = "COMPONENT" | "HOOK" | "FUNCTION" | "STATE_STORE" | "UTILITY" | "FILE" | "GHOST" | "ROUTE" | "TEST" | "STORY" | "THIRD_PARTY" | "CLASS" | "METHOD" | "INTERFACE" | "ENUM" | "STRUCT" | "MODULE" | "TRAIT" | "IMPL_BLOCK" | "PACKAGE";
|
|
51
|
+
export interface CallSite {
|
|
52
|
+
name: string;
|
|
53
|
+
argCount: number;
|
|
54
|
+
argTypes?: string[];
|
|
55
|
+
hasSpread?: boolean;
|
|
56
|
+
}
|
|
51
57
|
export interface CodeNode {
|
|
52
58
|
id: string;
|
|
53
59
|
name: string;
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/extractors/go/calls.go
CHANGED
|
@@ -152,8 +152,11 @@ func resolveCall(c CallSite, fn *ParsedFunc, pf *ParsedFile, info *types.Info,
|
|
|
152
152
|
if id, ok := l.MethodNodeByPkgTypeName[pkgPath+"::"+c.Receiver+"."+c.Sel]; ok {
|
|
153
153
|
return id, c.Sel, c.Receiver, callEdge(callerID, id, c.Sel, c.Receiver)
|
|
154
154
|
}
|
|
155
|
-
// receiver is a plain variable whose type we can't see —
|
|
156
|
-
//
|
|
155
|
+
// receiver is a plain variable whose type we can't see — bare-method-name
|
|
156
|
+
// candidates across packages, filtered by arity/type shape
|
|
157
|
+
if id, matchedBy := resolveByCandidates(l.MethodCandidatesByName[c.Sel], c, pf.RelPath); id != "" {
|
|
158
|
+
return id, c.Sel, "", callEdgeMatched(callerID, id, c.Sel, "", matchedBy)
|
|
159
|
+
}
|
|
157
160
|
return "", "", "", nil
|
|
158
161
|
}
|
|
159
162
|
|
|
@@ -161,6 +164,11 @@ func resolveCall(c CallSite, fn *ParsedFunc, pf *ParsedFile, info *types.Info,
|
|
|
161
164
|
if id, ok := l.FuncNodeByPkgName[pkgPath+"::"+c.Str]; ok {
|
|
162
165
|
return id, "", "", callEdge(callerID, id, "", "")
|
|
163
166
|
}
|
|
167
|
+
// same-package miss: bare-name candidates across packages, filtered by
|
|
168
|
+
// arity/type shape (fire only where resolution previously gave up)
|
|
169
|
+
if id, matchedBy := resolveByCandidates(l.FuncCandidatesByName[c.Str], c, pf.RelPath); id != "" {
|
|
170
|
+
return id, "", "", callEdgeMatched(callerID, id, "", "", matchedBy)
|
|
171
|
+
}
|
|
164
172
|
return "", "", "", nil
|
|
165
173
|
}
|
|
166
174
|
|
|
@@ -175,6 +183,130 @@ func callEdge(from, to, method, class string) map[string]any {
|
|
|
175
183
|
return edgeWithMeta(from, to, EdgeCalls, meta)
|
|
176
184
|
}
|
|
177
185
|
|
|
186
|
+
// callEdgeMatched — callEdge plus resolution confidence from the arity-aware
|
|
187
|
+
// candidate tier: "arity" (arg count singled out the target), "signature"
|
|
188
|
+
// (arg type agreement decided it), "name" (path proximity fallback).
|
|
189
|
+
func callEdgeMatched(from, to, method, class, matchedBy string) map[string]any {
|
|
190
|
+
e := callEdge(from, to, method, class)
|
|
191
|
+
e["metadata"].(map[string]any)["matchedBy"] = matchedBy
|
|
192
|
+
return e
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── arity-aware candidate resolution ──
|
|
196
|
+
|
|
197
|
+
// arityAccepts — Go call compatibility: exact arg count, except a variadic
|
|
198
|
+
// tail accepts any count >= required. Spread calls (f(xs...)) have an
|
|
199
|
+
// unknowable runtime arity — every candidate stays admissible.
|
|
200
|
+
func arityAccepts(cand funcCandidate, argCount int, hasSpread bool) bool {
|
|
201
|
+
if hasSpread {
|
|
202
|
+
return true
|
|
203
|
+
}
|
|
204
|
+
if cand.Variadic {
|
|
205
|
+
return argCount >= cand.Required
|
|
206
|
+
}
|
|
207
|
+
return argCount == cand.Required
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// typeAgrees — loose agreement between a call-site arg tag and a declared
|
|
211
|
+
// param type text; "unknown" tags never match (parity with the TS resolver).
|
|
212
|
+
func typeAgrees(argType, paramType string) bool {
|
|
213
|
+
if argType == "" || argType == "unknown" || paramType == "" {
|
|
214
|
+
return false
|
|
215
|
+
}
|
|
216
|
+
return paramType == argType || strings.Contains(paramType, argType)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// typeScore — how many argument tags agree with the declared params.
|
|
220
|
+
func typeScore(cand funcCandidate, argTypes []string) int {
|
|
221
|
+
score := 0
|
|
222
|
+
for i, at := range argTypes {
|
|
223
|
+
if i < len(cand.ParamTypes) && typeAgrees(at, cand.ParamTypes[i]) {
|
|
224
|
+
score++
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return score
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// pathProximity — shared leading path segments between two rel paths
|
|
231
|
+
// (mirrors the TS engine's closestByPath tiebreak).
|
|
232
|
+
func pathProximity(candidate, reference string) int {
|
|
233
|
+
a := strings.Split(candidate, "/")
|
|
234
|
+
b := strings.Split(reference, "/")
|
|
235
|
+
score := 0
|
|
236
|
+
for i := 0; i < len(a) && i < len(b); i++ {
|
|
237
|
+
if a[i] != b[i] {
|
|
238
|
+
break
|
|
239
|
+
}
|
|
240
|
+
score++
|
|
241
|
+
}
|
|
242
|
+
return score
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// resolveByCandidates — pick among same-name declarations by argument shape:
|
|
246
|
+
// arity filter first, then arg-type agreement tiebreak, then path proximity.
|
|
247
|
+
// Single-candidate groups return immediately (matchedBy "arity" only when the
|
|
248
|
+
// call shape actually participated). Returns ("", "") when nothing applies.
|
|
249
|
+
func resolveByCandidates(candidates []funcCandidate, c CallSite, callerRel string) (string, string) {
|
|
250
|
+
if len(candidates) == 0 {
|
|
251
|
+
return "", ""
|
|
252
|
+
}
|
|
253
|
+
if len(candidates) == 1 {
|
|
254
|
+
// a lone same-name declaration elsewhere — arity still gates it
|
|
255
|
+
if arityAccepts(candidates[0], c.ArgCount, c.HasSpread) {
|
|
256
|
+
return candidates[0].ID, "arity"
|
|
257
|
+
}
|
|
258
|
+
return "", ""
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
pool := make([]funcCandidate, 0, len(candidates))
|
|
262
|
+
for _, cand := range candidates {
|
|
263
|
+
if arityAccepts(cand, c.ArgCount, c.HasSpread) {
|
|
264
|
+
pool = append(pool, cand)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if len(pool) == 0 {
|
|
268
|
+
return "", "" // nothing accepts this shape — stay unresolvable
|
|
269
|
+
}
|
|
270
|
+
if len(pool) == 1 {
|
|
271
|
+
return pool[0].ID, "arity"
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// type agreement tiebreak — needs usable (non-unknown) arg tags
|
|
275
|
+
usable := false
|
|
276
|
+
for _, at := range c.ArgTypes {
|
|
277
|
+
if at != "unknown" {
|
|
278
|
+
usable = true
|
|
279
|
+
break
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if usable {
|
|
283
|
+
best := pool[0]
|
|
284
|
+
bestScore := typeScore(pool[0], c.ArgTypes)
|
|
285
|
+
tie := 1
|
|
286
|
+
for _, cand := range pool[1:] {
|
|
287
|
+
s := typeScore(cand, c.ArgTypes)
|
|
288
|
+
if s > bestScore {
|
|
289
|
+
best, bestScore, tie = cand, s, 1
|
|
290
|
+
} else if s == bestScore {
|
|
291
|
+
tie++
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if bestScore > 0 && tie == 1 {
|
|
295
|
+
return best.ID, "signature"
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// path proximity fallback
|
|
300
|
+
best := pool[0]
|
|
301
|
+
bestScore := pathProximity(best.RelPath, callerRel)
|
|
302
|
+
for _, cand := range pool[1:] {
|
|
303
|
+
if s := pathProximity(cand.RelPath, callerRel); s > bestScore {
|
|
304
|
+
best, bestScore = cand, s
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return best.ID, "name"
|
|
308
|
+
}
|
|
309
|
+
|
|
178
310
|
// receiverDeclaredType — the receiver's type from its DECLARATION: function
|
|
179
311
|
// parameters first (func(c *gin.Context) — the documented handler signature),
|
|
180
312
|
// then package-level typed vars (var DB *gorm.DB). Returns (importPath,
|
|
@@ -89,6 +89,7 @@ type ExtractorResult struct {
|
|
|
89
89
|
Nodes []map[string]any `json:"nodes"`
|
|
90
90
|
Edges []map[string]any `json:"edges"`
|
|
91
91
|
Routes []map[string]any `json:"routes"`
|
|
92
|
+
Exports *ExportsMap `json:"exports,omitempty"`
|
|
92
93
|
Stats Stats `json:"stats"`
|
|
93
94
|
Errors []ExtractorError `json:"errors"`
|
|
94
95
|
}
|