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.
@@ -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
+ }