universal-ast-mapper 2.0.0 → 2.0.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/CHANGELOG.md +15 -0
- package/README.md +261 -12
- package/dist/ai-refactor.js +185 -0
- package/dist/ai-testgen.js +105 -0
- package/dist/analysis.js +134 -0
- package/dist/arch-rules.js +82 -0
- package/dist/callgraph.js +467 -0
- package/dist/check.js +112 -0
- package/dist/cli.js +2284 -0
- package/dist/complexity.js +98 -0
- package/dist/config.js +53 -0
- package/dist/contextpack.js +79 -0
- package/dist/coupling.js +35 -0
- package/dist/covmerge.js +176 -0
- package/dist/crosslang.js +425 -0
- package/dist/dashboard.js +259 -0
- package/dist/diagram.js +264 -0
- package/dist/diskcache.js +97 -0
- package/dist/docgen.js +156 -0
- package/dist/embeddings.js +136 -0
- package/dist/explain.js +123 -0
- package/dist/explorer.js +123 -0
- package/dist/extractors/c.js +204 -0
- package/dist/extractors/common.js +56 -0
- package/dist/extractors/cpp.js +272 -0
- package/dist/extractors/csharp.js +209 -0
- package/dist/extractors/go.js +212 -0
- package/dist/extractors/java.js +152 -0
- package/dist/extractors/kotlin.js +159 -0
- package/dist/extractors/php.js +208 -0
- package/dist/extractors/python.js +153 -0
- package/dist/extractors/ruby.js +146 -0
- package/dist/extractors/rust.js +249 -0
- package/dist/extractors/swift.js +192 -0
- package/dist/extractors/typescript.js +577 -0
- package/dist/fix.js +92 -0
- package/dist/gitdiff.js +178 -0
- package/dist/graph-analysis.js +279 -0
- package/dist/graph.js +165 -0
- package/dist/history.js +36 -0
- package/dist/html.js +658 -0
- package/dist/incremental.js +122 -0
- package/dist/index.js +1945 -0
- package/dist/indexstore.js +105 -0
- package/dist/layers.js +36 -0
- package/dist/lsp.js +238 -0
- package/dist/modulecoupling.js +0 -0
- package/dist/parser.js +84 -0
- package/dist/patch.js +199 -0
- package/dist/plugins.js +88 -0
- package/dist/pool.js +114 -0
- package/dist/prompts.js +67 -0
- package/dist/registry.js +87 -0
- package/dist/report.js +441 -0
- package/dist/resolver.js +222 -0
- package/dist/roots.js +47 -0
- package/dist/search.js +68 -0
- package/dist/security.js +178 -0
- package/dist/semantic.js +365 -0
- package/dist/serve.js +328 -0
- package/dist/sfc.js +27 -0
- package/dist/similar.js +98 -0
- package/dist/skeleton.js +132 -0
- package/dist/smells.js +285 -0
- package/dist/sourcemap.js +60 -0
- package/dist/testgen.js +280 -0
- package/dist/testmap.js +167 -0
- package/dist/tsconfig.js +212 -0
- package/dist/typeflow.js +124 -0
- package/dist/types.js +5 -0
- package/dist/unused-params.js +127 -0
- package/dist/webapp.js +646 -0
- package/dist/worker.js +27 -0
- package/dist/workspace.js +330 -0
- package/package.json +2 -1
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
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
function decodeInline(url) {
|
|
4
|
+
const b64 = url.match(/base64,(.+)$/);
|
|
5
|
+
try {
|
|
6
|
+
if (b64)
|
|
7
|
+
return JSON.parse(Buffer.from(b64[1], "base64").toString("utf8"));
|
|
8
|
+
const comma = url.indexOf(",");
|
|
9
|
+
if (comma >= 0)
|
|
10
|
+
return JSON.parse(decodeURIComponent(url.slice(comma + 1)));
|
|
11
|
+
}
|
|
12
|
+
catch { /* malformed */ }
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Read the source map for a compiled JS/CSS file: handles an inline
|
|
17
|
+
* `//# sourceMappingURL=data:...` comment or an external `.map` file, and
|
|
18
|
+
* returns the original source paths it maps back to.
|
|
19
|
+
*/
|
|
20
|
+
export function readSourceMap(absPath, relPath) {
|
|
21
|
+
let src;
|
|
22
|
+
try {
|
|
23
|
+
src = fs.readFileSync(absPath, "utf8");
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
const matches = [...src.matchAll(/[#@]\s*sourceMappingURL=([^\s'"]+)/g)];
|
|
29
|
+
if (matches.length === 0)
|
|
30
|
+
return null;
|
|
31
|
+
const url = matches[matches.length - 1][1];
|
|
32
|
+
let map = null;
|
|
33
|
+
let mapKind;
|
|
34
|
+
let mapFile;
|
|
35
|
+
if (url.startsWith("data:")) {
|
|
36
|
+
mapKind = "inline";
|
|
37
|
+
map = decodeInline(url);
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
mapKind = "external";
|
|
41
|
+
mapFile = url;
|
|
42
|
+
try {
|
|
43
|
+
map = JSON.parse(fs.readFileSync(path.resolve(path.dirname(absPath), url), "utf8"));
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
map = null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (!map || !Array.isArray(map.sources))
|
|
50
|
+
return null;
|
|
51
|
+
const root = typeof map.sourceRoot === "string" ? map.sourceRoot.replace(/\/$/, "") : "";
|
|
52
|
+
const sources = map.sources.map((s) => root && !s.startsWith("/") ? root + "/" + s : s);
|
|
53
|
+
return {
|
|
54
|
+
file: relPath,
|
|
55
|
+
mapKind,
|
|
56
|
+
...(mapFile ? { mapFile } : {}),
|
|
57
|
+
sources,
|
|
58
|
+
hasContent: Array.isArray(map.sourcesContent) && map.sourcesContent.length > 0,
|
|
59
|
+
};
|
|
60
|
+
}
|
package/dist/testgen.js
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
// ─── Framework detection ───────────────────────────────────────────────────────
|
|
4
|
+
/** Infer the test framework from the nearest package.json. */
|
|
5
|
+
export function detectTestFramework(root) {
|
|
6
|
+
try {
|
|
7
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
|
|
8
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
9
|
+
if (deps["vitest"])
|
|
10
|
+
return "vitest";
|
|
11
|
+
if (deps["jest"] || deps["@jest/core"] || deps["ts-jest"] || deps["babel-jest"])
|
|
12
|
+
return "jest";
|
|
13
|
+
if (deps["mocha"])
|
|
14
|
+
return "mocha";
|
|
15
|
+
}
|
|
16
|
+
catch { /* no package.json */ }
|
|
17
|
+
return "node";
|
|
18
|
+
}
|
|
19
|
+
/** Derive the conventional test file path for a source file. */
|
|
20
|
+
export function resolveTestPath(sourceAbs, lang, outDir) {
|
|
21
|
+
const dir = outDir ?? path.dirname(sourceAbs);
|
|
22
|
+
const base = path.basename(sourceAbs);
|
|
23
|
+
const ext = path.extname(base);
|
|
24
|
+
const stem = base.slice(0, -ext.length);
|
|
25
|
+
if (lang === "python")
|
|
26
|
+
return path.join(dir, `test_${stem}.py`);
|
|
27
|
+
if (lang === "go")
|
|
28
|
+
return path.join(dir, `${stem}_test.go`);
|
|
29
|
+
if (lang === "java")
|
|
30
|
+
return path.join(dir, `${stem}Test.java`);
|
|
31
|
+
if (lang === "ruby")
|
|
32
|
+
return path.join(dir, `${stem}_spec.rb`);
|
|
33
|
+
return path.join(dir, `${stem}.test${ext}`);
|
|
34
|
+
}
|
|
35
|
+
// ─── Signature helpers ─────────────────────────────────────────────────────────
|
|
36
|
+
/** Extract param names from a function signature, returned as comment hints. */
|
|
37
|
+
function paramHints(sym) {
|
|
38
|
+
const s = sym.signature ?? "";
|
|
39
|
+
const m = s.match(/\(([^)]*)\)/);
|
|
40
|
+
if (!m || !m[1].trim())
|
|
41
|
+
return "";
|
|
42
|
+
return m[1]
|
|
43
|
+
.split(",")
|
|
44
|
+
.map((p) => {
|
|
45
|
+
const raw = p.trim()
|
|
46
|
+
.replace(/^\.\.\./, "")
|
|
47
|
+
.replace(/:.*$/, "")
|
|
48
|
+
.replace(/=.*$/, "")
|
|
49
|
+
.replace(/\?$/, "")
|
|
50
|
+
.trim();
|
|
51
|
+
return raw && raw !== "this" ? raw : null;
|
|
52
|
+
})
|
|
53
|
+
.filter(Boolean)
|
|
54
|
+
.map((n) => `/* ${n} */`)
|
|
55
|
+
.join(", ");
|
|
56
|
+
}
|
|
57
|
+
function isAsync(sym) {
|
|
58
|
+
return (sym.signature ?? "").includes("async ");
|
|
59
|
+
}
|
|
60
|
+
// ─── JavaScript / TypeScript ───────────────────────────────────────────────────
|
|
61
|
+
function jsFrameworkHeader(fw) {
|
|
62
|
+
if (fw === "vitest")
|
|
63
|
+
return [`import { describe, it, expect, beforeEach, vi } from 'vitest';`];
|
|
64
|
+
if (fw === "jest")
|
|
65
|
+
return [`import { describe, it, expect, beforeEach, jest } from '@jest/globals';`];
|
|
66
|
+
if (fw === "mocha")
|
|
67
|
+
return [`import { describe, it } from 'mocha';`, `import { expect } from 'chai';`];
|
|
68
|
+
// node:test (default)
|
|
69
|
+
return [`import { describe, it } from 'node:test';`, `import assert from 'node:assert/strict';`];
|
|
70
|
+
}
|
|
71
|
+
function jsAssert(fw, expr, indent) {
|
|
72
|
+
if (fw === "node")
|
|
73
|
+
return `${indent}assert.ok(${expr}); // TODO: assert expected value`;
|
|
74
|
+
return `${indent}expect(${expr}).toBeDefined(); // TODO: assert expected value`;
|
|
75
|
+
}
|
|
76
|
+
function jsSymbolTests(sym, fw, isTs) {
|
|
77
|
+
const lines = [];
|
|
78
|
+
let count = 0;
|
|
79
|
+
if (sym.kind === "function") {
|
|
80
|
+
const hint = paramHints(sym);
|
|
81
|
+
const awaitKw = isAsync(sym) ? "await " : "";
|
|
82
|
+
const itKw = isAsync(sym) ? "it('should ...', async () => {" : "it('should ...', () => {";
|
|
83
|
+
count++;
|
|
84
|
+
lines.push(`describe('${sym.name}', () => {`, ` ${itKw}`, ` // TODO: arrange`, ` const result = ${awaitKw}${sym.name}(${hint});`, jsAssert(fw, "result", " "), ` });`, `});`, "");
|
|
85
|
+
}
|
|
86
|
+
else if (sym.kind === "class") {
|
|
87
|
+
const publicMethods = sym.children.filter((c) => c.kind === "method" && c.visibility === "public" && c.name !== "constructor");
|
|
88
|
+
const typeAnno = isTs ? `: ${sym.name}` : "";
|
|
89
|
+
lines.push(`describe('${sym.name}', () => {`, ` let instance${typeAnno};`, "", ` beforeEach(() => {`, ` instance = new ${sym.name}(/* TODO: constructor args */);`, ` });`, "");
|
|
90
|
+
if (publicMethods.length === 0) {
|
|
91
|
+
count++;
|
|
92
|
+
lines.push(` it('should be instantiable', () => {`, jsAssert(fw, "instance", " "), ` });`);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
for (const m of publicMethods) {
|
|
96
|
+
const hint = paramHints(m);
|
|
97
|
+
const awaitKw = isAsync(m) ? "await " : "";
|
|
98
|
+
const itKw = isAsync(m) ? `it('${m.name}: should ...', async () => {` : `it('${m.name}: should ...', () => {`;
|
|
99
|
+
count++;
|
|
100
|
+
lines.push(` ${itKw}`, ` // TODO: arrange`, ` const result = ${awaitKw}instance.${m.name}(${hint});`, jsAssert(fw, "result", " "), ` });`, "");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
lines.push(`});`, "");
|
|
104
|
+
}
|
|
105
|
+
else if (sym.kind === "const" || sym.kind === "var") {
|
|
106
|
+
count++;
|
|
107
|
+
lines.push(`describe('${sym.name}', () => {`, ` it('should be defined', () => {`, jsAssert(fw, sym.name, " "), ` });`, `});`, "");
|
|
108
|
+
}
|
|
109
|
+
return { lines, count };
|
|
110
|
+
}
|
|
111
|
+
function generateJsTest(skel, syms, fw, isTs) {
|
|
112
|
+
const lines = [...jsFrameworkHeader(fw), ""];
|
|
113
|
+
const srcBase = path.basename(skel.file).replace(/\.[^.]+$/, "");
|
|
114
|
+
const srcPath = `./${srcBase}${isTs ? "" : ".js"}`;
|
|
115
|
+
const runtimeImports = syms.filter((s) => !["interface", "type"].includes(s.kind)).map((s) => s.name);
|
|
116
|
+
if (runtimeImports.length > 0)
|
|
117
|
+
lines.push(`import { ${runtimeImports.join(", ")} } from '${srcPath}';`);
|
|
118
|
+
if (isTs) {
|
|
119
|
+
const typeImports = syms.filter((s) => s.kind === "interface" || s.kind === "type").map((s) => s.name);
|
|
120
|
+
if (typeImports.length > 0)
|
|
121
|
+
lines.push(`import type { ${typeImports.join(", ")} } from '${srcPath}';`);
|
|
122
|
+
}
|
|
123
|
+
lines.push("");
|
|
124
|
+
let testCount = 0;
|
|
125
|
+
for (const sym of syms) {
|
|
126
|
+
const { lines: symLines, count } = jsSymbolTests(sym, fw, isTs);
|
|
127
|
+
lines.push(...symLines);
|
|
128
|
+
testCount += count;
|
|
129
|
+
}
|
|
130
|
+
return { content: lines.join("\n"), testCount };
|
|
131
|
+
}
|
|
132
|
+
// ─── Python ───────────────────────────────────────────────────────────────────
|
|
133
|
+
function generatePyTest(skel, syms) {
|
|
134
|
+
const lines = ["import pytest", ""];
|
|
135
|
+
const mod = path.basename(skel.file).replace(/\.py$/, "");
|
|
136
|
+
const fns = syms.filter((s) => s.kind === "function" && !s.name.startsWith("_"));
|
|
137
|
+
const classes = syms.filter((s) => s.kind === "class");
|
|
138
|
+
const toImport = [...fns.map((s) => s.name), ...classes.map((s) => s.name)];
|
|
139
|
+
if (toImport.length > 0)
|
|
140
|
+
lines.push(`from .${mod} import ${toImport.join(", ")}`, "");
|
|
141
|
+
let testCount = 0;
|
|
142
|
+
for (const fn of fns) {
|
|
143
|
+
const hint = paramHints(fn).replace(/\/\* (\w+) \*\//g, "$1");
|
|
144
|
+
testCount++;
|
|
145
|
+
lines.push(`def test_${fn.name}():`, ` # TODO: arrange`, ` result = ${fn.name}(${hint})`, ` assert result is not None # TODO: assert expected value`, "");
|
|
146
|
+
}
|
|
147
|
+
for (const cls of classes) {
|
|
148
|
+
const methods = cls.children.filter((c) => c.kind === "method" && c.visibility === "public" && !c.name.startsWith("__"));
|
|
149
|
+
lines.push(`class Test${cls.name}:`, "");
|
|
150
|
+
lines.push(` def setup_method(self):`, ` self.instance = ${cls.name}() # TODO: args`, "");
|
|
151
|
+
if (methods.length === 0) {
|
|
152
|
+
testCount++;
|
|
153
|
+
lines.push(` def test_created(self):`, ` assert self.instance is not None`, "");
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
for (const m of methods) {
|
|
157
|
+
const hint = paramHints(m).replace(/\/\* (\w+) \*\//g, "$1");
|
|
158
|
+
testCount++;
|
|
159
|
+
lines.push(` def test_${m.name}(self):`, ` # TODO: arrange`, ` result = self.instance.${m.name}(${hint})`, ` assert result is not None # TODO: assert expected value`, "");
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return { content: lines.join("\n"), testCount };
|
|
164
|
+
}
|
|
165
|
+
// ─── Go ───────────────────────────────────────────────────────────────────────
|
|
166
|
+
function generateGoTest(skel, syms) {
|
|
167
|
+
const pkgDir = path.dirname(skel.file).split("/").pop() ?? "main";
|
|
168
|
+
const lines = [`package ${pkgDir}`, "", `import (`, `\t"testing"`, `)`, ""];
|
|
169
|
+
let testCount = 0;
|
|
170
|
+
const fns = syms.filter((s) => s.kind === "function" && s.exported);
|
|
171
|
+
const structs = syms.filter((s) => s.kind === "struct" && s.exported);
|
|
172
|
+
for (const fn of fns) {
|
|
173
|
+
const hint = paramHints(fn).replace(/\/\* (\w+) \*\//g, "/* $1 */");
|
|
174
|
+
testCount++;
|
|
175
|
+
lines.push(`func Test${fn.name}(t *testing.T) {`, `\t// TODO: arrange`, `\t_ = ${fn.name}(${hint})`, `\t// if got != want { t.Errorf("expected %v, got %v", want, got) }`, `}`, "");
|
|
176
|
+
}
|
|
177
|
+
for (const s of structs) {
|
|
178
|
+
const methods = s.children.filter((c) => c.kind === "method" && c.visibility === "public");
|
|
179
|
+
for (const m of methods) {
|
|
180
|
+
testCount++;
|
|
181
|
+
lines.push(`func Test${s.name}_${m.name}(t *testing.T) {`, `\t// TODO: arrange`, `\t// instance := ${s.name}{}`, `\t// got := instance.${m.name}(/* args */)`, `\t// if got != want { t.Errorf("expected %v, got %v", want, got) }`, `}`, "");
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return { content: lines.join("\n"), testCount };
|
|
185
|
+
}
|
|
186
|
+
// ─── Java ─────────────────────────────────────────────────────────────────────
|
|
187
|
+
function generateJavaTest(skel, syms) {
|
|
188
|
+
const classes = syms.filter((s) => s.kind === "class" && s.exported);
|
|
189
|
+
const lines = [
|
|
190
|
+
`import org.junit.jupiter.api.Test;`,
|
|
191
|
+
`import org.junit.jupiter.api.BeforeEach;`,
|
|
192
|
+
`import static org.junit.jupiter.api.Assertions.*;`,
|
|
193
|
+
"",
|
|
194
|
+
];
|
|
195
|
+
let testCount = 0;
|
|
196
|
+
for (const cls of classes) {
|
|
197
|
+
const methods = cls.children.filter((c) => c.kind === "method" && c.visibility === "public");
|
|
198
|
+
lines.push(`class ${cls.name}Test {`, "", ` private ${cls.name} instance;`, "");
|
|
199
|
+
lines.push(` @BeforeEach`, ` void setUp() {`, ` instance = new ${cls.name}(); // TODO: args`, ` }`, "");
|
|
200
|
+
if (methods.length === 0) {
|
|
201
|
+
testCount++;
|
|
202
|
+
lines.push(` @Test`, ` void shouldBeCreated() {`, ` assertNotNull(instance);`, ` }`, "");
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
for (const m of methods) {
|
|
206
|
+
if (m.name === "constructor" || m.name === cls.name)
|
|
207
|
+
continue;
|
|
208
|
+
testCount++;
|
|
209
|
+
const camel = m.name.charAt(0).toUpperCase() + m.name.slice(1);
|
|
210
|
+
lines.push(` @Test`, ` void ${m.name}ShouldWork() {`, ` // TODO: arrange`, ` var result = instance.${m.name}(/* args */);`, ` assertNotNull(result); // TODO: assert expected value`, ` }`, "");
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
lines.push(`}`);
|
|
214
|
+
}
|
|
215
|
+
return { content: lines.join("\n"), testCount };
|
|
216
|
+
}
|
|
217
|
+
// ─── Ruby ─────────────────────────────────────────────────────────────────────
|
|
218
|
+
function generateRubyTest(skel, syms) {
|
|
219
|
+
const srcRel = "./" + path.basename(skel.file).replace(/\.rb$/, "");
|
|
220
|
+
const lines = [`require 'rspec'`, `require_relative '${srcRel}'`, ""];
|
|
221
|
+
let testCount = 0;
|
|
222
|
+
const classes = syms.filter((s) => s.kind === "class");
|
|
223
|
+
const fns = syms.filter((s) => s.kind === "function");
|
|
224
|
+
for (const cls of classes) {
|
|
225
|
+
const methods = cls.children.filter((c) => c.kind === "method" && c.visibility === "public" && !c.name.startsWith("initialize"));
|
|
226
|
+
lines.push(`RSpec.describe ${cls.name} do`, ` subject { described_class.new }`, "");
|
|
227
|
+
if (methods.length === 0) {
|
|
228
|
+
testCount++;
|
|
229
|
+
lines.push(` it 'is instantiable' do`, ` expect(subject).not_to be_nil`, ` end`, "");
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
for (const m of methods) {
|
|
233
|
+
testCount++;
|
|
234
|
+
lines.push(` describe '#${m.name}' do`, ` it 'should ...' do`, ` # TODO: arrange`, ` result = subject.${m.name}(/* args */)`, ` expect(result).not_to be_nil`, ` end`, ` end`, "");
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
lines.push(`end`, "");
|
|
238
|
+
}
|
|
239
|
+
for (const fn of fns) {
|
|
240
|
+
testCount++;
|
|
241
|
+
lines.push(`RSpec.describe '${fn.name}' do`, ` it 'should ...' do`, ` result = ${fn.name}(/* args */)`, ` expect(result).not_to be_nil`, ` end`, `end`, "");
|
|
242
|
+
}
|
|
243
|
+
return { content: lines.join("\n"), testCount };
|
|
244
|
+
}
|
|
245
|
+
// ─── Entry point ───────────────────────────────────────────────────────────────
|
|
246
|
+
/** Generate a test file for a parsed skeleton. Returns content and metadata. */
|
|
247
|
+
export function generateTestFile(skel, sourceAbs, opts = {}) {
|
|
248
|
+
const fw = opts.framework ?? "node";
|
|
249
|
+
const exportedOnly = opts.exportedOnly ?? true;
|
|
250
|
+
const syms = exportedOnly
|
|
251
|
+
? skel.symbols.filter((s) => s.exported !== false)
|
|
252
|
+
: skel.symbols;
|
|
253
|
+
const testPath = resolveTestPath(sourceAbs, skel.language, opts.outDir);
|
|
254
|
+
const lang = skel.language;
|
|
255
|
+
let content;
|
|
256
|
+
let testCount;
|
|
257
|
+
if (lang === "typescript" || lang === "tsx") {
|
|
258
|
+
({ content, testCount } = generateJsTest(skel, syms, fw, true));
|
|
259
|
+
}
|
|
260
|
+
else if (lang === "javascript" || lang === "jsx") {
|
|
261
|
+
({ content, testCount } = generateJsTest(skel, syms, fw, false));
|
|
262
|
+
}
|
|
263
|
+
else if (lang === "python") {
|
|
264
|
+
({ content, testCount } = generatePyTest(skel, syms));
|
|
265
|
+
}
|
|
266
|
+
else if (lang === "go") {
|
|
267
|
+
({ content, testCount } = generateGoTest(skel, syms));
|
|
268
|
+
}
|
|
269
|
+
else if (lang === "java") {
|
|
270
|
+
({ content, testCount } = generateJavaTest(skel, syms));
|
|
271
|
+
}
|
|
272
|
+
else if (lang === "ruby") {
|
|
273
|
+
({ content, testCount } = generateRubyTest(skel, syms));
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
content = `// Test file for ${skel.file}\n// Language: ${lang} — add tests manually\n`;
|
|
277
|
+
testCount = 0;
|
|
278
|
+
}
|
|
279
|
+
return { sourceFile: skel.file, testFilePath: testPath, framework: fw, content, testCount };
|
|
280
|
+
}
|