universal-ast-mapper 1.28.0 → 2.0.1

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,264 @@
1
+ import path from "node:path";
2
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
3
+ /** Replace non-word characters with underscore for Mermaid identifiers. */
4
+ function sanitizeName(name) {
5
+ return name.replace(/\W/g, "_");
6
+ }
7
+ /** Replace path separators, dots, and dashes with underscore for Mermaid node IDs. */
8
+ function sanitizeId(filePath) {
9
+ return filePath.replace(/[/.\-]/g, "_");
10
+ }
11
+ // ─── Class Diagram ────────────────────────────────────────────────────────────
12
+ /**
13
+ * Class diagram from skeletons — shows classes, interfaces, enums and their
14
+ * relationships.
15
+ */
16
+ export function buildClassDiagram(skeletons) {
17
+ const lines = ["classDiagram"];
18
+ const entries = [];
19
+ for (const skel of skeletons) {
20
+ for (const sym of skel.symbols) {
21
+ if (sym.kind === "class" || sym.kind === "interface" || sym.kind === "enum") {
22
+ entries.push({
23
+ name: sym.name,
24
+ sanitized: sanitizeName(sym.name),
25
+ kind: sym.kind,
26
+ node: sym,
27
+ });
28
+ }
29
+ }
30
+ }
31
+ // Enforce the 40-class limit.
32
+ const MAX_CLASSES = 40;
33
+ const truncated = entries.length > MAX_CLASSES;
34
+ const visible = truncated ? entries.slice(0, MAX_CLASSES) : entries;
35
+ const visibleNames = new Set(visible.map((e) => e.name));
36
+ let edgeCount = 0;
37
+ // Emit class/interface/enum blocks.
38
+ for (const entry of visible) {
39
+ const { sanitized, kind, node } = entry;
40
+ if (kind === "interface") {
41
+ lines.push(` class ${sanitized} {`);
42
+ lines.push(` <<interface>>`);
43
+ for (const child of node.children) {
44
+ if (child.kind === "method" || child.kind === "function") {
45
+ const prefix = child.visibility === "private" ? "-" : "+";
46
+ lines.push(` ${prefix}${sanitizeName(child.name)}()`);
47
+ }
48
+ }
49
+ lines.push(` }`);
50
+ }
51
+ else if (kind === "enum") {
52
+ lines.push(` class ${sanitized} {`);
53
+ lines.push(` <<enumeration>>`);
54
+ for (const child of node.children) {
55
+ lines.push(` ${sanitizeName(child.name)}`);
56
+ }
57
+ lines.push(` }`);
58
+ }
59
+ else {
60
+ // class
61
+ lines.push(` class ${sanitized} {`);
62
+ for (const child of node.children) {
63
+ const prefix = child.visibility === "private" ? "-" : "+";
64
+ if (child.kind === "method" || child.kind === "function") {
65
+ lines.push(` ${prefix}${sanitizeName(child.name)}()`);
66
+ }
67
+ else if (child.kind === "field") {
68
+ // Try to extract type from signature
69
+ let fieldType = "";
70
+ if (child.signature) {
71
+ // Signature format: "fieldName: TypeName" or "fieldName?: TypeName"
72
+ const match = child.signature.match(/:\s*(.+)$/);
73
+ if (match)
74
+ fieldType = sanitizeName(match[1].trim().split(/\s/)[0]) + " ";
75
+ }
76
+ lines.push(` ${prefix}${fieldType}${sanitizeName(child.name)}`);
77
+ }
78
+ }
79
+ lines.push(` }`);
80
+ }
81
+ }
82
+ // Emit "uses" edges from imports.
83
+ for (const skel of skeletons) {
84
+ if (!skel.imports)
85
+ continue;
86
+ // Find the class/interface names in this file.
87
+ const fileClasses = skel.symbols
88
+ .filter((s) => s.kind === "class" || s.kind === "interface" || s.kind === "enum")
89
+ .map((s) => s.name);
90
+ for (const fileClass of fileClasses) {
91
+ if (!visibleNames.has(fileClass))
92
+ continue;
93
+ const from = sanitizeName(fileClass);
94
+ for (const imp of skel.imports) {
95
+ if (imp.symbol && imp.symbol !== "*" && visibleNames.has(imp.symbol)) {
96
+ const to = sanitizeName(imp.symbol);
97
+ if (from !== to) {
98
+ lines.push(` ${from} --> ${to} : uses`);
99
+ edgeCount++;
100
+ }
101
+ }
102
+ }
103
+ }
104
+ }
105
+ if (truncated) {
106
+ lines.push(` %% ... and ${entries.length - MAX_CLASSES} more`);
107
+ }
108
+ return {
109
+ type: "class",
110
+ mermaid: lines.join("\n"),
111
+ title: "Class Diagram",
112
+ nodeCount: visible.length,
113
+ edgeCount,
114
+ };
115
+ }
116
+ // ─── Deps Diagram ─────────────────────────────────────────────────────────────
117
+ /**
118
+ * File dependency diagram from symbol graph.
119
+ */
120
+ export function buildDepsDiagram(graph, maxNodes = 50) {
121
+ const lines = ["graph TD"];
122
+ // Collect file nodes only.
123
+ const fileNodes = graph.nodes.filter((n) => n.nodeType === "file");
124
+ const truncated = fileNodes.length > maxNodes;
125
+ const visibleFiles = new Set((truncated ? fileNodes.slice(0, maxNodes) : fileNodes).map((n) => n.id));
126
+ // Emit node labels.
127
+ for (const n of fileNodes) {
128
+ if (!visibleFiles.has(n.id))
129
+ continue;
130
+ const nodeId = sanitizeId(n.id);
131
+ const label = path.basename(n.id, path.extname(n.id));
132
+ lines.push(` ${nodeId}["${label}"]`);
133
+ lines.push(` click ${nodeId} callback "${n.id}"`);
134
+ }
135
+ // Collect import edges between visible file nodes.
136
+ // Build a map: symbolNodeId → file it belongs to.
137
+ const symbolToFile = new Map();
138
+ for (const n of graph.nodes) {
139
+ if (n.nodeType === "symbol") {
140
+ const sym = n;
141
+ symbolToFile.set(sym.id, sym.file);
142
+ }
143
+ }
144
+ // Deduplicate file-level edges.
145
+ const emittedEdges = new Set();
146
+ let edgeCount = 0;
147
+ for (const edge of graph.edges) {
148
+ if (edge.edgeType !== "imports")
149
+ continue;
150
+ const fromFile = edge.from;
151
+ // `to` may be a symbol node ID or a file node ID.
152
+ const toFile = symbolToFile.get(edge.to) ?? edge.to;
153
+ if (fromFile === toFile)
154
+ continue;
155
+ if (!visibleFiles.has(fromFile) || !visibleFiles.has(toFile))
156
+ continue;
157
+ const key = `${fromFile}→${toFile}`;
158
+ if (emittedEdges.has(key))
159
+ continue;
160
+ emittedEdges.add(key);
161
+ const fromId = sanitizeId(fromFile);
162
+ const toId = sanitizeId(toFile);
163
+ lines.push(` ${fromId} --> ${toId}`);
164
+ edgeCount++;
165
+ }
166
+ if (truncated) {
167
+ lines.push(` %% ... ${fileNodes.length - maxNodes} more nodes not shown`);
168
+ }
169
+ return {
170
+ type: "deps",
171
+ mermaid: lines.join("\n"),
172
+ title: "File Dependency Diagram",
173
+ nodeCount: visibleFiles.size,
174
+ edgeCount,
175
+ };
176
+ }
177
+ // ─── Modules Diagram ──────────────────────────────────────────────────────────
178
+ /**
179
+ * Module/directory-level dependency diagram (collapsed by top-level directory).
180
+ */
181
+ export function buildModulesDiagram(graph) {
182
+ const MAX_MODULES = 20;
183
+ const lines = ["graph LR"];
184
+ // Map each file to its top-level module (first directory component).
185
+ function fileToModule(fileId) {
186
+ const parts = fileId.split("/");
187
+ // If the file is at the root level (no directory), use "." as the module.
188
+ if (parts.length <= 1)
189
+ return ".";
190
+ // Use up to 2 path components for meaningful grouping when first segment is short
191
+ // (e.g. "src/auth" for "src/auth/user.ts").
192
+ return parts.slice(0, Math.min(2, parts.length - 1)).join("/");
193
+ }
194
+ // Build symbol-to-file lookup.
195
+ const symbolToFile = new Map();
196
+ for (const n of graph.nodes) {
197
+ if (n.nodeType === "symbol") {
198
+ const sym = n;
199
+ symbolToFile.set(sym.id, sym.file);
200
+ }
201
+ }
202
+ // Count inter-module edges.
203
+ const moduleEdgeCounts = new Map();
204
+ const moduleSet = new Set();
205
+ for (const n of graph.nodes) {
206
+ if (n.nodeType === "file") {
207
+ moduleSet.add(fileToModule(n.id));
208
+ }
209
+ }
210
+ for (const edge of graph.edges) {
211
+ if (edge.edgeType !== "imports")
212
+ continue;
213
+ const fromFile = edge.from;
214
+ const toFile = symbolToFile.get(edge.to) ?? edge.to;
215
+ if (fromFile === toFile)
216
+ continue;
217
+ const fromModule = fileToModule(fromFile);
218
+ const toModule = fileToModule(toFile);
219
+ if (fromModule === toModule)
220
+ continue;
221
+ const key = `${fromModule}→${toModule}`;
222
+ moduleEdgeCounts.set(key, (moduleEdgeCounts.get(key) ?? 0) + 1);
223
+ }
224
+ // Determine which modules are actually referenced by edges, then sort by
225
+ // occurrence to pick the most connected ones if we need to truncate.
226
+ const moduleOccurrences = new Map();
227
+ for (const [key, count] of moduleEdgeCounts) {
228
+ const [from, to] = key.split("→");
229
+ moduleOccurrences.set(from, (moduleOccurrences.get(from) ?? 0) + count);
230
+ moduleOccurrences.set(to, (moduleOccurrences.get(to) ?? 0) + count);
231
+ }
232
+ // Include all modules from the file set, sorted by occurrence descending.
233
+ const allModules = [...moduleSet].sort((a, b) => {
234
+ return (moduleOccurrences.get(b) ?? 0) - (moduleOccurrences.get(a) ?? 0);
235
+ });
236
+ const truncated = allModules.length > MAX_MODULES;
237
+ const visibleModules = new Set(truncated ? allModules.slice(0, MAX_MODULES) : allModules);
238
+ // Emit module nodes.
239
+ for (const mod of visibleModules) {
240
+ const nodeId = sanitizeName(mod);
241
+ lines.push(` ${nodeId}["${mod}"]`);
242
+ }
243
+ // Emit weighted edges between visible modules.
244
+ let edgeCount = 0;
245
+ for (const [key, count] of moduleEdgeCounts) {
246
+ const [fromModule, toModule] = key.split("→");
247
+ if (!visibleModules.has(fromModule) || !visibleModules.has(toModule))
248
+ continue;
249
+ const fromId = sanitizeName(fromModule);
250
+ const toId = sanitizeName(toModule);
251
+ lines.push(` ${fromId} -->|${count}| ${toId}`);
252
+ edgeCount++;
253
+ }
254
+ if (truncated) {
255
+ lines.push(` %% ... ${allModules.length - MAX_MODULES} more modules not shown`);
256
+ }
257
+ return {
258
+ type: "modules",
259
+ mermaid: lines.join("\n"),
260
+ title: "Module Dependency Diagram",
261
+ nodeCount: visibleModules.size,
262
+ edgeCount,
263
+ };
264
+ }
package/dist/docgen.js ADDED
@@ -0,0 +1,156 @@
1
+ import https from "node:https";
2
+ function flattenSymbols(symbols, file) {
3
+ const result = [];
4
+ for (const sym of symbols) {
5
+ result.push({
6
+ file,
7
+ name: sym.name,
8
+ kind: sym.kind,
9
+ signature: sym.signature,
10
+ exported: sym.exported !== false,
11
+ lineStart: sym.range.startLine,
12
+ description: sym.doc ?? undefined,
13
+ });
14
+ if (sym.children.length > 0) {
15
+ result.push(...flattenSymbols(sym.children, file));
16
+ }
17
+ }
18
+ return result;
19
+ }
20
+ export function buildDocOutput(skeletons, opts = {}) {
21
+ const files = [];
22
+ let totalSymbols = 0;
23
+ let exportedSymbols = 0;
24
+ for (const skel of skeletons) {
25
+ let syms = flattenSymbols(skel.symbols, skel.file);
26
+ if (opts.exportedOnly)
27
+ syms = syms.filter(s => s.exported);
28
+ if (syms.length === 0)
29
+ continue;
30
+ totalSymbols += syms.length;
31
+ exportedSymbols += syms.filter(s => s.exported).length;
32
+ files.push({ file: skel.file, language: skel.language, symbols: syms });
33
+ }
34
+ return { files, totalSymbols, exportedSymbols };
35
+ }
36
+ function htmlEsc(s) {
37
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
38
+ }
39
+ export function renderMarkdown(output) {
40
+ const lines = [
41
+ "# API Reference",
42
+ "",
43
+ `> ${output.exportedSymbols} exported symbols across ${output.files.length} files`,
44
+ "",
45
+ ];
46
+ for (const f of output.files) {
47
+ lines.push(`## \`${f.file}\``, "");
48
+ for (const sym of f.symbols) {
49
+ const expMark = sym.exported ? "" : " _(internal)_";
50
+ lines.push(`### \`${sym.name}\` _(${sym.kind})_${expMark}`, "");
51
+ if (sym.signature)
52
+ lines.push("```", sym.signature, "```", "");
53
+ if (sym.description)
54
+ lines.push(sym.description, "");
55
+ lines.push(`_Line ${sym.lineStart}_`, "");
56
+ }
57
+ }
58
+ return lines.join("\n");
59
+ }
60
+ export function renderDocHtml(output) {
61
+ const rows = output.files.flatMap(f => f.symbols.map(s => `<tr><td><code>${htmlEsc(f.file)}</code></td><td><code>${htmlEsc(s.name)}</code></td><td>${s.kind}</td><td>${s.exported ? "✓" : ""}</td><td><code>${htmlEsc(s.signature ?? "")}</code></td><td>${htmlEsc(s.description ?? "")}</td></tr>`)).join("\n");
62
+ return `<!DOCTYPE html>
63
+ <html lang="en">
64
+ <head><meta charset="utf-8"><title>API Reference</title>
65
+ <style>body{font-family:sans-serif;padding:2rem;max-width:1200px;margin:0 auto}table{border-collapse:collapse;width:100%}th,td{border:1px solid #ddd;padding:6px 10px;text-align:left;vertical-align:top}th{background:#f5f5f5;font-size:.8em;text-transform:uppercase}code{font-family:monospace;font-size:.9em;background:#f0f0f0;padding:1px 3px;border-radius:2px}h1{margin-bottom:4px}p{color:#666;margin-bottom:1.5rem}</style>
66
+ </head>
67
+ <body>
68
+ <h1>API Reference</h1>
69
+ <p>${output.exportedSymbols} exported symbols &bull; ${output.files.length} files</p>
70
+ <table>
71
+ <thead><tr><th>File</th><th>Name</th><th>Kind</th><th>Exp</th><th>Signature</th><th>Description</th></tr></thead>
72
+ <tbody>
73
+ ${rows}
74
+ </tbody>
75
+ </table>
76
+ </body></html>`;
77
+ }
78
+ // ─── AI enhancement ───────────────────────────────────────────────────────────
79
+ async function callClaude(prompt, opts) {
80
+ const apiKey = opts.apiKey ?? process.env.ANTHROPIC_API_KEY;
81
+ if (!apiKey)
82
+ throw new Error("No Anthropic API key — set ANTHROPIC_API_KEY or pass --api-key");
83
+ const body = JSON.stringify({
84
+ model: opts.model ?? "claude-sonnet-4-6",
85
+ max_tokens: opts.maxTokens ?? 1024,
86
+ messages: [{ role: "user", content: prompt }],
87
+ });
88
+ return new Promise((resolve, reject) => {
89
+ const req = https.request({
90
+ hostname: "api.anthropic.com",
91
+ path: "/v1/messages",
92
+ method: "POST",
93
+ headers: {
94
+ "content-type": "application/json",
95
+ "x-api-key": apiKey,
96
+ "anthropic-version": "2023-06-01",
97
+ "content-length": Buffer.byteLength(body),
98
+ },
99
+ }, (res) => {
100
+ const chunks = [];
101
+ res.on("data", (c) => chunks.push(c));
102
+ res.on("end", () => {
103
+ const raw = Buffer.concat(chunks).toString("utf8");
104
+ try {
105
+ const parsed = JSON.parse(raw);
106
+ if (parsed.error)
107
+ reject(new Error(`Anthropic API: ${parsed.error.message}`));
108
+ else
109
+ resolve(parsed.content?.[0]?.text ?? "");
110
+ }
111
+ catch {
112
+ reject(new Error("Unexpected API response"));
113
+ }
114
+ });
115
+ });
116
+ req.on("error", reject);
117
+ req.write(body);
118
+ req.end();
119
+ });
120
+ }
121
+ export async function aiEnhanceDocs(output, opts = {}) {
122
+ const enhanced = {
123
+ ...output,
124
+ files: output.files.map(f => ({ ...f, symbols: f.symbols.map(s => ({ ...s })) })),
125
+ };
126
+ for (const docFile of enhanced.files) {
127
+ const toEnhance = docFile.symbols.filter(s => s.exported && !s.description);
128
+ if (toEnhance.length === 0)
129
+ continue;
130
+ const batch = toEnhance.slice(0, 20);
131
+ const symbolList = batch.map(s => `- ${s.name} (${s.kind})${s.signature ? `: ${s.signature}` : ""}`).join("\n");
132
+ const prompt = `You are a technical writer generating concise JSDoc-style descriptions for API symbols.
133
+
134
+ File: ${docFile.file}
135
+ Language: ${docFile.language}
136
+
137
+ For each symbol below, write a single short sentence (max 20 words) describing what it does.
138
+ Respond ONLY as JSON: {"symbolName": "description", ...}
139
+
140
+ Symbols:
141
+ ${symbolList}`;
142
+ try {
143
+ const raw = await callClaude(prompt, opts);
144
+ const jsonMatch = raw.match(/\{[\s\S]*\}/);
145
+ if (jsonMatch) {
146
+ const descriptions = JSON.parse(jsonMatch[0]);
147
+ for (const sym of docFile.symbols) {
148
+ if (descriptions[sym.name])
149
+ sym.description = descriptions[sym.name];
150
+ }
151
+ }
152
+ }
153
+ catch { /* skip on error */ }
154
+ }
155
+ return enhanced;
156
+ }
@@ -0,0 +1,136 @@
1
+ import https from "node:https";
2
+ // ─── Tokenize ─────────────────────────────────────────────────────────────────
3
+ function tokenize(text) {
4
+ return text
5
+ .replace(/([A-Z])/g, " $1")
6
+ .replace(/[_\-().:<>{}[\],]/g, " ")
7
+ .toLowerCase()
8
+ .split(/\s+/)
9
+ .filter(t => t.length > 1);
10
+ }
11
+ // ─── TF-IDF vectors ───────────────────────────────────────────────────────────
12
+ export function buildTfIdfVectors(skeletons) {
13
+ const docs = [];
14
+ for (const skel of skeletons) {
15
+ for (const sym of skel.symbols) {
16
+ const text = [sym.name, sym.signature ?? "", sym.kind].join(" ");
17
+ docs.push({ file: skel.file, symbol: sym.name, kind: sym.kind, tokens: tokenize(text) });
18
+ }
19
+ }
20
+ if (docs.length === 0)
21
+ return [];
22
+ const df = new Map();
23
+ for (const doc of docs) {
24
+ for (const term of new Set(doc.tokens)) {
25
+ df.set(term, (df.get(term) ?? 0) + 1);
26
+ }
27
+ }
28
+ const N = docs.length;
29
+ const vectors = [];
30
+ for (const doc of docs) {
31
+ const tf = new Map();
32
+ for (const term of doc.tokens)
33
+ tf.set(term, (tf.get(term) ?? 0) + 1);
34
+ const terms = {};
35
+ for (const [term, count] of tf.entries()) {
36
+ const idf = Math.log(N / (df.get(term) ?? 1));
37
+ terms[term] = (count / doc.tokens.length) * idf;
38
+ }
39
+ const norm = Math.sqrt(Object.values(terms).reduce((s, v) => s + v * v, 0));
40
+ vectors.push({ file: doc.file, symbol: doc.symbol, kind: doc.kind, terms, norm });
41
+ }
42
+ return vectors;
43
+ }
44
+ // ─── Cosine search ────────────────────────────────────────────────────────────
45
+ export function cosineSearch(vectors, query, limit = 20) {
46
+ const qTokens = tokenize(query);
47
+ if (qTokens.length === 0)
48
+ return [];
49
+ const qTf = new Map();
50
+ for (const t of qTokens)
51
+ qTf.set(t, (qTf.get(t) ?? 0) + 1);
52
+ const qNorm = Math.sqrt([...qTf.values()].reduce((s, v) => s + (v / qTokens.length) ** 2, 0));
53
+ if (qNorm === 0)
54
+ return [];
55
+ const scores = [];
56
+ for (const vec of vectors) {
57
+ if (vec.norm === 0)
58
+ continue;
59
+ let dot = 0;
60
+ for (const [term, qCount] of qTf.entries()) {
61
+ const qTfidf = qCount / qTokens.length;
62
+ const dTfidf = vec.terms[term] ?? 0;
63
+ dot += qTfidf * dTfidf;
64
+ }
65
+ const score = dot / (qNorm * vec.norm);
66
+ if (score > 0)
67
+ scores.push({ file: vec.file, symbol: vec.symbol, kind: vec.kind, score });
68
+ }
69
+ return scores.sort((a, b) => b.score - a.score).slice(0, limit);
70
+ }
71
+ // ─── Claude re-ranking ────────────────────────────────────────────────────────
72
+ export async function rerankWithClaude(matches, query, opts = {}) {
73
+ if (matches.length === 0)
74
+ return matches;
75
+ const apiKey = opts.apiKey ?? process.env.ANTHROPIC_API_KEY;
76
+ if (!apiKey)
77
+ return matches;
78
+ const list = matches.map((m, i) => `${i + 1}. ${m.symbol} (${m.kind}) in ${m.file}`).join("\n");
79
+ const prompt = `You are helping re-rank code search results for the query: "${query}"
80
+
81
+ Results:
82
+ ${list}
83
+
84
+ Re-rank these by relevance to the query. Respond ONLY with a JSON array of 1-based indices in order of relevance, e.g. [3, 1, 5, 2, 4].`;
85
+ const body = JSON.stringify({
86
+ model: opts.model ?? "claude-sonnet-4-6",
87
+ max_tokens: 256,
88
+ messages: [{ role: "user", content: prompt }],
89
+ });
90
+ try {
91
+ const raw = await new Promise((resolve, reject) => {
92
+ const req = https.request({
93
+ hostname: "api.anthropic.com",
94
+ path: "/v1/messages",
95
+ method: "POST",
96
+ headers: {
97
+ "content-type": "application/json",
98
+ "x-api-key": apiKey,
99
+ "anthropic-version": "2023-06-01",
100
+ "content-length": Buffer.byteLength(body),
101
+ },
102
+ }, (res) => {
103
+ const chunks = [];
104
+ res.on("data", (c) => chunks.push(c));
105
+ res.on("end", () => {
106
+ const text = Buffer.concat(chunks).toString("utf8");
107
+ try {
108
+ const parsed = JSON.parse(text);
109
+ resolve(parsed.content?.[0]?.text ?? "");
110
+ }
111
+ catch {
112
+ resolve("");
113
+ }
114
+ });
115
+ });
116
+ req.on("error", reject);
117
+ req.write(body);
118
+ req.end();
119
+ });
120
+ const arrMatch = raw.match(/\[[\d,\s]+\]/);
121
+ if (arrMatch) {
122
+ const indices = JSON.parse(arrMatch[0]);
123
+ const reranked = indices
124
+ .filter(i => i >= 1 && i <= matches.length)
125
+ .map(i => matches[i - 1]);
126
+ const covered = new Set(indices);
127
+ for (let i = 1; i <= matches.length; i++) {
128
+ if (!covered.has(i))
129
+ reranked.push(matches[i - 1]);
130
+ }
131
+ return reranked;
132
+ }
133
+ }
134
+ catch { /* fall through */ }
135
+ return matches;
136
+ }
@@ -0,0 +1,123 @@
1
+ import https from "node:https";
2
+ // ─── Structural analysis ──────────────────────────────────────────────────────
3
+ export function buildExplainResult(symbolName, skel, graph, impact, smells, complexityRating) {
4
+ // Find the symbol node in the skeleton
5
+ const sym = findSymbolNode(skel.symbols, symbolName);
6
+ // Callers = files that transitively depend on this symbol
7
+ const callerFiles = impact
8
+ ? [...new Set([...impact.direct, ...impact.transitive].map((n) => n.file))]
9
+ : [];
10
+ // Dependencies = what this file imports that relate to this symbol
11
+ const dependsOn = (skel.imports ?? [])
12
+ .filter((imp) => imp.symbol || imp.from)
13
+ .map((imp) => imp.symbol ? `${imp.symbol} from ${imp.from}` : imp.from)
14
+ .slice(0, 10);
15
+ const lineCount = sym ? sym.range.endLine - sym.range.startLine + 1 : 0;
16
+ const isAsync = !!(sym?.signature?.includes("async "));
17
+ const isExported = sym?.exported !== false;
18
+ return {
19
+ symbol: symbolName,
20
+ file: skel.file,
21
+ kind: sym?.kind ?? "unknown",
22
+ signature: sym?.signature,
23
+ summary: {
24
+ callerFiles: callerFiles.slice(0, 20),
25
+ callerCount: impact ? impact.totalFiles : 0,
26
+ dependsOn,
27
+ childCount: sym?.children.length ?? 0,
28
+ lineCount,
29
+ isExported,
30
+ isAsync,
31
+ },
32
+ smells,
33
+ complexityRating,
34
+ };
35
+ }
36
+ function findSymbolNode(symbols, name) {
37
+ for (const sym of symbols) {
38
+ if (sym.name === name)
39
+ return sym;
40
+ const child = findSymbolNode(sym.children, name);
41
+ if (child)
42
+ return child;
43
+ }
44
+ return undefined;
45
+ }
46
+ // ─── AI explanation ───────────────────────────────────────────────────────────
47
+ function buildPrompt(result, sourceCode) {
48
+ const callers = result.summary.callerFiles.slice(0, 8).join(", ") || "none detected";
49
+ const deps = result.summary.dependsOn.slice(0, 6).join(", ") || "none";
50
+ const smellsStr = result.smells.length ? result.smells.join(", ") : "none";
51
+ return `You are a senior software engineer explaining a codebase symbol to a teammate.
52
+
53
+ ## Symbol: \`${result.symbol}\`
54
+ - Kind: ${result.kind}
55
+ - File: ${result.file}
56
+ - Signature: ${result.signature ?? "(no signature)"}
57
+ - Exported: ${result.summary.isExported}
58
+ - Async: ${result.summary.isAsync}
59
+ - Line count: ${result.summary.lineCount}
60
+ - Complexity rating: ${result.complexityRating ?? "unknown"}
61
+ - Code smells: ${smellsStr}
62
+ - Depends on: ${deps}
63
+ - Used by (${result.summary.callerCount} files): ${callers}
64
+
65
+ ## Source snippet (first 60 lines):
66
+ \`\`\`
67
+ ${sourceCode.split("\n").slice(0, 60).join("\n")}
68
+ \`\`\`
69
+
70
+ Explain this symbol in 3–5 concise sentences covering:
71
+ 1. What it does (purpose, not implementation)
72
+ 2. When/why callers use it
73
+ 3. Key dependencies or side effects worth knowing
74
+ 4. Change risk: what breaks if this symbol is modified or removed
75
+
76
+ Do NOT include code. Plain prose only. Be specific, not generic.`;
77
+ }
78
+ async function callClaude(prompt, opts) {
79
+ const apiKey = opts.apiKey ?? process.env.ANTHROPIC_API_KEY;
80
+ if (!apiKey)
81
+ throw new Error("No Anthropic API key — set ANTHROPIC_API_KEY or pass --api-key");
82
+ const body = JSON.stringify({
83
+ model: opts.model ?? "claude-sonnet-4-6",
84
+ max_tokens: opts.maxTokens ?? 1024,
85
+ messages: [{ role: "user", content: prompt }],
86
+ });
87
+ return new Promise((resolve, reject) => {
88
+ const req = https.request({
89
+ hostname: "api.anthropic.com",
90
+ path: "/v1/messages",
91
+ method: "POST",
92
+ headers: {
93
+ "content-type": "application/json",
94
+ "x-api-key": apiKey,
95
+ "anthropic-version": "2023-06-01",
96
+ "content-length": Buffer.byteLength(body),
97
+ },
98
+ }, (res) => {
99
+ const chunks = [];
100
+ res.on("data", (c) => chunks.push(c));
101
+ res.on("end", () => {
102
+ const raw = Buffer.concat(chunks).toString("utf8");
103
+ try {
104
+ const parsed = JSON.parse(raw);
105
+ if (parsed.error)
106
+ reject(new Error(`Anthropic API: ${parsed.error.message}`));
107
+ else
108
+ resolve(parsed.content?.[0]?.text ?? "");
109
+ }
110
+ catch {
111
+ reject(new Error(`Unexpected API response`));
112
+ }
113
+ });
114
+ });
115
+ req.on("error", reject);
116
+ req.write(body);
117
+ req.end();
118
+ });
119
+ }
120
+ export async function aiExplain(result, sourceCode, opts = {}) {
121
+ const aiExplanation = await callClaude(buildPrompt(result, sourceCode), opts);
122
+ return { ...result, aiExplanation };
123
+ }