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/BLUEPRINT.md +230 -230
- package/CHANGELOG.md +475 -338
- package/README.md +1127 -878
- package/dist/ai-refactor.js +185 -0
- package/dist/ai-testgen.js +105 -0
- package/dist/arch-rules.js +82 -0
- package/dist/cli.js +1029 -20
- package/dist/covmerge.js +176 -0
- package/dist/dashboard.js +259 -0
- package/dist/diagram.js +264 -0
- package/dist/docgen.js +156 -0
- package/dist/embeddings.js +136 -0
- package/dist/explain.js +123 -0
- package/dist/fix.js +92 -0
- package/dist/history.js +36 -0
- package/dist/html.js +602 -270
- package/dist/incremental.js +122 -0
- package/dist/index.js +537 -0
- package/dist/indexstore.js +105 -0
- package/dist/lsp.js +238 -0
- package/dist/patch.js +199 -0
- package/dist/plugins.js +88 -0
- package/dist/report.js +285 -76
- package/dist/security.js +178 -0
- package/dist/serve.js +185 -0
- package/dist/similar.js +98 -0
- package/dist/smells.js +285 -0
- package/dist/testgen.js +280 -0
- package/dist/webapp.js +341 -0
- package/package.json +49 -47
- package/scripts/install-skill.mjs +187 -187
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
|
+
}
|
package/dist/similar.js
ADDED
|
@@ -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
|
+
}
|