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.
package/dist/serve.js ADDED
@@ -0,0 +1,185 @@
1
+ import http from "node:http";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { collectSourceFiles } from "./skeleton.js";
5
+ import { resolveOptions } from "./config.js";
6
+ import { buildSymbolGraph } from "./graph.js";
7
+ import { findDeadExports, findCircularDeps, getTopSymbols } from "./graph-analysis.js";
8
+ import { buildReport } from "./report.js";
9
+ import { loadHistory } from "./history.js";
10
+ import { detectSmells } from "./smells.js";
11
+ import { scanFileForSecurityIssues } from "./security.js";
12
+ import { buildSkeletonsBulk } from "./pool.js";
13
+ import { webAppHtml } from "./webapp.js";
14
+ export async function startServe(opts) {
15
+ const root = opts.root;
16
+ const scanDir = opts.scanDir ?? root;
17
+ const port = opts.port ?? 7337;
18
+ // SSE client registry
19
+ const sseClients = new Set();
20
+ function broadcastChange() {
21
+ for (const client of sseClients) {
22
+ try {
23
+ client.write("event: change\ndata: {}\n\n");
24
+ }
25
+ catch {
26
+ sseClients.delete(client);
27
+ }
28
+ }
29
+ }
30
+ // fs.watch for live reload
31
+ if (opts.watch) {
32
+ try {
33
+ fs.watch(scanDir, { recursive: true }, (_event, filename) => {
34
+ if (!filename || filename.includes(".ast-map"))
35
+ return;
36
+ cache = null;
37
+ broadcastChange();
38
+ });
39
+ }
40
+ catch { /* watch not supported on all platforms */ }
41
+ }
42
+ let cache = null;
43
+ const CACHE_TTL = 5000;
44
+ async function getSkeletons() {
45
+ if (cache && Date.now() - cache.ts < CACHE_TTL)
46
+ return cache.skeletons;
47
+ const skOpts = resolveOptions({ detail: "outline", emitHtml: false });
48
+ const files = collectSourceFiles(scanDir, skOpts);
49
+ const items = files.map((f) => ({
50
+ abs: f,
51
+ rel: path.relative(root, f).split(path.sep).join("/"),
52
+ }));
53
+ const built = await buildSkeletonsBulk(items, skOpts);
54
+ const skeletons = built.filter(Boolean).map((r) => r.skel);
55
+ cache = { skeletons, ts: Date.now() };
56
+ return skeletons;
57
+ }
58
+ const server = http.createServer(async (req, res) => {
59
+ const url = new URL(req.url ?? "/", `http://localhost:${port}`);
60
+ const pathname = url.pathname;
61
+ res.setHeader("Access-Control-Allow-Origin", "*");
62
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
63
+ try {
64
+ if (pathname === "/" || pathname === "/index.html") {
65
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
66
+ res.end(webAppHtml(port));
67
+ return;
68
+ }
69
+ if (pathname === "/api/report") {
70
+ const skeletons = await getSkeletons();
71
+ const graph = buildSymbolGraph(skeletons, root);
72
+ const history = loadHistory(root);
73
+ const report = await buildReport(scanDir, root);
74
+ const smellsAll = [];
75
+ const securityAll = [];
76
+ for (const skel of skeletons) {
77
+ const fileAbs = path.resolve(root, skel.file);
78
+ try {
79
+ const src = fs.readFileSync(fileAbs, "utf8");
80
+ smellsAll.push(...detectSmells(skel, src.split("\n").length));
81
+ securityAll.push(...scanFileForSecurityIssues(src, skel.file));
82
+ }
83
+ catch { /* skip */ }
84
+ }
85
+ res.writeHead(200, { "Content-Type": "application/json" });
86
+ res.end(JSON.stringify({ ...report, smells: smellsAll, security: securityAll, history }, null, 2));
87
+ return;
88
+ }
89
+ if (pathname === "/api/graph") {
90
+ const skeletons = await getSkeletons();
91
+ const graph = buildSymbolGraph(skeletons, root);
92
+ res.writeHead(200, { "Content-Type": "application/json" });
93
+ res.end(JSON.stringify(graph, null, 2));
94
+ return;
95
+ }
96
+ if (pathname === "/api/dead") {
97
+ const skeletons = await getSkeletons();
98
+ const graph = buildSymbolGraph(skeletons, root);
99
+ const dead = findDeadExports(graph);
100
+ res.writeHead(200, { "Content-Type": "application/json" });
101
+ res.end(JSON.stringify(dead, null, 2));
102
+ return;
103
+ }
104
+ if (pathname === "/api/top") {
105
+ const skeletons = await getSkeletons();
106
+ const graph = buildSymbolGraph(skeletons, root);
107
+ const top = getTopSymbols(graph, 20);
108
+ res.writeHead(200, { "Content-Type": "application/json" });
109
+ res.end(JSON.stringify(top, null, 2));
110
+ return;
111
+ }
112
+ if (pathname === "/api/cycles") {
113
+ const skeletons = await getSkeletons();
114
+ const graph = buildSymbolGraph(skeletons, root);
115
+ const cycles = findCircularDeps(graph);
116
+ res.writeHead(200, { "Content-Type": "application/json" });
117
+ res.end(JSON.stringify(cycles, null, 2));
118
+ return;
119
+ }
120
+ if (pathname === "/api/history") {
121
+ const history = loadHistory(root);
122
+ res.writeHead(200, { "Content-Type": "application/json" });
123
+ res.end(JSON.stringify(history, null, 2));
124
+ return;
125
+ }
126
+ if (pathname === "/api/skeletons") {
127
+ const skeletons = await getSkeletons();
128
+ res.writeHead(200, { "Content-Type": "application/json" });
129
+ res.end(JSON.stringify(skeletons, null, 2));
130
+ return;
131
+ }
132
+ if (pathname === "/events") {
133
+ res.writeHead(200, {
134
+ "Content-Type": "text/event-stream",
135
+ "Cache-Control": "no-cache",
136
+ "Connection": "keep-alive",
137
+ "Access-Control-Allow-Origin": "*",
138
+ });
139
+ res.write("event: connected\ndata: {}\n\n");
140
+ sseClients.add(res);
141
+ req.on("close", () => sseClients.delete(res));
142
+ return;
143
+ }
144
+ if (pathname === "/api/smells") {
145
+ const skeletons = await getSkeletons();
146
+ const all = [];
147
+ for (const skel of skeletons) {
148
+ const fileAbs = path.resolve(root, skel.file);
149
+ try {
150
+ const src = fs.readFileSync(fileAbs, "utf8");
151
+ all.push(...detectSmells(skel, src.split("\n").length));
152
+ }
153
+ catch { /* skip */ }
154
+ }
155
+ res.writeHead(200, { "Content-Type": "application/json" });
156
+ res.end(JSON.stringify(all, null, 2));
157
+ return;
158
+ }
159
+ if (pathname === "/api/security") {
160
+ const skeletons = await getSkeletons();
161
+ const all = [];
162
+ for (const skel of skeletons) {
163
+ const fileAbs = path.resolve(root, skel.file);
164
+ try {
165
+ const src = fs.readFileSync(fileAbs, "utf8");
166
+ all.push(...scanFileForSecurityIssues(src, skel.file));
167
+ }
168
+ catch { /* skip */ }
169
+ }
170
+ res.writeHead(200, { "Content-Type": "application/json" });
171
+ res.end(JSON.stringify(all, null, 2));
172
+ return;
173
+ }
174
+ res.writeHead(404, { "Content-Type": "text/plain" });
175
+ res.end("Not found");
176
+ }
177
+ catch (e) {
178
+ res.writeHead(500, { "Content-Type": "application/json" });
179
+ res.end(JSON.stringify({ error: e instanceof Error ? e.message : String(e) }));
180
+ }
181
+ });
182
+ return new Promise((resolve) => {
183
+ server.listen(port, () => resolve(server));
184
+ });
185
+ }
@@ -0,0 +1,98 @@
1
+ // ─── Fingerprinting ────────────────────────────────────────────────────────────
2
+ /**
3
+ * Build a structural fingerprint for a symbol.
4
+ * Two symbols with the same fingerprint are structurally similar (not textually identical).
5
+ */
6
+ function fingerprint(sym) {
7
+ const kind = sym.kind;
8
+ if (!["function", "method", "class", "struct"].includes(kind))
9
+ return null;
10
+ const sig = sym.signature ?? "";
11
+ // Param count from signature
12
+ const paramMatch = sig.match(/\(([^)]*)\)/);
13
+ const paramStr = paramMatch?.[1]?.trim() ?? "";
14
+ const paramCount = paramStr === "" ? 0 : paramStr.split(",").length;
15
+ // Presence of return type annotation
16
+ const hasReturnType = sig.includes("): ") || sig.includes(") :") || sig.includes("->");
17
+ // Async?
18
+ const isAsync = sig.includes("async ") || sig.includes("suspend ") || sig.includes("async def ");
19
+ // Visibility
20
+ const visibility = sym.visibility;
21
+ // Child count bucket for classes
22
+ const childCount = sym.children.length;
23
+ const childBucket = childCount === 0 ? "0" : childCount <= 3 ? "1-3" : childCount <= 8 ? "4-8" : "9+";
24
+ // Line length bucket
25
+ const lineCount = sym.range.endLine - sym.range.startLine + 1;
26
+ const sizeBucket = lineCount <= 5 ? "xs" : lineCount <= 20 ? "sm" : lineCount <= 60 ? "md" : "lg";
27
+ // Has nested functions?
28
+ const hasNested = sym.children.some((c) => c.kind === "function" || c.kind === "method");
29
+ if (kind === "class" || kind === "struct") {
30
+ return `${kind}|children:${childBucket}|vis:${visibility}|size:${sizeBucket}`;
31
+ }
32
+ return `${kind}|params:${paramCount}|async:${isAsync}|ret:${hasReturnType}|vis:${visibility}|size:${sizeBucket}|nested:${hasNested}`;
33
+ }
34
+ // ─── Collector ────────────────────────────────────────────────────────────────
35
+ function collect(symbols, file, kinds, out) {
36
+ for (const sym of symbols) {
37
+ if (kinds.has(sym.kind)) {
38
+ const fp = fingerprint(sym);
39
+ if (fp) {
40
+ out.push({
41
+ file,
42
+ symbol: sym.name,
43
+ kind: sym.kind,
44
+ line: sym.range.startLine,
45
+ signature: sym.signature,
46
+ fingerprint: fp,
47
+ });
48
+ }
49
+ }
50
+ if (sym.children.length > 0)
51
+ collect(sym.children, file, kinds, out);
52
+ }
53
+ }
54
+ // ─── Human-readable description ───────────────────────────────────────────────
55
+ function describeFingerprint(fp) {
56
+ const parts = Object.fromEntries(fp.split("|").map((p) => p.split(":")));
57
+ const kind = fp.split("|")[0];
58
+ const segments = [`${kind}s`];
59
+ if (parts.params)
60
+ segments.push(`${parts.params} param(s)`);
61
+ if (parts.async === "true")
62
+ segments.push("async");
63
+ if (parts.ret === "true")
64
+ segments.push("typed return");
65
+ if (parts.children)
66
+ segments.push(`${parts.children} children`);
67
+ if (parts.size)
68
+ segments.push(`size:${parts.size}`);
69
+ if (parts.vis)
70
+ segments.push(parts.vis);
71
+ return segments.join(", ");
72
+ }
73
+ // ─── Public API ───────────────────────────────────────────────────────────────
74
+ /** Find groups of structurally similar symbols across multiple skeleton files. */
75
+ export function findSimilar(skeletons, opts = {}) {
76
+ const minGroupSize = opts.minGroupSize ?? 2;
77
+ const kinds = new Set(opts.kinds ?? ["function", "method", "class", "struct"]);
78
+ const entries = [];
79
+ for (const skel of skeletons) {
80
+ collect(skel.symbols, skel.file, kinds, entries);
81
+ }
82
+ // Group by fingerprint
83
+ const groups = new Map();
84
+ for (const entry of entries) {
85
+ const g = groups.get(entry.fingerprint) ?? [];
86
+ g.push(entry);
87
+ groups.set(entry.fingerprint, g);
88
+ }
89
+ return [...groups.entries()]
90
+ .filter(([, g]) => g.length >= minGroupSize)
91
+ .map(([fp, g]) => ({
92
+ fingerprint: fp,
93
+ description: describeFingerprint(fp),
94
+ count: g.length,
95
+ entries: g.sort((a, b) => a.file.localeCompare(b.file) || a.symbol.localeCompare(b.symbol)),
96
+ }))
97
+ .sort((a, b) => b.count - a.count);
98
+ }
package/dist/smells.js ADDED
@@ -0,0 +1,285 @@
1
+ // ─── Parameter counting ───────────────────────────────────────────────────────
2
+ /**
3
+ * Count the number of parameters in a signature string by finding the first
4
+ * `(...)` group and splitting by comma. Handles `...rest` as 1 param.
5
+ * Returns -1 when no parameter list can be found.
6
+ */
7
+ function countParams(signature) {
8
+ if (!signature)
9
+ return -1;
10
+ // Find the first balanced parenthesis group
11
+ const start = signature.indexOf("(");
12
+ if (start === -1)
13
+ return -1;
14
+ let depth = 0;
15
+ let end = -1;
16
+ for (let i = start; i < signature.length; i++) {
17
+ if (signature[i] === "(")
18
+ depth++;
19
+ else if (signature[i] === ")") {
20
+ depth--;
21
+ if (depth === 0) {
22
+ end = i;
23
+ break;
24
+ }
25
+ }
26
+ }
27
+ if (end === -1)
28
+ return -1;
29
+ const inner = signature.slice(start + 1, end).trim();
30
+ if (inner === "")
31
+ return 0;
32
+ // Split by top-level commas only (ignore generics / nested parens)
33
+ const parts = [];
34
+ let buf = "";
35
+ let nested = 0;
36
+ for (const ch of inner) {
37
+ if (ch === "(" || ch === "<" || ch === "[" || ch === "{") {
38
+ nested++;
39
+ buf += ch;
40
+ }
41
+ else if (ch === ")" || ch === ">" || ch === "]" || ch === "}") {
42
+ nested--;
43
+ buf += ch;
44
+ }
45
+ else if (ch === "," && nested === 0) {
46
+ parts.push(buf.trim());
47
+ buf = "";
48
+ }
49
+ else {
50
+ buf += ch;
51
+ }
52
+ }
53
+ if (buf.trim())
54
+ parts.push(buf.trim());
55
+ // Filter out empty strings and `this` parameters
56
+ const filtered = parts.filter((p) => p.length > 0 && p !== "this" && !p.startsWith("this:"));
57
+ return filtered.length;
58
+ }
59
+ /**
60
+ * Determine whether a signature's parameter list consists exclusively of
61
+ * primitive types (string, number, boolean). Returns true only when there are
62
+ * more than 3 params AND every param type annotation is a primitive (or a
63
+ * union of primitives).
64
+ */
65
+ function isPrimitiveObsession(signature, paramCount) {
66
+ if (!signature || paramCount <= 3)
67
+ return false;
68
+ const start = signature.indexOf("(");
69
+ if (start === -1)
70
+ return false;
71
+ let depth = 0;
72
+ let end = -1;
73
+ for (let i = start; i < signature.length; i++) {
74
+ if (signature[i] === "(")
75
+ depth++;
76
+ else if (signature[i] === ")") {
77
+ depth--;
78
+ if (depth === 0) {
79
+ end = i;
80
+ break;
81
+ }
82
+ }
83
+ }
84
+ if (end === -1)
85
+ return false;
86
+ const inner = signature.slice(start + 1, end).trim();
87
+ if (!inner)
88
+ return false;
89
+ // Remove `this` param
90
+ const withoutThis = inner.replace(/^this\s*:[^,]+,?\s*/, "");
91
+ // Strip parameter names — keep only the type annotation portion
92
+ // Handles: `name: type`, `name?: type`, `...rest: type`
93
+ // We check the extracted type annotations against known primitives
94
+ const PRIMITIVE_RE = /^(string|number|boolean)(\s*\|\s*(string|number|boolean))*(\[\])?(\s*\|\s*null|\s*\|\s*undefined)*$/;
95
+ // Extract type annotations: split by top-level comma, then grab the part after `:`
96
+ const parts = [];
97
+ let buf = "";
98
+ let nested2 = 0;
99
+ for (const ch of withoutThis) {
100
+ if (ch === "(" || ch === "<" || ch === "[" || ch === "{") {
101
+ nested2++;
102
+ buf += ch;
103
+ }
104
+ else if (ch === ")" || ch === ">" || ch === "]" || ch === "}") {
105
+ nested2--;
106
+ buf += ch;
107
+ }
108
+ else if (ch === "," && nested2 === 0) {
109
+ parts.push(buf.trim());
110
+ buf = "";
111
+ }
112
+ else {
113
+ buf += ch;
114
+ }
115
+ }
116
+ if (buf.trim())
117
+ parts.push(buf.trim());
118
+ const meaningful = parts.filter((p) => p.length > 0 && p !== "this" && !p.startsWith("this:"));
119
+ if (meaningful.length === 0)
120
+ return false;
121
+ for (const param of meaningful) {
122
+ // Strip leading `...` for rest params
123
+ const stripped = param.replace(/^\.\.\./, "");
124
+ // Get the type annotation after `:`
125
+ const colonIdx = stripped.indexOf(":");
126
+ if (colonIdx === -1) {
127
+ // No explicit type — treat as unknown (non-primitive)
128
+ return false;
129
+ }
130
+ const typeAnnotation = stripped.slice(colonIdx + 1).trim().replace(/\s*=\s*.*$/, ""); // remove default value
131
+ if (!PRIMITIVE_RE.test(typeAnnotation))
132
+ return false;
133
+ }
134
+ return true;
135
+ }
136
+ // ─── Individual smell detectors ───────────────────────────────────────────────
137
+ function checkGodClass(sym, file, maxMethods, maxFields) {
138
+ if (sym.kind !== "class")
139
+ return null;
140
+ const publicMethods = sym.children.filter((c) => (c.kind === "method" || c.kind === "function") && c.visibility === "public");
141
+ const fields = sym.children.filter((c) => c.kind === "field");
142
+ if (publicMethods.length > maxMethods) {
143
+ return {
144
+ file,
145
+ smell: "god-class",
146
+ symbol: sym.name,
147
+ severity: "warning",
148
+ message: `Class "${sym.name}" has ${publicMethods.length} public methods (threshold: ${maxMethods})`,
149
+ line: sym.range.startLine,
150
+ };
151
+ }
152
+ if (fields.length > maxFields) {
153
+ return {
154
+ file,
155
+ smell: "god-class",
156
+ symbol: sym.name,
157
+ severity: "warning",
158
+ message: `Class "${sym.name}" has ${fields.length} fields (threshold: ${maxFields})`,
159
+ line: sym.range.startLine,
160
+ };
161
+ }
162
+ return null;
163
+ }
164
+ function checkLongMethod(sym, file, maxLines) {
165
+ if (sym.kind !== "function" && sym.kind !== "method")
166
+ return null;
167
+ const length = sym.range.endLine - sym.range.startLine;
168
+ if (length > maxLines) {
169
+ return {
170
+ file,
171
+ smell: "long-method",
172
+ symbol: sym.name,
173
+ severity: "warning",
174
+ message: `"${sym.name}" is ${length} lines long (threshold: ${maxLines})`,
175
+ line: sym.range.startLine,
176
+ };
177
+ }
178
+ return null;
179
+ }
180
+ function checkLongParamList(sym, file, maxParams) {
181
+ if (sym.kind !== "function" && sym.kind !== "method")
182
+ return null;
183
+ const count = countParams(sym.signature);
184
+ if (count > maxParams) {
185
+ return {
186
+ file,
187
+ smell: "long-param-list",
188
+ symbol: sym.name,
189
+ severity: "warning",
190
+ message: `"${sym.name}" has ${count} parameters (threshold: ${maxParams})`,
191
+ line: sym.range.startLine,
192
+ };
193
+ }
194
+ return null;
195
+ }
196
+ function checkPrimitiveObsession(sym, file) {
197
+ if (sym.kind !== "function" && sym.kind !== "method")
198
+ return null;
199
+ const count = countParams(sym.signature);
200
+ if (count > 3 && isPrimitiveObsession(sym.signature, count)) {
201
+ return {
202
+ file,
203
+ smell: "primitive-obsession",
204
+ symbol: sym.name,
205
+ severity: "info",
206
+ message: `"${sym.name}" has ${count} parameters all of primitive types — consider a parameter object`,
207
+ line: sym.range.startLine,
208
+ };
209
+ }
210
+ return null;
211
+ }
212
+ function checkShallowWrapper(sym, file) {
213
+ if (sym.kind !== "class")
214
+ return null;
215
+ const publicMethods = sym.children.filter((c) => (c.kind === "method" || c.kind === "function") && c.visibility === "public");
216
+ if (publicMethods.length !== 1)
217
+ return null;
218
+ const method = publicMethods[0];
219
+ const methodLines = method.range.endLine - method.range.startLine;
220
+ if (methodLines <= 5) {
221
+ return {
222
+ file,
223
+ smell: "shallow-wrapper",
224
+ symbol: sym.name,
225
+ severity: "info",
226
+ message: `Class "${sym.name}" has exactly 1 public method "${method.name}" (${methodLines} lines) — may be a thin wrapper`,
227
+ line: sym.range.startLine,
228
+ };
229
+ }
230
+ return null;
231
+ }
232
+ // ─── Walk helpers ─────────────────────────────────────────────────────────────
233
+ /**
234
+ * Recursively walk top-level symbols and their children, collecting smells.
235
+ * `exported` is propagated from the parent class when checking methods.
236
+ */
237
+ function walkSymbols(symbols, file, maxMethods, maxFields, maxMethodLines, maxParams, results, parentExported) {
238
+ for (const sym of symbols) {
239
+ const isExported = sym.exported ?? parentExported;
240
+ // Only check exported (or publicly accessible) symbols
241
+ if (isExported || parentExported) {
242
+ // class-level smells
243
+ const godClass = checkGodClass(sym, file, maxMethods, maxFields);
244
+ if (godClass)
245
+ results.push(godClass);
246
+ const shallowWrapper = checkShallowWrapper(sym, file);
247
+ if (shallowWrapper)
248
+ results.push(shallowWrapper);
249
+ // function/method smells
250
+ const longMethod = checkLongMethod(sym, file, maxMethodLines);
251
+ if (longMethod)
252
+ results.push(longMethod);
253
+ const longParam = checkLongParamList(sym, file, maxParams);
254
+ if (longParam)
255
+ results.push(longParam);
256
+ const primitiveObs = checkPrimitiveObsession(sym, file);
257
+ if (primitiveObs)
258
+ results.push(primitiveObs);
259
+ }
260
+ // Recurse into children (methods inside a class inherit the parent's exported status)
261
+ if (sym.children.length > 0) {
262
+ walkSymbols(sym.children, file, maxMethods, maxFields, maxMethodLines, maxParams, results, isExported);
263
+ }
264
+ }
265
+ }
266
+ // ─── Main entry ───────────────────────────────────────────────────────────────
267
+ export function detectSmells(skel, sourceLineCount, opts) {
268
+ const maxMethods = opts?.maxMethods ?? 10;
269
+ const maxFields = opts?.maxFields ?? 8;
270
+ const maxMethodLines = opts?.maxMethodLines ?? 60;
271
+ const maxParams = opts?.maxParams ?? 4;
272
+ const results = [];
273
+ // ── large-file ────────────────────────────────────────────────────────────
274
+ if (sourceLineCount > 500) {
275
+ results.push({
276
+ file: skel.file,
277
+ smell: "large-file",
278
+ severity: "warning",
279
+ message: `File has ${sourceLineCount} lines (threshold: 500)`,
280
+ });
281
+ }
282
+ // ── symbol-level smells ───────────────────────────────────────────────────
283
+ walkSymbols(skel.symbols, skel.file, maxMethods, maxFields, maxMethodLines, maxParams, results, false);
284
+ return results;
285
+ }