trimcode 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +127 -2
- package/bin/cli.js +103 -0
- package/package.json +45 -4
- package/src/index.d.ts +141 -0
- package/src/index.js +794 -0
- package/src/parse.js +64 -0
- package/src/util.js +119 -0
- package/src/walk.js +32 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,794 @@
|
|
|
1
|
+
import { builtinModules } from 'node:module';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { parseSource, ParseError } from './parse.js';
|
|
4
|
+
import { walk } from './walk.js';
|
|
5
|
+
import {
|
|
6
|
+
propKey,
|
|
7
|
+
propName,
|
|
8
|
+
memberPath,
|
|
9
|
+
calleeName,
|
|
10
|
+
stringArg,
|
|
11
|
+
snippet,
|
|
12
|
+
patternNames,
|
|
13
|
+
isRequireCall,
|
|
14
|
+
jsdocSummary,
|
|
15
|
+
} from './util.js';
|
|
16
|
+
|
|
17
|
+
export { ParseError };
|
|
18
|
+
export const SCHEMA_VERSION = 1;
|
|
19
|
+
|
|
20
|
+
const BUILTINS = new Set(builtinModules);
|
|
21
|
+
const FUNCTION_TYPES = new Set(['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression']);
|
|
22
|
+
const CLASS_TYPES = new Set(['ClassDeclaration', 'ClassExpression']);
|
|
23
|
+
const COMPLEXITY_TYPES = new Set([
|
|
24
|
+
'IfStatement',
|
|
25
|
+
'ConditionalExpression',
|
|
26
|
+
'ForStatement',
|
|
27
|
+
'ForInStatement',
|
|
28
|
+
'ForOfStatement',
|
|
29
|
+
'WhileStatement',
|
|
30
|
+
'DoWhileStatement',
|
|
31
|
+
'CatchClause',
|
|
32
|
+
]);
|
|
33
|
+
const NESTING_TYPES = new Set([
|
|
34
|
+
'IfStatement',
|
|
35
|
+
'ForStatement',
|
|
36
|
+
'ForInStatement',
|
|
37
|
+
'ForOfStatement',
|
|
38
|
+
'WhileStatement',
|
|
39
|
+
'DoWhileStatement',
|
|
40
|
+
'SwitchStatement',
|
|
41
|
+
'TryStatement',
|
|
42
|
+
]);
|
|
43
|
+
// Boundaries when looking for the statement a JSDoc comment is attached to.
|
|
44
|
+
const DOC_BOUNDARY = new Set([
|
|
45
|
+
'Program',
|
|
46
|
+
'BlockStatement',
|
|
47
|
+
'ClassBody',
|
|
48
|
+
'ObjectExpression',
|
|
49
|
+
'ArrayExpression',
|
|
50
|
+
...FUNCTION_TYPES,
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
const HTTP_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'all', 'use']);
|
|
54
|
+
const ROUTER_LIKE = /^(app|router|routes?|server|fastify|express|\w*Router|\w*App)$/i;
|
|
55
|
+
const HTTP_CLIENTS = new Set([
|
|
56
|
+
'axios', 'fetch', 'http', 'https', 'got', 'ky', 'request', 'superagent', 'supertest', 'agent', 'client', '$http', '$',
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
const CONNECTION_CLASSES = new Set(['Pool', 'Client', 'MongoClient', 'Sequelize', 'PrismaClient', 'Redis', 'WebSocket']);
|
|
60
|
+
const SERVER_CLASSES = new Set(['WebSocketServer']);
|
|
61
|
+
const CHILD_PROCESS_FNS = new Set(['exec', 'execSync', 'execFile', 'execFileSync', 'spawn', 'spawnSync', 'fork']);
|
|
62
|
+
const FS_OPERATION =
|
|
63
|
+
/(Sync$)|^(readFile|writeFile|appendFile|mkdir|mkdtemp|rm|rmdir|unlink|rename|copyFile|cp|readdir|stat|access|truncate|chmod|chown|symlink|createReadStream|createWriteStream|watch|watchFile)$/;
|
|
64
|
+
|
|
65
|
+
const SIDE_EFFECT_PHRASES = {
|
|
66
|
+
'server-listen': 'starts a server',
|
|
67
|
+
'server-create': 'creates a server',
|
|
68
|
+
connection: 'opens connections',
|
|
69
|
+
'env-load': 'loads environment variables from a file',
|
|
70
|
+
'process-handler': 'registers process event handlers',
|
|
71
|
+
'process-exit': 'can exit the process',
|
|
72
|
+
timer: 'schedules timers',
|
|
73
|
+
filesystem: 'touches the filesystem',
|
|
74
|
+
'child-process': 'spawns child processes',
|
|
75
|
+
'console-output': 'writes to the console',
|
|
76
|
+
'global-mutation': 'modifies globals',
|
|
77
|
+
'top-level-await': 'awaits at the top level',
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Imports
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
function classifySpecifier(specifier) {
|
|
85
|
+
if (specifier.startsWith('node:')) return { kind: 'builtin', name: specifier.slice(5) };
|
|
86
|
+
if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('#')) {
|
|
87
|
+
return { kind: 'local', name: specifier };
|
|
88
|
+
}
|
|
89
|
+
if (BUILTINS.has(specifier)) return { kind: 'builtin', name: specifier };
|
|
90
|
+
const parts = specifier.split('/');
|
|
91
|
+
const name = specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0];
|
|
92
|
+
return { kind: 'package', name };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function makeImport(source, style, line, { binding, names } = {}) {
|
|
96
|
+
const entry = { source };
|
|
97
|
+
if (source === null) {
|
|
98
|
+
entry.kind = 'dynamic';
|
|
99
|
+
} else {
|
|
100
|
+
const c = classifySpecifier(source);
|
|
101
|
+
entry.kind = c.kind;
|
|
102
|
+
if (c.kind === 'package') entry.package = c.name;
|
|
103
|
+
}
|
|
104
|
+
entry.style = style;
|
|
105
|
+
if (binding) entry.binding = binding;
|
|
106
|
+
if (names?.length) entry.names = names;
|
|
107
|
+
entry.line = line;
|
|
108
|
+
return entry;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** First pass: every import/require/import(), plus a map from local names to module specifiers. */
|
|
112
|
+
function collectImports(ast) {
|
|
113
|
+
const imports = [];
|
|
114
|
+
const bindings = new Map();
|
|
115
|
+
|
|
116
|
+
const register = (entry, locals) => {
|
|
117
|
+
imports.push(entry);
|
|
118
|
+
if (entry.source !== null) for (const name of locals) bindings.set(name, entry.source);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
walk(ast, {
|
|
122
|
+
enter(node, anc) {
|
|
123
|
+
const parent = anc[anc.length - 1];
|
|
124
|
+
|
|
125
|
+
if (node.type === 'ImportDeclaration') {
|
|
126
|
+
const locals = [];
|
|
127
|
+
const names = [];
|
|
128
|
+
let binding;
|
|
129
|
+
for (const spec of node.specifiers) {
|
|
130
|
+
locals.push(spec.local.name);
|
|
131
|
+
if (spec.type === 'ImportSpecifier') names.push(spec.imported.name ?? String(spec.imported.value));
|
|
132
|
+
else binding = spec.local.name;
|
|
133
|
+
}
|
|
134
|
+
register(makeImport(node.source.value, 'import', node.loc.start.line, { binding, names }), locals);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (node.type === 'ImportExpression') {
|
|
139
|
+
register(makeImport(stringArg(node.source), 'dynamic-import', node.loc.start.line), []);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (isRequireCall(node)) {
|
|
144
|
+
let binding;
|
|
145
|
+
let names;
|
|
146
|
+
let locals = [];
|
|
147
|
+
if (parent?.type === 'VariableDeclarator' && parent.init === node) {
|
|
148
|
+
if (parent.id.type === 'Identifier') {
|
|
149
|
+
binding = parent.id.name;
|
|
150
|
+
locals = [binding];
|
|
151
|
+
} else if (parent.id.type === 'ObjectPattern') {
|
|
152
|
+
names = parent.id.properties
|
|
153
|
+
.filter((p) => p.type === 'Property')
|
|
154
|
+
.map((p) => propKey(p.key, p.computed))
|
|
155
|
+
.filter(Boolean);
|
|
156
|
+
locals = patternNames(parent.id);
|
|
157
|
+
}
|
|
158
|
+
} else if (parent?.type === 'MemberExpression' && parent.object === node) {
|
|
159
|
+
const key = propName(parent);
|
|
160
|
+
if (key) names = [key];
|
|
161
|
+
const grand = anc[anc.length - 2];
|
|
162
|
+
if (grand?.type === 'VariableDeclarator' && grand.init === parent && grand.id.type === 'Identifier') {
|
|
163
|
+
locals = [grand.id.name];
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
register(makeImport(stringArg(node.arguments[0]), 'require', node.loc.start.line, { binding, names }), locals);
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
return { imports, bindings };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// Naming helpers
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
/** `module.exports` / `exports.x` / any other assignment target as { name, exported }. */
|
|
179
|
+
function assignTarget(left) {
|
|
180
|
+
const path = memberPath(left);
|
|
181
|
+
if (!path) return null;
|
|
182
|
+
if (path === 'module.exports') return { name: 'module.exports', exported: true };
|
|
183
|
+
const m = /^(?:module\.)?exports\.([^.]+)$/.exec(path);
|
|
184
|
+
if (m) return { name: m[1], exported: true };
|
|
185
|
+
return { name: path, exported: false };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The name a function/class expression is bound to by its parent, if any. */
|
|
189
|
+
function bindingName(node, anc) {
|
|
190
|
+
const parent = anc[anc.length - 1];
|
|
191
|
+
if (!parent) return null;
|
|
192
|
+
switch (parent.type) {
|
|
193
|
+
case 'VariableDeclarator':
|
|
194
|
+
return parent.init === node && parent.id.type === 'Identifier' ? { name: parent.id.name } : null;
|
|
195
|
+
case 'AssignmentExpression':
|
|
196
|
+
return parent.right === node && parent.operator === '=' ? assignTarget(parent.left) : null;
|
|
197
|
+
case 'ExportDefaultDeclaration':
|
|
198
|
+
return { name: 'default' };
|
|
199
|
+
case 'Property': {
|
|
200
|
+
if (parent.value !== node) return null;
|
|
201
|
+
const key = propKey(parent.key, parent.computed);
|
|
202
|
+
if (!key) return null;
|
|
203
|
+
const object = anc[anc.length - 2];
|
|
204
|
+
const owner = anc[anc.length - 3];
|
|
205
|
+
if (object?.type === 'ObjectExpression') {
|
|
206
|
+
if (owner?.type === 'VariableDeclarator' && owner.init === object && owner.id.type === 'Identifier') {
|
|
207
|
+
return { name: `${owner.id.name}.${key}` };
|
|
208
|
+
}
|
|
209
|
+
if (owner?.type === 'AssignmentExpression' && owner.right === object) {
|
|
210
|
+
const target = assignTarget(owner.left);
|
|
211
|
+
if (target?.name === 'module.exports') return { name: key, exported: true };
|
|
212
|
+
if (target) return { name: `${target.name}.${key}` };
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return { name: key };
|
|
216
|
+
}
|
|
217
|
+
default:
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function methodKey(code, key, computed) {
|
|
223
|
+
return propKey(key, computed) ?? `[${snippet(code, key, 40)}]`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function describeFunction(code, node, anc, classStack, routeNames) {
|
|
227
|
+
const parent = anc[anc.length - 1];
|
|
228
|
+
const cls = classStack[classStack.length - 1];
|
|
229
|
+
const className = cls?.name ?? '<anonymous>';
|
|
230
|
+
|
|
231
|
+
if (parent?.type === 'MethodDefinition' && parent.value === node) {
|
|
232
|
+
const kind =
|
|
233
|
+
parent.kind === 'constructor' ? 'constructor' : parent.kind === 'get' ? 'getter' : parent.kind === 'set' ? 'setter' : 'method';
|
|
234
|
+
const key = methodKey(code, parent.key, parent.computed);
|
|
235
|
+
return { name: `${className}.${key}`, kind, isStatic: parent.static, classMethod: { cls, key } };
|
|
236
|
+
}
|
|
237
|
+
if (parent?.type === 'PropertyDefinition' && parent.value === node) {
|
|
238
|
+
const key = methodKey(code, parent.key, parent.computed);
|
|
239
|
+
return { name: `${className}.${key}`, kind: 'method', isStatic: parent.static, classMethod: { cls, key } };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const routeName = routeNames.get(node);
|
|
243
|
+
if (routeName) return { name: routeName, kind: 'route-handler' };
|
|
244
|
+
|
|
245
|
+
const kind = node.type === 'ArrowFunctionExpression' ? 'arrow' : 'function';
|
|
246
|
+
if (node.type === 'FunctionDeclaration') return { name: node.id?.name ?? 'default', kind };
|
|
247
|
+
|
|
248
|
+
const bound = bindingName(node, anc);
|
|
249
|
+
if (bound) return { name: bound.name, kind, exported: bound.exported };
|
|
250
|
+
if (node.id) return { name: node.id.name, kind };
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** JSDoc summary attached to the statement that contains `node`, if any. */
|
|
255
|
+
function findDoc(node, anc, docsByEndLine, usedDocs) {
|
|
256
|
+
const lines = [node.loc.start.line];
|
|
257
|
+
for (let i = anc.length - 1; i >= 0; i--) {
|
|
258
|
+
if (DOC_BOUNDARY.has(anc[i].type)) break;
|
|
259
|
+
lines.push(anc[i].loc.start.line);
|
|
260
|
+
}
|
|
261
|
+
for (const line of lines) {
|
|
262
|
+
for (const endLine of [line - 1, line]) {
|
|
263
|
+
const doc = docsByEndLine.get(endLine);
|
|
264
|
+
if (doc && !usedDocs.has(doc)) {
|
|
265
|
+
usedDocs.add(doc);
|
|
266
|
+
return jsdocSummary(doc.value);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return undefined;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
// Routes
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
function handlerLabel(arg, routeLabel) {
|
|
278
|
+
switch (arg.type) {
|
|
279
|
+
case 'Identifier':
|
|
280
|
+
return arg.name;
|
|
281
|
+
case 'MemberExpression':
|
|
282
|
+
return memberPath(arg) ?? '<expression>';
|
|
283
|
+
case 'CallExpression': {
|
|
284
|
+
const name = calleeName(arg.callee);
|
|
285
|
+
return name ? `${name}()` : '<expression>';
|
|
286
|
+
}
|
|
287
|
+
case 'FunctionExpression':
|
|
288
|
+
case 'ArrowFunctionExpression':
|
|
289
|
+
return routeLabel;
|
|
290
|
+
default:
|
|
291
|
+
return '<expression>';
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Recognise `app.get('/x', ...handlers)` and `router.route('/x').get(...handlers)`.
|
|
297
|
+
* This is a heuristic: HTTP-client calls such as `axios.get('/x')` are excluded.
|
|
298
|
+
*/
|
|
299
|
+
function detectRoute(node) {
|
|
300
|
+
const callee = node.callee;
|
|
301
|
+
if (callee.type !== 'MemberExpression') return null;
|
|
302
|
+
const method = propName(callee);
|
|
303
|
+
if (!method || !HTTP_METHODS.has(method)) return null;
|
|
304
|
+
|
|
305
|
+
let path = null;
|
|
306
|
+
let handlerArgs = node.arguments;
|
|
307
|
+
let viaRoute = false;
|
|
308
|
+
|
|
309
|
+
let object = callee.object;
|
|
310
|
+
while (object?.type === 'CallExpression' && object.callee.type === 'MemberExpression') {
|
|
311
|
+
if (propName(object.callee) === 'route') {
|
|
312
|
+
path = stringArg(object.arguments[0]);
|
|
313
|
+
viaRoute = true;
|
|
314
|
+
break;
|
|
315
|
+
}
|
|
316
|
+
object = object.callee.object;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (!viaRoute) {
|
|
320
|
+
path = stringArg(node.arguments[0]);
|
|
321
|
+
handlerArgs = node.arguments.slice(1);
|
|
322
|
+
}
|
|
323
|
+
if (path === null || !path.startsWith('/')) return null;
|
|
324
|
+
|
|
325
|
+
const handlers = handlerArgs.filter((a) => a.type !== 'ObjectExpression' && a.type !== 'Literal' && a.type !== 'SpreadElement');
|
|
326
|
+
if (handlers.length === 0) return null;
|
|
327
|
+
|
|
328
|
+
if (!viaRoute) {
|
|
329
|
+
const objectPath = memberPath(callee.object);
|
|
330
|
+
const root = objectPath?.split('.')[0];
|
|
331
|
+
const leaf = objectPath?.split('.').pop();
|
|
332
|
+
if ((root && HTTP_CLIENTS.has(root)) || (leaf && HTTP_CLIENTS.has(leaf))) return null;
|
|
333
|
+
const hasInlineFunction = handlers.some((a) => a.type === 'FunctionExpression' || a.type === 'ArrowFunctionExpression');
|
|
334
|
+
const looksLikeRouter = Boolean((root && ROUTER_LIKE.test(root)) || (leaf && ROUTER_LIKE.test(leaf)));
|
|
335
|
+
if (!hasInlineFunction && !looksLikeRouter) return null;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return { method: method.toUpperCase(), path, handlers };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
// Side effects
|
|
343
|
+
// ---------------------------------------------------------------------------
|
|
344
|
+
|
|
345
|
+
function rootModule(callee, bindings) {
|
|
346
|
+
let n = callee;
|
|
347
|
+
while (n?.type === 'MemberExpression') n = n.object;
|
|
348
|
+
if (n?.type === 'ChainExpression') n = n.expression;
|
|
349
|
+
let specifier;
|
|
350
|
+
if (n?.type === 'Identifier') specifier = bindings.get(n.name);
|
|
351
|
+
else if (isRequireCall(n)) specifier = stringArg(n.arguments[0]);
|
|
352
|
+
return specifier?.replace(/^node:/, '');
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function detectCallSideEffect(code, node, bindings) {
|
|
356
|
+
const name = calleeName(node.callee);
|
|
357
|
+
if (!name) return null;
|
|
358
|
+
const last = name.slice(name.lastIndexOf('.') + 1);
|
|
359
|
+
const mod = rootModule(node.callee, bindings);
|
|
360
|
+
const firstArg = node.arguments[0] ? snippet(code, node.arguments[0], 50) : undefined;
|
|
361
|
+
|
|
362
|
+
if (name === 'process.exit') return { type: 'process-exit', detail: name };
|
|
363
|
+
if (name === 'process.on' || name === 'process.once') return { type: 'process-handler', detail: firstArg ?? name };
|
|
364
|
+
if (/^(?:global\.|globalThis\.)?(?:setTimeout|setInterval|setImmediate)$/.test(name)) return { type: 'timer', detail: name };
|
|
365
|
+
if (last === 'listen') return { type: 'server-listen', detail: firstArg ?? name };
|
|
366
|
+
if (last === 'createServer' || last === 'createSecureServer') return { type: 'server-create', detail: name };
|
|
367
|
+
if (mod === 'dotenv' && (last === 'config' || last === 'configDotenv')) return { type: 'env-load', detail: `dotenv.${last}` };
|
|
368
|
+
if (mod === 'child_process' && CHILD_PROCESS_FNS.has(last)) return { type: 'child-process', detail: name };
|
|
369
|
+
if ((mod === 'fs' || mod === 'fs/promises') && FS_OPERATION.test(last)) return { type: 'filesystem', detail: name };
|
|
370
|
+
if (/^(?:connect|createConnection|createPool|createClient)$/.test(last)) return { type: 'connection', detail: name };
|
|
371
|
+
if (name.startsWith('console.')) return { type: 'console-output', detail: name };
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function detectNewSideEffect(node) {
|
|
376
|
+
const name = calleeName(node.callee);
|
|
377
|
+
if (!name) return null;
|
|
378
|
+
const last = name.slice(name.lastIndexOf('.') + 1);
|
|
379
|
+
if (CONNECTION_CLASSES.has(last)) return { type: 'connection', detail: `new ${name}` };
|
|
380
|
+
if (SERVER_CLASSES.has(last)) return { type: 'server-create', detail: `new ${name}` };
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ---------------------------------------------------------------------------
|
|
385
|
+
// Environment variables
|
|
386
|
+
// ---------------------------------------------------------------------------
|
|
387
|
+
|
|
388
|
+
function isProcessEnv(node) {
|
|
389
|
+
return (
|
|
390
|
+
node?.type === 'MemberExpression' &&
|
|
391
|
+
!node.computed &&
|
|
392
|
+
node.object.type === 'Identifier' &&
|
|
393
|
+
node.object.name === 'process' &&
|
|
394
|
+
node.property.type === 'Identifier' &&
|
|
395
|
+
node.property.name === 'env'
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ---------------------------------------------------------------------------
|
|
400
|
+
// Summary
|
|
401
|
+
// ---------------------------------------------------------------------------
|
|
402
|
+
|
|
403
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
404
|
+
|
|
405
|
+
function joinList(parts) {
|
|
406
|
+
if (parts.length <= 1) return parts.join('');
|
|
407
|
+
return `${parts.slice(0, -1).join(', ')} and ${parts[parts.length - 1]}`;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function buildSummary({ moduleType, lines, dependencies, functions, classes, exportList, routes, sideEffects, env }) {
|
|
411
|
+
const out = [`${moduleType === 'esm' ? 'ES module' : 'CommonJS module'}, ${plural(lines, 'line')}.`];
|
|
412
|
+
|
|
413
|
+
const deps = [];
|
|
414
|
+
if (dependencies.packages.length) deps.push(plural(dependencies.packages.length, 'npm package'));
|
|
415
|
+
if (dependencies.builtin.length) deps.push(plural(dependencies.builtin.length, 'Node built-in'));
|
|
416
|
+
if (dependencies.local.length) deps.push(plural(dependencies.local.length, 'local module'));
|
|
417
|
+
if (deps.length) out.push(`Depends on ${joinList(deps)}.`);
|
|
418
|
+
|
|
419
|
+
const defs = [];
|
|
420
|
+
if (functions.length) defs.push(plural(functions.length, 'function'));
|
|
421
|
+
if (classes.length) defs.push(plural(classes.length, 'class').replace('classs', 'classes'));
|
|
422
|
+
if (defs.length) out.push(`Defines ${joinList(defs)}.`);
|
|
423
|
+
|
|
424
|
+
if (exportList.length) out.push(`Exports ${plural(exportList.length, 'name')}.`);
|
|
425
|
+
if (routes.length) out.push(`Declares ${plural(routes.length, 'HTTP route')}.`);
|
|
426
|
+
|
|
427
|
+
const phrases = [...new Set(sideEffects.map((s) => SIDE_EFFECT_PHRASES[s.type]).filter(Boolean))];
|
|
428
|
+
if (phrases.length) out.push(`When loaded it ${joinList(phrases)}.`);
|
|
429
|
+
if (env.length) out.push(`Reads ${plural(env.length, 'environment variable')}.`);
|
|
430
|
+
|
|
431
|
+
return out.join(' ');
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ---------------------------------------------------------------------------
|
|
435
|
+
// analyze
|
|
436
|
+
// ---------------------------------------------------------------------------
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Analyze JavaScript source and return a plain, JSON-serializable summary.
|
|
440
|
+
*
|
|
441
|
+
* @param {string} code
|
|
442
|
+
* @param {{ filename?: string, sourceType?: 'auto' | 'module' | 'script' }} [options]
|
|
443
|
+
*/
|
|
444
|
+
export function analyze(code, options = {}) {
|
|
445
|
+
if (typeof code !== 'string') throw new TypeError('analyze(code): code must be a string');
|
|
446
|
+
const { filename = null, sourceType = 'auto' } = options;
|
|
447
|
+
const text = code.charCodeAt(0) === 0xfeff ? code.slice(1) : code;
|
|
448
|
+
|
|
449
|
+
const { ast, comments } = parseSource(text, sourceType);
|
|
450
|
+
const { imports, bindings } = collectImports(ast);
|
|
451
|
+
|
|
452
|
+
const docsByEndLine = new Map();
|
|
453
|
+
for (const c of comments) {
|
|
454
|
+
if (c.type === 'Block' && c.value.startsWith('*') && c.loc) docsByEndLine.set(c.loc.end.line, c);
|
|
455
|
+
}
|
|
456
|
+
const usedDocs = new Set();
|
|
457
|
+
|
|
458
|
+
const functions = [];
|
|
459
|
+
const classes = [];
|
|
460
|
+
const exportList = [];
|
|
461
|
+
const routes = [];
|
|
462
|
+
const sideEffects = [];
|
|
463
|
+
const envMap = new Map();
|
|
464
|
+
|
|
465
|
+
const frames = new Map(); // function node -> frame
|
|
466
|
+
const allFrames = [];
|
|
467
|
+
const recStack = []; // frames of recorded (named) functions
|
|
468
|
+
const classStack = [];
|
|
469
|
+
const routeNames = new WeakMap();
|
|
470
|
+
const iifeNodes = new WeakSet();
|
|
471
|
+
const skippedNesting = new WeakSet();
|
|
472
|
+
let fnDepth = 0; // functions entered, not counting IIFEs: "is this code run when the file loads?"
|
|
473
|
+
let allFnDepth = 0; // every function entered
|
|
474
|
+
let hasEsmSyntax = false;
|
|
475
|
+
|
|
476
|
+
const addSideEffect = (type, detail, node) => {
|
|
477
|
+
const entry = { type };
|
|
478
|
+
if (detail) entry.detail = detail;
|
|
479
|
+
entry.line = node.loc.start.line;
|
|
480
|
+
sideEffects.push(entry);
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
const noteEnv = (name, node, hasDefault) => {
|
|
484
|
+
const existing = envMap.get(name);
|
|
485
|
+
if (existing) existing.hasDefault ||= hasDefault;
|
|
486
|
+
else envMap.set(name, { name, line: node.loc.start.line, hasDefault });
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
const kindOfValue = (value) => {
|
|
490
|
+
if (!value) return 'value';
|
|
491
|
+
if (value.type === 'FunctionExpression' || value.type === 'ArrowFunctionExpression') return 'function';
|
|
492
|
+
if (value.type === 'ClassExpression') return 'class';
|
|
493
|
+
if (value.type === 'Identifier') return 'unknown';
|
|
494
|
+
return 'value';
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
const addExport = (name, value, style, node, extra = {}) => {
|
|
498
|
+
const entry = { name, kind: extra.kind ?? kindOfValue(value), style };
|
|
499
|
+
if (extra.from) entry.from = extra.from;
|
|
500
|
+
const local = 'local' in extra ? extra.local : value?.type === 'Identifier' ? value.name : undefined;
|
|
501
|
+
if (local && local !== name) entry.local = local;
|
|
502
|
+
entry.line = node.loc.start.line;
|
|
503
|
+
exportList.push(entry);
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
walk(ast, {
|
|
507
|
+
enter(node, anc) {
|
|
508
|
+
const parent = anc[anc.length - 1];
|
|
509
|
+
const t = node.type;
|
|
510
|
+
const top = recStack[recStack.length - 1];
|
|
511
|
+
|
|
512
|
+
// ---- ES module syntax -------------------------------------------------
|
|
513
|
+
if (t === 'ImportDeclaration' || t === 'MetaProperty') hasEsmSyntax = true;
|
|
514
|
+
|
|
515
|
+
if (t === 'ExportNamedDeclaration') {
|
|
516
|
+
hasEsmSyntax = true;
|
|
517
|
+
const decl = node.declaration;
|
|
518
|
+
if (decl) {
|
|
519
|
+
if (decl.type === 'FunctionDeclaration') addExport(decl.id.name, null, 'esm', node, { kind: 'function' });
|
|
520
|
+
else if (decl.type === 'ClassDeclaration') addExport(decl.id.name, null, 'esm', node, { kind: 'class' });
|
|
521
|
+
else if (decl.type === 'VariableDeclaration') {
|
|
522
|
+
for (const d of decl.declarations) {
|
|
523
|
+
if (d.id.type === 'Identifier') addExport(d.id.name, d.init, 'esm', node, { local: null });
|
|
524
|
+
else for (const n of patternNames(d.id)) addExport(n, null, 'esm', node, { kind: 'value' });
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
for (const spec of node.specifiers) {
|
|
529
|
+
const exported = spec.exported.name ?? String(spec.exported.value);
|
|
530
|
+
const local = spec.local.name ?? String(spec.local.value);
|
|
531
|
+
if (node.source) {
|
|
532
|
+
addExport(exported, null, 'esm', node, { kind: 're-export', from: node.source.value, local });
|
|
533
|
+
} else {
|
|
534
|
+
addExport(exported, null, 'esm', node, { kind: 'unknown', local });
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
} else if (t === 'ExportDefaultDeclaration') {
|
|
538
|
+
hasEsmSyntax = true;
|
|
539
|
+
const decl = node.declaration;
|
|
540
|
+
if (decl.type === 'FunctionDeclaration') {
|
|
541
|
+
addExport('default', null, 'esm', node, { kind: 'function', local: decl.id?.name });
|
|
542
|
+
} else if (decl.type === 'ClassDeclaration') {
|
|
543
|
+
addExport('default', null, 'esm', node, { kind: 'class', local: decl.id?.name });
|
|
544
|
+
} else {
|
|
545
|
+
addExport('default', decl, 'esm', node);
|
|
546
|
+
}
|
|
547
|
+
} else if (t === 'ExportAllDeclaration') {
|
|
548
|
+
hasEsmSyntax = true;
|
|
549
|
+
const exported = node.exported ? (node.exported.name ?? String(node.exported.value)) : '*';
|
|
550
|
+
addExport(exported, null, 'esm', node, { kind: 're-export', from: node.source.value, local: null });
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// ---- CommonJS exports & global mutation ------------------------------
|
|
554
|
+
if (t === 'AssignmentExpression' && node.operator === '=') {
|
|
555
|
+
const target = assignTarget(node.left);
|
|
556
|
+
if (target?.exported) {
|
|
557
|
+
if (target.name !== 'module.exports') {
|
|
558
|
+
addExport(target.name, node.right, 'commonjs', node);
|
|
559
|
+
} else if (node.right.type === 'ObjectExpression') {
|
|
560
|
+
for (const prop of node.right.properties) {
|
|
561
|
+
if (prop.type !== 'Property') continue;
|
|
562
|
+
const key = propKey(prop.key, prop.computed);
|
|
563
|
+
if (key) addExport(key, prop.value, 'commonjs', prop);
|
|
564
|
+
}
|
|
565
|
+
} else {
|
|
566
|
+
addExport('default', node.right, 'commonjs', node);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
if (fnDepth === 0) {
|
|
570
|
+
const root = memberPath(node.left)?.split('.')[0];
|
|
571
|
+
if (node.left.type === 'MemberExpression' && (root === 'global' || root === 'globalThis')) {
|
|
572
|
+
addSideEffect('global-mutation', memberPath(node.left), node);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// ---- Classes -----------------------------------------------------------
|
|
578
|
+
if (CLASS_TYPES.has(t)) {
|
|
579
|
+
const bound = bindingName(node, anc);
|
|
580
|
+
const cls = {
|
|
581
|
+
name: node.id?.name ?? bound?.name ?? '<anonymous>',
|
|
582
|
+
extends: node.superClass ? snippet(text, node.superClass, 60) : null,
|
|
583
|
+
line: node.loc.start.line,
|
|
584
|
+
endLine: node.loc.end.line,
|
|
585
|
+
methods: [],
|
|
586
|
+
exported: Boolean(bound?.exported),
|
|
587
|
+
};
|
|
588
|
+
classes.push(cls);
|
|
589
|
+
classStack.push(cls);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// ---- Functions ---------------------------------------------------------
|
|
593
|
+
if (FUNCTION_TYPES.has(t)) {
|
|
594
|
+
allFnDepth++;
|
|
595
|
+
const isIife = parent?.type === 'CallExpression' && parent.callee === node;
|
|
596
|
+
if (isIife) iifeNodes.add(node);
|
|
597
|
+
else fnDepth++;
|
|
598
|
+
|
|
599
|
+
const d = describeFunction(text, node, anc, classStack, routeNames);
|
|
600
|
+
if (d) {
|
|
601
|
+
const doc = findDoc(node, anc, docsByEndLine, usedDocs);
|
|
602
|
+
const entry = {
|
|
603
|
+
name: d.name,
|
|
604
|
+
kind: d.kind,
|
|
605
|
+
async: node.async,
|
|
606
|
+
generator: node.generator,
|
|
607
|
+
...(d.isStatic && { static: true }),
|
|
608
|
+
params: node.params.map((p) => snippet(text, p, 50)),
|
|
609
|
+
line: node.loc.start.line,
|
|
610
|
+
endLine: node.loc.end.line,
|
|
611
|
+
lines: node.loc.end.line - node.loc.start.line + 1,
|
|
612
|
+
exported: Boolean(d.exported),
|
|
613
|
+
...(doc && { doc }),
|
|
614
|
+
complexity: 1,
|
|
615
|
+
maxNesting: 0,
|
|
616
|
+
calls: [],
|
|
617
|
+
localCalls: [],
|
|
618
|
+
};
|
|
619
|
+
functions.push(entry);
|
|
620
|
+
const frame = { entry, owner: classStack[classStack.length - 1]?.name ?? null, calls: new Set(), depth: 0 };
|
|
621
|
+
frames.set(node, frame);
|
|
622
|
+
allFrames.push(frame);
|
|
623
|
+
recStack.push(frame);
|
|
624
|
+
if (d.classMethod?.cls) {
|
|
625
|
+
d.classMethod.cls.methods.push({
|
|
626
|
+
name: d.classMethod.key,
|
|
627
|
+
kind: d.kind,
|
|
628
|
+
static: Boolean(d.isStatic),
|
|
629
|
+
async: node.async,
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// ---- Calls, routes, side effects --------------------------------------
|
|
636
|
+
if (t === 'CallExpression') {
|
|
637
|
+
const name = calleeName(node.callee);
|
|
638
|
+
if (top && name && name !== 'require' && !name.startsWith('require()') && !name.startsWith('console.')) {
|
|
639
|
+
top.calls.add(name);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const route = detectRoute(node);
|
|
643
|
+
if (route) {
|
|
644
|
+
const label = `${route.method} ${route.path}`;
|
|
645
|
+
const last = route.handlers[route.handlers.length - 1];
|
|
646
|
+
if (last.type === 'FunctionExpression' || last.type === 'ArrowFunctionExpression') routeNames.set(last, label);
|
|
647
|
+
const entry = { method: route.method, path: route.path, handler: handlerLabel(last, label) };
|
|
648
|
+
const middleware = route.handlers.slice(0, -1).map((h) => handlerLabel(h, 'inline'));
|
|
649
|
+
if (middleware.length) entry.middleware = middleware;
|
|
650
|
+
entry.line = node.loc.start.line;
|
|
651
|
+
routes.push(entry);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
if (fnDepth === 0) {
|
|
655
|
+
const effect = detectCallSideEffect(text, node, bindings);
|
|
656
|
+
if (effect) addSideEffect(effect.type, effect.detail, node);
|
|
657
|
+
}
|
|
658
|
+
} else if (t === 'NewExpression' && fnDepth === 0) {
|
|
659
|
+
const effect = detectNewSideEffect(node);
|
|
660
|
+
if (effect) addSideEffect(effect.type, effect.detail, node);
|
|
661
|
+
} else if ((t === 'AwaitExpression' || (t === 'ForOfStatement' && node.await)) && allFnDepth === 0) {
|
|
662
|
+
hasEsmSyntax = true;
|
|
663
|
+
addSideEffect('top-level-await', t === 'AwaitExpression' ? snippet(text, node.argument, 50) : 'for await', node);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// ---- Environment variables --------------------------------------------
|
|
667
|
+
if (t === 'MemberExpression' && isProcessEnv(node.object)) {
|
|
668
|
+
const isWrite = parent?.type === 'AssignmentExpression' && parent.left === node;
|
|
669
|
+
if (!isWrite) {
|
|
670
|
+
const hasDefault =
|
|
671
|
+
parent?.type === 'LogicalExpression' && (parent.operator === '||' || parent.operator === '??') && parent.left === node;
|
|
672
|
+
noteEnv(propName(node) ?? '<dynamic>', node, hasDefault);
|
|
673
|
+
}
|
|
674
|
+
} else if (t === 'VariableDeclarator' && isProcessEnv(node.init) && node.id.type === 'ObjectPattern') {
|
|
675
|
+
for (const prop of node.id.properties) {
|
|
676
|
+
if (prop.type !== 'Property') continue;
|
|
677
|
+
const key = propKey(prop.key, prop.computed);
|
|
678
|
+
if (key) noteEnv(key, prop, prop.value.type === 'AssignmentPattern');
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// ---- Complexity & nesting (attributed to the nearest named function) --
|
|
683
|
+
if (top) {
|
|
684
|
+
if (COMPLEXITY_TYPES.has(t) || t === 'LogicalExpression' || (t === 'SwitchCase' && node.test)) {
|
|
685
|
+
top.entry.complexity++;
|
|
686
|
+
}
|
|
687
|
+
if (NESTING_TYPES.has(t)) {
|
|
688
|
+
if (t === 'IfStatement' && parent?.type === 'IfStatement' && parent.alternate === node) {
|
|
689
|
+
skippedNesting.add(node); // `else if` stays on the same level
|
|
690
|
+
} else {
|
|
691
|
+
top.depth++;
|
|
692
|
+
top.entry.maxNesting = Math.max(top.entry.maxNesting, top.depth);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
},
|
|
697
|
+
|
|
698
|
+
leave(node) {
|
|
699
|
+
const t = node.type;
|
|
700
|
+
if (FUNCTION_TYPES.has(t)) {
|
|
701
|
+
allFnDepth--;
|
|
702
|
+
if (!iifeNodes.has(node)) fnDepth--;
|
|
703
|
+
if (frames.has(node)) recStack.pop();
|
|
704
|
+
} else if (CLASS_TYPES.has(t)) {
|
|
705
|
+
classStack.pop();
|
|
706
|
+
} else if (NESTING_TYPES.has(t) && !skippedNesting.has(node)) {
|
|
707
|
+
const top = recStack[recStack.length - 1];
|
|
708
|
+
if (top) top.depth--;
|
|
709
|
+
}
|
|
710
|
+
},
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
// `import 'dotenv/config'` loads a .env file just by being imported.
|
|
714
|
+
for (const imp of imports) {
|
|
715
|
+
if (imp.source === 'dotenv/config') {
|
|
716
|
+
sideEffects.push({ type: 'env-load', detail: 'import dotenv/config', line: imp.line });
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
sideEffects.sort((a, b) => a.line - b.line);
|
|
720
|
+
|
|
721
|
+
// ---- Resolve call graph ----------------------------------------------------
|
|
722
|
+
const functionNames = new Set(functions.map((f) => f.name));
|
|
723
|
+
for (const frame of allFrames) {
|
|
724
|
+
frame.entry.calls = [...frame.calls];
|
|
725
|
+
const local = new Set();
|
|
726
|
+
for (const call of frame.calls) {
|
|
727
|
+
if (functionNames.has(call)) {
|
|
728
|
+
local.add(call);
|
|
729
|
+
} else if (call.startsWith('this.') && frame.owner) {
|
|
730
|
+
const candidate = `${frame.owner}.${call.slice(5)}`;
|
|
731
|
+
if (functionNames.has(candidate)) local.add(candidate);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
frame.entry.localCalls = [...local];
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// ---- Resolve export kinds and "exported" flags ---------------------------
|
|
738
|
+
const classNames = new Set(classes.map((c) => c.name));
|
|
739
|
+
for (const e of exportList) {
|
|
740
|
+
if (e.kind !== 'unknown') continue;
|
|
741
|
+
const target = e.local ?? e.name;
|
|
742
|
+
e.kind = functionNames.has(target) ? 'function' : classNames.has(target) ? 'class' : 'value';
|
|
743
|
+
}
|
|
744
|
+
const exportedNames = new Set(exportList.filter((e) => e.kind !== 're-export').map((e) => e.local ?? e.name));
|
|
745
|
+
for (const f of functions) if (exportedNames.has(f.name)) f.exported = true;
|
|
746
|
+
for (const c of classes) if (exportedNames.has(c.name)) c.exported = true;
|
|
747
|
+
|
|
748
|
+
// ---- Dependencies ------------------------------------------------------------
|
|
749
|
+
const dependencies = { builtin: [], packages: [], local: [] };
|
|
750
|
+
const pushUnique = (list, value) => {
|
|
751
|
+
if (!list.includes(value)) list.push(value);
|
|
752
|
+
};
|
|
753
|
+
for (const imp of imports) {
|
|
754
|
+
if (imp.source === null) continue;
|
|
755
|
+
const c = classifySpecifier(imp.source);
|
|
756
|
+
if (c.kind === 'builtin') pushUnique(dependencies.builtin, c.name);
|
|
757
|
+
else if (c.kind === 'package') pushUnique(dependencies.packages, c.name);
|
|
758
|
+
else pushUnique(dependencies.local, c.name);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const moduleType = hasEsmSyntax ? 'esm' : 'commonjs';
|
|
762
|
+
// A final newline ends the last line; it does not start another one.
|
|
763
|
+
const lines = text === '' ? 0 : text.split(/\r\n|\r|\n/).length - (/(\r\n|\r|\n)$/.test(text) ? 1 : 0);
|
|
764
|
+
const env = [...envMap.values()];
|
|
765
|
+
|
|
766
|
+
return {
|
|
767
|
+
schemaVersion: SCHEMA_VERSION,
|
|
768
|
+
file: filename,
|
|
769
|
+
moduleType,
|
|
770
|
+
stats: {
|
|
771
|
+
lines,
|
|
772
|
+
imports: imports.length,
|
|
773
|
+
exports: exportList.length,
|
|
774
|
+
functions: functions.length,
|
|
775
|
+
classes: classes.length,
|
|
776
|
+
routes: routes.length,
|
|
777
|
+
},
|
|
778
|
+
summary: buildSummary({ moduleType, lines, dependencies, functions, classes, exportList, routes, sideEffects, env }),
|
|
779
|
+
dependencies,
|
|
780
|
+
imports,
|
|
781
|
+
exports: exportList,
|
|
782
|
+
functions,
|
|
783
|
+
classes,
|
|
784
|
+
routes,
|
|
785
|
+
sideEffects,
|
|
786
|
+
env,
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/** Read a file from disk and analyze it. `file` in the result is the path as given. */
|
|
791
|
+
export async function analyzeFile(path, options = {}) {
|
|
792
|
+
const code = await readFile(path, 'utf8');
|
|
793
|
+
return analyze(code, { ...options, filename: String(path) });
|
|
794
|
+
}
|