universal-ast-mapper 0.5.2
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/BLUEPRINT.md +230 -0
- package/README.md +465 -0
- package/dist/analysis.js +134 -0
- package/dist/callgraph.js +238 -0
- package/dist/cli.js +617 -0
- package/dist/config.js +53 -0
- package/dist/extractors/common.js +54 -0
- package/dist/extractors/go.js +212 -0
- package/dist/extractors/python.js +142 -0
- package/dist/extractors/typescript.js +320 -0
- package/dist/graph-analysis.js +243 -0
- package/dist/graph.js +118 -0
- package/dist/html.js +325 -0
- package/dist/index.js +762 -0
- package/dist/parser.js +84 -0
- package/dist/registry.js +40 -0
- package/dist/resolver.js +131 -0
- package/dist/search.js +68 -0
- package/dist/skeleton.js +106 -0
- package/dist/types.js +5 -0
- package/package.json +44 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parseSource } from "./parser.js";
|
|
4
|
+
import { buildSkeleton } from "./skeleton.js";
|
|
5
|
+
import { resolveOptions, loadProjectConfig } from "./config.js";
|
|
6
|
+
import { detectLanguage } from "./registry.js";
|
|
7
|
+
import { resolveImportPath } from "./resolver.js";
|
|
8
|
+
/** Recursively collect call expressions from a subtree. */
|
|
9
|
+
function collectCalls(node, out) {
|
|
10
|
+
// TypeScript / JavaScript / Go use "call_expression"; Python uses "call"
|
|
11
|
+
if (node.type === "call_expression" || node.type === "call") {
|
|
12
|
+
const fn = node.childForFieldName("function");
|
|
13
|
+
if (fn) {
|
|
14
|
+
let callee = null;
|
|
15
|
+
if (fn.type === "identifier") {
|
|
16
|
+
callee = fn.text;
|
|
17
|
+
}
|
|
18
|
+
else if (fn.type === "member_expression" || fn.type === "attribute") {
|
|
19
|
+
// TS/JS: member_expression with "object"/"property" fields
|
|
20
|
+
// Python: attribute with "object"/"attribute" fields
|
|
21
|
+
const obj = fn.childForFieldName("object");
|
|
22
|
+
const prop = fn.childForFieldName("property") ?? fn.childForFieldName("attribute");
|
|
23
|
+
if (prop)
|
|
24
|
+
callee = obj ? `${obj.text}.${prop.text}` : prop.text;
|
|
25
|
+
}
|
|
26
|
+
if (callee)
|
|
27
|
+
out.push({ callee, line: fn.startPosition.row + 1 });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
for (let i = 0; i < node.namedChildCount; i++) {
|
|
31
|
+
const c = node.namedChild(i);
|
|
32
|
+
if (c)
|
|
33
|
+
collectCalls(c, out);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Walk the AST and return the first function/method node whose declared name
|
|
38
|
+
* matches `name`. Handles:
|
|
39
|
+
* - function declarations (TS/JS/Go)
|
|
40
|
+
* - const/let arrow functions and function expressions (TS/JS)
|
|
41
|
+
* - method definitions inside classes (TS/JS)
|
|
42
|
+
* - function_definition (Python)
|
|
43
|
+
* - method_declaration (Go)
|
|
44
|
+
*/
|
|
45
|
+
function findFunctionNode(root, name) {
|
|
46
|
+
function walk(node) {
|
|
47
|
+
const t = node.type;
|
|
48
|
+
// Direct named functions / methods
|
|
49
|
+
if (t === "function_declaration" ||
|
|
50
|
+
t === "generator_function_declaration" ||
|
|
51
|
+
t === "method_definition" ||
|
|
52
|
+
t === "method_signature" ||
|
|
53
|
+
t === "abstract_method_signature" ||
|
|
54
|
+
t === "function_definition" || // Python
|
|
55
|
+
t === "async_function_definition" || // Python async
|
|
56
|
+
t === "method_declaration" // Go
|
|
57
|
+
) {
|
|
58
|
+
if (node.childForFieldName("name")?.text === name)
|
|
59
|
+
return node;
|
|
60
|
+
}
|
|
61
|
+
// const foo = () => ... or const foo = function() ...
|
|
62
|
+
if (t === "variable_declarator") {
|
|
63
|
+
const declName = node.childForFieldName("name")?.text;
|
|
64
|
+
const value = node.childForFieldName("value");
|
|
65
|
+
if (declName === name &&
|
|
66
|
+
value &&
|
|
67
|
+
(value.type === "arrow_function" ||
|
|
68
|
+
value.type === "function" ||
|
|
69
|
+
value.type === "function_expression")) {
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// Recurse
|
|
74
|
+
for (let i = 0; i < node.namedChildCount; i++) {
|
|
75
|
+
const c = node.namedChild(i);
|
|
76
|
+
if (c) {
|
|
77
|
+
const found = walk(c);
|
|
78
|
+
if (found)
|
|
79
|
+
return found;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
return walk(root);
|
|
85
|
+
}
|
|
86
|
+
// ─── Destructuring alias tracker ─────────────────────────────────────────────
|
|
87
|
+
/**
|
|
88
|
+
* Walk a subtree and collect variable destructuring patterns where the source
|
|
89
|
+
* is a known import. Handles:
|
|
90
|
+
* const { sign, verify } = jwt; → sign/verify → (jwt's source)
|
|
91
|
+
* const { readFile: rf } = fs; → rf → (fs's source)
|
|
92
|
+
* let { a, b } = someNamespace.nested; → a/b → (someNamespace's source)
|
|
93
|
+
*
|
|
94
|
+
* Returns a map of localAlias → moduleSpecifier (same format as importMap).
|
|
95
|
+
*/
|
|
96
|
+
function collectDestructuredAliases(node, importMap) {
|
|
97
|
+
const aliases = new Map();
|
|
98
|
+
function walk(n) {
|
|
99
|
+
if (n.type === "variable_declarator") {
|
|
100
|
+
const nameNode = n.childForFieldName("name");
|
|
101
|
+
const valueNode = n.childForFieldName("value");
|
|
102
|
+
if (nameNode && valueNode && nameNode.type === "object_pattern") {
|
|
103
|
+
// value might be `jwt` or `jwt.utils` — base is the first identifier
|
|
104
|
+
const baseName = valueNode.text.split(".")[0];
|
|
105
|
+
const origin = importMap.get(baseName) ?? aliases.get(baseName);
|
|
106
|
+
if (origin) {
|
|
107
|
+
for (let i = 0; i < nameNode.namedChildCount; i++) {
|
|
108
|
+
const prop = nameNode.namedChild(i);
|
|
109
|
+
if (!prop)
|
|
110
|
+
continue;
|
|
111
|
+
// { sign } — shorthand
|
|
112
|
+
if (prop.type === "shorthand_property_identifier_pattern" ||
|
|
113
|
+
prop.type === "shorthand_property_identifier") {
|
|
114
|
+
aliases.set(prop.text, origin);
|
|
115
|
+
}
|
|
116
|
+
// { readFile: rf } — renamed
|
|
117
|
+
if (prop.type === "pair_pattern") {
|
|
118
|
+
const val = prop.childForFieldName("value");
|
|
119
|
+
if (val)
|
|
120
|
+
aliases.set(val.text, origin);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
for (let i = 0; i < n.namedChildCount; i++) {
|
|
127
|
+
const c = n.namedChild(i);
|
|
128
|
+
if (c)
|
|
129
|
+
walk(c);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
walk(node);
|
|
133
|
+
return aliases;
|
|
134
|
+
}
|
|
135
|
+
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
136
|
+
/**
|
|
137
|
+
* Build the call graph for a single named function.
|
|
138
|
+
*
|
|
139
|
+
* @param filePath Absolute path to the source file.
|
|
140
|
+
* @param funcName Name of the function/method to analyse.
|
|
141
|
+
* @param root Project root (for computing relative paths).
|
|
142
|
+
* @param allSkeletons Optional: pre-parsed skeletons of the whole project,
|
|
143
|
+
* used to find which files import (and thus call) this function.
|
|
144
|
+
*
|
|
145
|
+
* Returns null if the language is unsupported or the function is not found.
|
|
146
|
+
*/
|
|
147
|
+
export async function buildCallGraph(filePath, funcName, root, allSkeletons) {
|
|
148
|
+
const langEntry = detectLanguage(filePath);
|
|
149
|
+
if (!langEntry)
|
|
150
|
+
return null;
|
|
151
|
+
const source = fs.readFileSync(filePath, "utf8");
|
|
152
|
+
const relPath = path.relative(root, filePath).split(path.sep).join("/");
|
|
153
|
+
const rootNode = await parseSource(langEntry.grammar, source);
|
|
154
|
+
const funcNode = findFunctionNode(rootNode, funcName);
|
|
155
|
+
if (!funcNode)
|
|
156
|
+
return null;
|
|
157
|
+
// Use the body subtree for call extraction (avoids counting the signature itself)
|
|
158
|
+
const body = funcNode.childForFieldName("body") ?? funcNode;
|
|
159
|
+
const rawCalls = [];
|
|
160
|
+
collectCalls(body, rawCalls);
|
|
161
|
+
// Parse the file's imports to resolve callee origins
|
|
162
|
+
const opts = resolveOptions({ detail: "outline", emitHtml: false }, loadProjectConfig(root));
|
|
163
|
+
const skel = await buildSkeleton(filePath, relPath, opts);
|
|
164
|
+
// localName → module specifier
|
|
165
|
+
const importMap = new Map();
|
|
166
|
+
for (const imp of skel.imports ?? []) {
|
|
167
|
+
if (imp.symbol !== "*" && !imp.isSideEffect) {
|
|
168
|
+
importMap.set(imp.alias ?? imp.symbol, imp.from);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const localNames = new Set(skel.symbols.map((s) => s.name));
|
|
172
|
+
// Track destructured aliases within the function body
|
|
173
|
+
// e.g. const { sign } = jwt → sign maps to the same source as jwt
|
|
174
|
+
const destructuredAliases = collectDestructuredAliases(body, importMap);
|
|
175
|
+
// Deduplicate by callee+line and resolve origins
|
|
176
|
+
const calls = [];
|
|
177
|
+
const seen = new Set();
|
|
178
|
+
for (const { callee, line } of rawCalls) {
|
|
179
|
+
const key = `${callee}:${line}`;
|
|
180
|
+
if (seen.has(key))
|
|
181
|
+
continue;
|
|
182
|
+
seen.add(key);
|
|
183
|
+
const baseName = callee.split(".")[0];
|
|
184
|
+
// Check import map first, then destructured aliases
|
|
185
|
+
const importFrom = importMap.get(baseName) ?? destructuredAliases.get(baseName);
|
|
186
|
+
const call = { callee, line };
|
|
187
|
+
if (importFrom) {
|
|
188
|
+
if (importFrom.startsWith(".")) {
|
|
189
|
+
const resolvedAbs = resolveImportPath(importFrom, filePath);
|
|
190
|
+
if (resolvedAbs) {
|
|
191
|
+
call.calleeFileRel = path.relative(root, resolvedAbs).split(path.sep).join("/");
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
call.isExternal = true;
|
|
196
|
+
call.calleeFileRel = importFrom;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
else if (localNames.has(baseName)) {
|
|
200
|
+
call.isLocal = true;
|
|
201
|
+
}
|
|
202
|
+
calls.push(call);
|
|
203
|
+
}
|
|
204
|
+
// calledBy: files that import this function (reverse import lookup)
|
|
205
|
+
const calledBy = [];
|
|
206
|
+
if (allSkeletons) {
|
|
207
|
+
for (const otherSkel of allSkeletons) {
|
|
208
|
+
if (otherSkel.file === relPath)
|
|
209
|
+
continue;
|
|
210
|
+
for (const imp of otherSkel.imports ?? []) {
|
|
211
|
+
const importedName = imp.alias ?? imp.symbol;
|
|
212
|
+
if (importedName !== funcName && imp.symbol !== funcName)
|
|
213
|
+
continue;
|
|
214
|
+
if (!imp.from.startsWith("."))
|
|
215
|
+
continue;
|
|
216
|
+
const otherAbs = path.resolve(root, otherSkel.file);
|
|
217
|
+
const resolvedAbs = resolveImportPath(imp.from, otherAbs);
|
|
218
|
+
if (!resolvedAbs)
|
|
219
|
+
continue;
|
|
220
|
+
const resolvedRel = path.relative(root, resolvedAbs).split(path.sep).join("/");
|
|
221
|
+
if (resolvedRel === relPath) {
|
|
222
|
+
calledBy.push({ file: otherSkel.file });
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
file: relPath,
|
|
230
|
+
function: funcName,
|
|
231
|
+
functionRange: {
|
|
232
|
+
startLine: funcNode.startPosition.row + 1,
|
|
233
|
+
endLine: funcNode.endPosition.row + 1,
|
|
234
|
+
},
|
|
235
|
+
calls,
|
|
236
|
+
calledBy,
|
|
237
|
+
};
|
|
238
|
+
}
|