deadwood-scan 0.2.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/dist/index.js ADDED
@@ -0,0 +1,2597 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { createRequire } from "module";
5
+ import process from "process";
6
+ import { Command, Option } from "commander";
7
+ import ora from "ora";
8
+ import pc2 from "picocolors";
9
+
10
+ // ../core/src/index.ts
11
+ import fs8 from "fs";
12
+ import path11 from "path";
13
+ import { glob as glob2 } from "tinyglobby";
14
+
15
+ // ../core/src/errors.ts
16
+ var ScanError = class extends Error {
17
+ constructor(code, message) {
18
+ super(message);
19
+ this.code = code;
20
+ this.name = "ScanError";
21
+ }
22
+ code;
23
+ };
24
+
25
+ // ../core/src/graph/parser.ts
26
+ import ts from "typescript";
27
+ var DEPRECATED_RE = /@deprecated\b/;
28
+ var REMOVAL_RE = /\b(?:TODO:?\s*(?:remove|delete|kill)|dead\s*code|FIXME:?\s*remove)\b/i;
29
+ function scriptKindFor(file) {
30
+ if (file.endsWith(".tsx")) return ts.ScriptKind.TSX;
31
+ if (file.endsWith(".jsx")) return ts.ScriptKind.JSX;
32
+ if (/\.(ts|mts|cts)$/.test(file)) return ts.ScriptKind.TS;
33
+ return ts.ScriptKind.JS;
34
+ }
35
+ function parseFile(filePosix, text) {
36
+ const ast = ts.createSourceFile(
37
+ filePosix,
38
+ text,
39
+ ts.ScriptTarget.Latest,
40
+ true,
41
+ scriptKindFor(filePosix)
42
+ );
43
+ const result = {
44
+ file: filePosix,
45
+ ast,
46
+ imports: [],
47
+ exports: [],
48
+ dynamicPatterns: [],
49
+ commentMarkers: collectCommentMarkers(text),
50
+ pathLiterals: [],
51
+ hasCjsDynamicExport: false
52
+ };
53
+ const lineOf = (node) => ast.getLineAndCharacterOfPosition(node.getStart(ast)).line + 1;
54
+ const hasDeprecatedJsDoc = (node) => {
55
+ const ranges = ts.getLeadingCommentRanges(text, node.getFullStart()) ?? [];
56
+ return ranges.some((r) => DEPRECATED_RE.test(text.slice(r.pos, r.end)));
57
+ };
58
+ for (const stmt of ast.statements) {
59
+ if (ts.isImportDeclaration(stmt) && ts.isStringLiteral(stmt.moduleSpecifier)) {
60
+ const record = {
61
+ specifier: stmt.moduleSpecifier.text,
62
+ kind: "static",
63
+ names: [],
64
+ line: lineOf(stmt)
65
+ };
66
+ const clause = stmt.importClause;
67
+ if (clause) {
68
+ if (clause.name) record.names.push({ imported: "default", local: clause.name.text });
69
+ if (clause.namedBindings) {
70
+ if (ts.isNamespaceImport(clause.namedBindings)) {
71
+ record.namespaceLocal = clause.namedBindings.name.text;
72
+ } else {
73
+ for (const el of clause.namedBindings.elements) {
74
+ record.names.push({
75
+ imported: el.propertyName?.text ?? el.name.text,
76
+ local: el.name.text
77
+ });
78
+ }
79
+ }
80
+ }
81
+ }
82
+ result.imports.push(record);
83
+ continue;
84
+ }
85
+ if (ts.isExportDeclaration(stmt)) {
86
+ const spec = stmt.moduleSpecifier;
87
+ if (spec && ts.isStringLiteral(spec)) {
88
+ if (!stmt.exportClause) {
89
+ result.imports.push({
90
+ specifier: spec.text,
91
+ kind: "reexport-star",
92
+ names: [],
93
+ line: lineOf(stmt)
94
+ });
95
+ } else if (ts.isNamedExports(stmt.exportClause)) {
96
+ const reexported = stmt.exportClause.elements.map((el) => ({
97
+ exported: el.name.text,
98
+ original: el.propertyName?.text ?? el.name.text
99
+ }));
100
+ result.imports.push({
101
+ specifier: spec.text,
102
+ kind: "reexport-named",
103
+ names: [],
104
+ reexported,
105
+ line: lineOf(stmt)
106
+ });
107
+ for (const r of reexported) {
108
+ result.exports.push({ name: r.exported, kind: "value", line: lineOf(stmt) });
109
+ }
110
+ } else if (ts.isNamespaceExport(stmt.exportClause)) {
111
+ result.imports.push({
112
+ specifier: spec.text,
113
+ kind: "reexport-star",
114
+ names: [],
115
+ line: lineOf(stmt)
116
+ });
117
+ result.exports.push({
118
+ name: stmt.exportClause.name.text,
119
+ kind: "value",
120
+ line: lineOf(stmt)
121
+ });
122
+ }
123
+ } else if (stmt.exportClause && ts.isNamedExports(stmt.exportClause)) {
124
+ for (const el of stmt.exportClause.elements) {
125
+ result.exports.push({
126
+ name: el.name.text,
127
+ kind: "value",
128
+ line: lineOf(el),
129
+ ...hasDeprecatedJsDoc(stmt) ? { deprecated: true } : {}
130
+ });
131
+ }
132
+ }
133
+ continue;
134
+ }
135
+ if (ts.isExportAssignment(stmt)) {
136
+ result.exports.push({ name: "default", kind: "value", line: lineOf(stmt) });
137
+ continue;
138
+ }
139
+ collectModifierExports(stmt, result, lineOf, hasDeprecatedJsDoc);
140
+ }
141
+ const visit = (node) => {
142
+ if (ts.isCallExpression(node)) {
143
+ handleCall(node, result, lineOf);
144
+ } else if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken) {
145
+ handleCjsExportAssignment(node, result, lineOf);
146
+ } else if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
147
+ const v = node.text;
148
+ if (v.length < 200 && v.startsWith("/") && !/\s/.test(v)) {
149
+ result.pathLiterals.push({ value: v, line: lineOf(node) });
150
+ }
151
+ }
152
+ ts.forEachChild(node, visit);
153
+ };
154
+ ts.forEachChild(ast, visit);
155
+ const namespaceLocals = /* @__PURE__ */ new Map();
156
+ for (const imp of result.imports) {
157
+ if (imp.namespaceLocal) {
158
+ const usage = { members: /* @__PURE__ */ new Set(), escapes: false };
159
+ imp.namespaceUsage = usage;
160
+ namespaceLocals.set(imp.namespaceLocal, usage);
161
+ }
162
+ }
163
+ if (namespaceLocals.size > 0) {
164
+ trackNamespaceUsage(ast, namespaceLocals);
165
+ }
166
+ return result;
167
+ }
168
+ function collectModifierExports(stmt, result, lineOf, hasDeprecatedJsDoc) {
169
+ const mods = ts.canHaveModifiers(stmt) ? ts.getModifiers(stmt) : void 0;
170
+ if (!mods?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) return;
171
+ const isDefault = mods.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword);
172
+ const deprecated = hasDeprecatedJsDoc(stmt) ? true : void 0;
173
+ const push = (name, kind, node) => result.exports.push({ name, kind, line: lineOf(node), ...deprecated ? { deprecated } : {} });
174
+ if (ts.isFunctionDeclaration(stmt) || ts.isClassDeclaration(stmt)) {
175
+ push(isDefault ? "default" : stmt.name?.text ?? "default", "value", stmt);
176
+ } else if (ts.isVariableStatement(stmt)) {
177
+ for (const decl of stmt.declarationList.declarations) {
178
+ collectBindingNames(decl.name, (name) => push(name, "value", decl));
179
+ }
180
+ } else if (ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt)) {
181
+ push(stmt.name.text, "type", stmt);
182
+ } else if (ts.isEnumDeclaration(stmt) || ts.isModuleDeclaration(stmt)) {
183
+ if (stmt.name && ts.isIdentifier(stmt.name)) push(stmt.name.text, "value", stmt);
184
+ }
185
+ }
186
+ function collectBindingNames(name, cb) {
187
+ if (ts.isIdentifier(name)) {
188
+ cb(name.text);
189
+ } else if (ts.isObjectBindingPattern(name) || ts.isArrayBindingPattern(name)) {
190
+ for (const el of name.elements) {
191
+ if (ts.isBindingElement(el)) collectBindingNames(el.name, cb);
192
+ }
193
+ }
194
+ }
195
+ function literalPrefixOf(arg) {
196
+ if (ts.isTemplateExpression(arg) && arg.head.text.length > 0) return arg.head.text;
197
+ if (ts.isBinaryExpression(arg) && arg.operatorToken.kind === ts.SyntaxKind.PlusToken && (ts.isStringLiteral(arg.left) || ts.isNoSubstitutionTemplateLiteral(arg.left))) {
198
+ return arg.left.text;
199
+ }
200
+ return void 0;
201
+ }
202
+ function handleCall(node, result, lineOf) {
203
+ const line = lineOf(node);
204
+ if (ts.isIdentifier(node.expression) && node.expression.text === "require" && node.arguments.length === 1) {
205
+ const arg = node.arguments[0];
206
+ if (ts.isStringLiteral(arg) || ts.isNoSubstitutionTemplateLiteral(arg)) {
207
+ const record = { specifier: arg.text, kind: "require", names: [], line };
208
+ bindRequireResult(node, record);
209
+ result.imports.push(record);
210
+ } else {
211
+ const prefix = literalPrefixOf(arg);
212
+ result.dynamicPatterns.push({ kind: "require", line, ...prefix ? { prefix } : {} });
213
+ }
214
+ return;
215
+ }
216
+ if (node.expression.kind === ts.SyntaxKind.ImportKeyword && node.arguments.length >= 1) {
217
+ const arg = node.arguments[0];
218
+ if (ts.isStringLiteral(arg) || ts.isNoSubstitutionTemplateLiteral(arg)) {
219
+ result.imports.push({ specifier: arg.text, kind: "dynamic", names: [], line });
220
+ } else {
221
+ const prefix = literalPrefixOf(arg);
222
+ result.dynamicPatterns.push({ kind: "import", line, ...prefix ? { prefix } : {} });
223
+ }
224
+ return;
225
+ }
226
+ if (ts.isPropertyAccessExpression(node.expression) && ts.isIdentifier(node.expression.expression) && node.expression.expression.text === "require" && node.expression.name.text === "context" && node.arguments.length >= 1) {
227
+ const arg = node.arguments[0];
228
+ const prefix = ts.isStringLiteral(arg) ? arg.text : literalPrefixOf(arg);
229
+ result.dynamicPatterns.push({ kind: "require", line, ...prefix ? { prefix } : {} });
230
+ }
231
+ }
232
+ function bindRequireResult(call, record) {
233
+ const parent = call.parent;
234
+ if (parent && ts.isVariableDeclaration(parent) && parent.initializer === call) {
235
+ if (ts.isIdentifier(parent.name)) {
236
+ record.namespaceLocal = parent.name.text;
237
+ } else if (ts.isObjectBindingPattern(parent.name)) {
238
+ for (const el of parent.name.elements) {
239
+ if (ts.isBindingElement(el) && ts.isIdentifier(el.name)) {
240
+ const imported = el.propertyName && ts.isIdentifier(el.propertyName) ? el.propertyName.text : el.name.text;
241
+ record.names.push({ imported, local: el.name.text });
242
+ }
243
+ }
244
+ }
245
+ }
246
+ }
247
+ function handleCjsExportAssignment(node, result, lineOf) {
248
+ const left = node.left;
249
+ const line = lineOf(node);
250
+ const isModuleExports = (e) => ts.isPropertyAccessExpression(e) && ts.isIdentifier(e.expression) && e.expression.text === "module" && e.name.text === "exports";
251
+ if (isModuleExports(left)) {
252
+ if (ts.isObjectLiteralExpression(node.right)) {
253
+ for (const prop of node.right.properties) {
254
+ const name = prop.name;
255
+ if (name && (ts.isIdentifier(name) || ts.isStringLiteral(name))) {
256
+ result.exports.push({ name: name.text, kind: "cjs", line });
257
+ }
258
+ }
259
+ } else {
260
+ result.exports.push({ name: "default", kind: "cjs", line });
261
+ }
262
+ return;
263
+ }
264
+ if (ts.isPropertyAccessExpression(left)) {
265
+ const base = left.expression;
266
+ const isExportsBase = ts.isIdentifier(base) && base.text === "exports" || isModuleExports(base);
267
+ if (isExportsBase && left.name.text !== "__esModule") {
268
+ result.exports.push({ name: left.name.text, kind: "cjs", line });
269
+ }
270
+ return;
271
+ }
272
+ if (ts.isElementAccessExpression(left)) {
273
+ const base = left.expression;
274
+ if (ts.isIdentifier(base) && base.text === "exports" || isModuleExports(base)) {
275
+ result.hasCjsDynamicExport = true;
276
+ }
277
+ }
278
+ }
279
+ function trackNamespaceUsage(ast, locals) {
280
+ const visit = (node) => {
281
+ if (ts.isIdentifier(node) && locals.has(node.text)) {
282
+ const usage = locals.get(node.text);
283
+ const parent = node.parent;
284
+ const isDeclarationSite = parent && (ts.isNamespaceImport(parent) && parent.name === node || ts.isVariableDeclaration(parent) && parent.name === node || ts.isImportSpecifier(parent) && parent.name === node);
285
+ if (!isDeclarationSite) {
286
+ if (parent && ts.isPropertyAccessExpression(parent) && parent.expression === node) {
287
+ usage.members.add(parent.name.text);
288
+ } else {
289
+ usage.escapes = true;
290
+ }
291
+ }
292
+ }
293
+ ts.forEachChild(node, visit);
294
+ };
295
+ ts.forEachChild(ast, visit);
296
+ }
297
+ function collectCommentMarkers(text) {
298
+ const markers = [];
299
+ const lines = text.split(/\r?\n/);
300
+ for (let i = 0; i < lines.length; i++) {
301
+ const line = lines[i];
302
+ if (!/\/\/|\/\*|^\s*\*/.test(line)) continue;
303
+ if (DEPRECATED_RE.test(line)) {
304
+ markers.push({ line: i + 1, type: "deprecated", text: line.trim() });
305
+ } else if (REMOVAL_RE.test(line)) {
306
+ markers.push({ line: i + 1, type: "removal", text: line.trim() });
307
+ }
308
+ }
309
+ return markers;
310
+ }
311
+
312
+ // ../core/src/graph/moduleGraph.ts
313
+ var ModuleGraph = class {
314
+ files = /* @__PURE__ */ new Map();
315
+ reverse = /* @__PURE__ */ new Map();
316
+ addFile(pf) {
317
+ this.files.set(pf.file, pf);
318
+ }
319
+ /** Resolve every import record and build the reverse index. Call once after all addFile(). */
320
+ link(resolver) {
321
+ for (const pf of this.files.values()) {
322
+ for (const record of pf.imports) {
323
+ const res = resolver.resolve(record.specifier, pf.file);
324
+ if (res.file && this.files.has(res.file)) {
325
+ record.resolvedFile = res.file;
326
+ let edges = this.reverse.get(res.file);
327
+ if (!edges) this.reverse.set(res.file, edges = []);
328
+ edges.push({ importer: pf.file, record });
329
+ } else if (res.pkg) {
330
+ record.resolvedPackage = res.pkg;
331
+ }
332
+ }
333
+ }
334
+ }
335
+ importersOf(file) {
336
+ return this.reverse.get(file) ?? [];
337
+ }
338
+ /** BFS over resolved edges (all kinds — static, require, dynamic-literal, reexports). */
339
+ reachable(entries) {
340
+ const seen = /* @__PURE__ */ new Set();
341
+ const queue = [];
342
+ for (const e of entries) {
343
+ if (this.files.has(e) && !seen.has(e)) {
344
+ seen.add(e);
345
+ queue.push(e);
346
+ }
347
+ }
348
+ while (queue.length > 0) {
349
+ const file = queue.shift();
350
+ const pf = this.files.get(file);
351
+ for (const record of pf.imports) {
352
+ const next = record.resolvedFile;
353
+ if (next && !seen.has(next)) {
354
+ seen.add(next);
355
+ queue.push(next);
356
+ }
357
+ }
358
+ }
359
+ return seen;
360
+ }
361
+ /**
362
+ * Is `name` (exported by `file`) forwarded by re-export chains into any of
363
+ * the `targets` files? Used to detect public-API exposure through entry
364
+ * barrels — external consumers of those are invisible to static analysis.
365
+ */
366
+ isReexportedThrough(file, name, targets, visited = /* @__PURE__ */ new Set()) {
367
+ const key = `${file}#${name}`;
368
+ if (visited.has(key)) return false;
369
+ visited.add(key);
370
+ for (const { importer, record } of this.importersOf(file)) {
371
+ let forwardedAs;
372
+ if (record.kind === "reexport-star" && name !== "default") {
373
+ forwardedAs = name;
374
+ } else if (record.kind === "reexport-named") {
375
+ forwardedAs = record.reexported?.find((r) => r.original === name)?.exported;
376
+ }
377
+ if (!forwardedAs) continue;
378
+ if (targets.has(importer)) return true;
379
+ if (this.isReexportedThrough(importer, forwardedAs, targets, visited)) return true;
380
+ }
381
+ return false;
382
+ }
383
+ /**
384
+ * Is `name` exported by `file` consumed anywhere, following re-export chains
385
+ * through barrels? Conservative by construction: namespace imports whose
386
+ * usage can't be fully traced count as usage.
387
+ */
388
+ isExportUsed(file, name, visited = /* @__PURE__ */ new Set()) {
389
+ const key = `${file}#${name}`;
390
+ if (visited.has(key)) return false;
391
+ visited.add(key);
392
+ for (const { importer, record } of this.importersOf(file)) {
393
+ switch (record.kind) {
394
+ case "static":
395
+ case "require": {
396
+ if (record.names.some((n) => n.imported === name)) return true;
397
+ const ns = record.namespaceUsage;
398
+ if (ns && (ns.escapes || ns.members.has(name))) return true;
399
+ break;
400
+ }
401
+ case "dynamic":
402
+ return true;
403
+ case "reexport-named": {
404
+ const match = record.reexported?.find((r) => r.original === name);
405
+ if (match && this.isExportUsed(importer, match.exported, visited)) return true;
406
+ break;
407
+ }
408
+ case "reexport-star":
409
+ if (name !== "default" && this.isExportUsed(importer, name, visited)) return true;
410
+ break;
411
+ }
412
+ }
413
+ return false;
414
+ }
415
+ };
416
+
417
+ // ../core/src/graph/resolver.ts
418
+ import fs from "fs";
419
+ import path2 from "path";
420
+ import { builtinModules } from "module";
421
+ import ts2 from "typescript";
422
+
423
+ // ../core/src/util/paths.ts
424
+ import path from "path";
425
+ function toPosix(p) {
426
+ return p.replace(/\\/g, "/");
427
+ }
428
+ function absPosix(p) {
429
+ return toPosix(path.resolve(p));
430
+ }
431
+ function relPosix(from, to) {
432
+ return toPosix(path.relative(from, to));
433
+ }
434
+ function dirnamePosix(p) {
435
+ return toPosix(path.dirname(p));
436
+ }
437
+ function isUnder(dir, file) {
438
+ return file === dir || file.startsWith(dir.endsWith("/") ? dir : dir + "/");
439
+ }
440
+
441
+ // ../core/src/graph/resolver.ts
442
+ var BUILTINS = new Set(builtinModules);
443
+ var RESOLVE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"];
444
+ var DEFAULT_OPTIONS = {
445
+ moduleResolution: ts2.ModuleResolutionKind.Bundler,
446
+ module: ts2.ModuleKind.ESNext,
447
+ target: ts2.ScriptTarget.Latest,
448
+ allowJs: true,
449
+ resolveJsonModule: true
450
+ };
451
+ var Resolver = class {
452
+ constructor(rootDir, workspacePackages, scannedFiles, warnings) {
453
+ this.rootDir = rootDir;
454
+ this.workspacePackages = workspacePackages;
455
+ this.scannedFiles = scannedFiles;
456
+ this.warnings = warnings;
457
+ }
458
+ rootDir;
459
+ workspacePackages;
460
+ scannedFiles;
461
+ warnings;
462
+ /** dir → compiler options of the nearest tsconfig (or defaults). */
463
+ optionsByDir = /* @__PURE__ */ new Map();
464
+ /** tsconfig path → parsed options. */
465
+ parsedConfigs = /* @__PURE__ */ new Map();
466
+ warnedConfigs = /* @__PURE__ */ new Set();
467
+ resolve(specifier, containingFile) {
468
+ if (specifier.startsWith("node:")) return {};
469
+ if (BUILTINS.has(specifier) || BUILTINS.has(specifier.split("/")[0])) return {};
470
+ const wsFirst = this.resolveWorkspace(specifier);
471
+ if (wsFirst?.file && this.scannedFiles.has(wsFirst.file)) return wsFirst;
472
+ const options = this.optionsFor(dirnamePosix(containingFile));
473
+ const r = ts2.resolveModuleName(specifier, containingFile, options, ts2.sys);
474
+ const resolved = r.resolvedModule;
475
+ if (resolved) {
476
+ const file = toPosix(resolved.resolvedFileName);
477
+ if (!resolved.isExternalLibraryImport && !file.includes("/node_modules/")) {
478
+ if (this.scannedFiles.has(file)) return { file };
479
+ const ws2 = this.resolveWorkspace(specifier);
480
+ if (ws2?.file && this.scannedFiles.has(ws2.file)) return ws2;
481
+ if (specifier.startsWith(".")) {
482
+ const manual = tryFile(path2.resolve(path2.dirname(containingFile), specifier));
483
+ if (manual && this.scannedFiles.has(toPosix(manual))) return { file: toPosix(manual) };
484
+ return {};
485
+ }
486
+ return { pkg: packageNameOf(specifier) };
487
+ }
488
+ return { pkg: resolved.packageId?.name ?? packageNameOf(specifier) };
489
+ }
490
+ const ws = this.resolveWorkspace(specifier);
491
+ if (ws) return ws;
492
+ if (specifier.startsWith(".") || specifier.startsWith("/")) {
493
+ const manual = tryFile(path2.resolve(path2.dirname(containingFile), specifier));
494
+ if (manual) return { file: toPosix(manual) };
495
+ return {};
496
+ }
497
+ return { pkg: packageNameOf(specifier) };
498
+ }
499
+ resolveWorkspace(specifier) {
500
+ for (const ws of this.workspacePackages) {
501
+ if (specifier === ws.name) {
502
+ return ws.entryFile ? { file: ws.entryFile } : { pkg: ws.name };
503
+ }
504
+ if (specifier.startsWith(ws.name + "/")) {
505
+ const sub = specifier.slice(ws.name.length + 1);
506
+ const hit = tryFile(path2.join(ws.dir, sub)) ?? tryFile(path2.join(ws.dir, "src", sub));
507
+ return hit ? { file: toPosix(hit) } : { pkg: ws.name };
508
+ }
509
+ }
510
+ return void 0;
511
+ }
512
+ optionsFor(dirPosix) {
513
+ const cached = this.optionsByDir.get(dirPosix);
514
+ if (cached) return cached;
515
+ let options = DEFAULT_OPTIONS;
516
+ let dir = dirPosix;
517
+ while (true) {
518
+ const candidate = dir + "/tsconfig.json";
519
+ if (fs.existsSync(candidate)) {
520
+ options = this.parseConfig(candidate);
521
+ break;
522
+ }
523
+ if (dir === this.rootDir || !dir.startsWith(this.rootDir)) break;
524
+ const parent = dirnamePosix(dir);
525
+ if (parent === dir) break;
526
+ dir = parent;
527
+ }
528
+ this.optionsByDir.set(dirPosix, options);
529
+ return options;
530
+ }
531
+ parseConfig(tsconfigPath) {
532
+ const cached = this.parsedConfigs.get(tsconfigPath);
533
+ if (cached) return cached;
534
+ let options = DEFAULT_OPTIONS;
535
+ const read = ts2.readConfigFile(tsconfigPath, ts2.sys.readFile);
536
+ if (read.error) {
537
+ this.warnConfig(tsconfigPath);
538
+ } else {
539
+ const parsed = ts2.parseJsonConfigFileContent(read.config, ts2.sys, path2.dirname(tsconfigPath));
540
+ if (parsed.errors.some((e) => e.category === ts2.DiagnosticCategory.Error && e.code === 5083)) {
541
+ this.warnConfig(tsconfigPath);
542
+ }
543
+ options = {
544
+ ...parsed.options,
545
+ allowJs: true,
546
+ resolveJsonModule: true,
547
+ // Classic resolution predates node_modules conventions; never useful here.
548
+ moduleResolution: !parsed.options.moduleResolution || parsed.options.moduleResolution === ts2.ModuleResolutionKind.Classic ? ts2.ModuleResolutionKind.Bundler : parsed.options.moduleResolution
549
+ };
550
+ }
551
+ this.parsedConfigs.set(tsconfigPath, options);
552
+ return options;
553
+ }
554
+ warnConfig(tsconfigPath) {
555
+ if (this.warnedConfigs.has(tsconfigPath)) return;
556
+ this.warnedConfigs.add(tsconfigPath);
557
+ this.warnings.push(
558
+ `Could not parse ${toPosix(path2.relative(this.rootDir, tsconfigPath))}; path aliases from it are ignored.`
559
+ );
560
+ }
561
+ };
562
+ function packageNameOf(specifier) {
563
+ const parts = specifier.split("/");
564
+ return specifier.startsWith("@") && parts.length >= 2 ? `${parts[0]}/${parts[1]}` : parts[0];
565
+ }
566
+ function tryFile(p) {
567
+ if (fs.existsSync(p) && fs.statSync(p).isFile()) return p;
568
+ for (const ext of RESOLVE_EXTENSIONS) {
569
+ if (fs.existsSync(p + ext)) return p + ext;
570
+ }
571
+ for (const ext of RESOLVE_EXTENSIONS) {
572
+ const idx = path2.join(p, "index" + ext);
573
+ if (fs.existsSync(idx)) return idx;
574
+ }
575
+ return void 0;
576
+ }
577
+
578
+ // ../core/src/workspace/workspaces.ts
579
+ import fs2 from "fs";
580
+ import path3 from "path";
581
+ import { glob } from "tinyglobby";
582
+ import { parse as parseYaml } from "yaml";
583
+ async function discoverWorkspaces(root) {
584
+ const warnings = [];
585
+ const units = [];
586
+ const rootPkg = readPackageJson(path3.join(root, "package.json"), warnings);
587
+ units.push({ name: rootPkg?.name ?? ".", dir: root, packageJson: rootPkg ?? {} });
588
+ if (!rootPkg) {
589
+ warnings.push("No package.json at the scan root; dependency analysis is limited.");
590
+ }
591
+ const globs = workspaceGlobs(root, rootPkg);
592
+ if (globs.length > 0) {
593
+ const pkgFiles = await glob(
594
+ globs.map((g) => `${g.replace(/\/$/, "")}/package.json`),
595
+ { cwd: root, ignore: ["**/node_modules/**"], absolute: true }
596
+ );
597
+ for (const pkgFile of pkgFiles.sort()) {
598
+ const pkg2 = readPackageJson(pkgFile, warnings);
599
+ if (!pkg2) continue;
600
+ const dir = toPosix(path3.dirname(pkgFile));
601
+ units.push({ name: pkg2.name ?? toPosix(path3.relative(root, dir)), dir, packageJson: pkg2 });
602
+ }
603
+ }
604
+ return { units, warnings };
605
+ }
606
+ function workspaceGlobs(root, rootPkg) {
607
+ const pnpmWs = path3.join(root, "pnpm-workspace.yaml");
608
+ if (fs2.existsSync(pnpmWs)) {
609
+ try {
610
+ const parsed = parseYaml(fs2.readFileSync(pnpmWs, "utf8"));
611
+ if (Array.isArray(parsed?.packages)) return parsed.packages.filter((p) => !p.startsWith("!"));
612
+ } catch {
613
+ }
614
+ }
615
+ const ws = rootPkg?.workspaces;
616
+ if (Array.isArray(ws)) return ws;
617
+ if (ws && Array.isArray(ws.packages)) return ws.packages;
618
+ return [];
619
+ }
620
+ function readPackageJson(file, warnings) {
621
+ if (!fs2.existsSync(file)) return void 0;
622
+ try {
623
+ return JSON.parse(fs2.readFileSync(file, "utf8"));
624
+ } catch {
625
+ warnings.push(`Could not parse ${toPosix(file)}; treating package as empty.`);
626
+ return void 0;
627
+ }
628
+ }
629
+ function unitForFile(units, file) {
630
+ let best = units[0];
631
+ for (const u of units) {
632
+ if ((file === u.dir || file.startsWith(u.dir + "/")) && u.dir.length > best.dir.length) {
633
+ best = u;
634
+ }
635
+ }
636
+ if (!(file === best.dir || file.startsWith(best.dir + "/"))) return units[0];
637
+ return best;
638
+ }
639
+
640
+ // ../core/src/workspace/entrypoints.ts
641
+ import path4 from "path";
642
+
643
+ // ../core/src/manifest/fileRefs.ts
644
+ var RUNNER_RE = /(?:^|[\s;&|("'`])(?:node|tsx|ts-node(?:-esm)?|nodemon|vite-node|bun)\s+(?:-{1,2}[\w-]+(?:[= ][^\s;&|"'`]+)?\s+)*["']?([^\s;&|"'`)]+\.(?:m?[jt]s|[jt]sx|c[jt]s))["']?/g;
645
+ function extractFileRefs(text) {
646
+ const refs = [];
647
+ for (const match of text.matchAll(RUNNER_RE)) {
648
+ refs.push(match[1]);
649
+ }
650
+ return refs;
651
+ }
652
+
653
+ // ../core/src/workspace/entrypoints.ts
654
+ var BUILD_DIRS = ["dist", "build", "lib", "out", "output", ".next"];
655
+ var TEST_FILE_RE = /(\.|\/)(test|spec)\.[mc]?[jt]sx?$|\/__tests__\/|^tests?\//;
656
+ function isTestFile(relPath) {
657
+ return TEST_FILE_RE.test(relPath);
658
+ }
659
+ var CONFIG_FILE_RE = /(^|\/)[\w.-]+\.config\.[mc]?[jt]s$|(^|\/)\.\w+rc\.[mc]?js$/;
660
+ function discoverEntryPoints(unit, scannedFiles, warnings) {
661
+ const entries = [];
662
+ const seen = /* @__PURE__ */ new Set();
663
+ const pkg2 = unit.packageJson;
664
+ const add = (file, reason, detail, tag) => {
665
+ if (!seen.has(file) && scannedFiles.has(file)) {
666
+ seen.add(file);
667
+ entries.push({ file, reason, ...tag ? { tag } : {}, ...detail ? { detail } : {} });
668
+ }
669
+ };
670
+ const addRef = (ref, reason, detail) => {
671
+ const resolved = resolveEntryRef(unit.dir, ref, scannedFiles);
672
+ if (resolved) {
673
+ add(resolved, reason, detail);
674
+ } else if (["main", "module", "bin", "exports"].includes(reason)) {
675
+ warnings.push(
676
+ `Entry '${ref}' in ${unit.name}/package.json does not map to a scanned source file.`
677
+ );
678
+ }
679
+ };
680
+ if (typeof pkg2.main === "string") addRef(pkg2.main, "main");
681
+ if (typeof pkg2.module === "string") addRef(pkg2.module, "module");
682
+ if (typeof pkg2.browser === "string") addRef(pkg2.browser, "browser");
683
+ if (typeof pkg2.bin === "string") addRef(pkg2.bin, "bin");
684
+ else if (pkg2.bin && typeof pkg2.bin === "object") {
685
+ for (const target of Object.values(pkg2.bin)) addRef(target, "bin");
686
+ }
687
+ for (const target of exportsTargets(pkg2.exports)) addRef(target, "exports");
688
+ for (const [scriptName, command] of Object.entries(pkg2.scripts ?? {})) {
689
+ for (const ref of extractFileRefs(command)) {
690
+ addRef(ref, "script", `package.json script '${scriptName}'`);
691
+ }
692
+ }
693
+ const deps = { ...pkg2.dependencies, ...pkg2.devDependencies };
694
+ if (deps["next"]) {
695
+ for (const file of scannedFiles) {
696
+ const rel = relTo(unit.dir, file);
697
+ if (rel && /^(pages|app|src\/pages|src\/app)\//.test(rel))
698
+ add(file, "framework", "Next.js route convention");
699
+ if (rel && /^(src\/)?middleware\.[jt]s$/.test(rel))
700
+ add(file, "framework", "Next.js middleware");
701
+ }
702
+ }
703
+ if (deps["@nestjs/core"]) {
704
+ const main = tryFile(path4.join(unit.dir, "src/main"));
705
+ if (main) add(toPosix(main), "framework", "NestJS bootstrap");
706
+ }
707
+ for (const file of scannedFiles) {
708
+ const rel = relTo(unit.dir, file);
709
+ if (rel && !rel.includes("/") && CONFIG_FILE_RE.test(rel)) {
710
+ add(file, "config", "tool configuration file");
711
+ }
712
+ }
713
+ for (const file of scannedFiles) {
714
+ const rel = relTo(unit.dir, file);
715
+ if (rel && isTestFile(rel)) add(file, "test", void 0, "test");
716
+ }
717
+ return entries;
718
+ }
719
+ function resolveEntryRef(unitDir, ref, scannedFiles) {
720
+ const accept = (p) => {
721
+ if (!p) return void 0;
722
+ const posix = toPosix(p);
723
+ return !scannedFiles || scannedFiles.has(posix) ? posix : void 0;
724
+ };
725
+ const clean = ref.replace(/^\.\//, "");
726
+ const direct = accept(tryFile(path4.join(unitDir, clean)));
727
+ if (direct) return direct;
728
+ const parts = clean.split("/");
729
+ const first = parts[0];
730
+ if (BUILD_DIRS.includes(first)) {
731
+ const rest = parts.slice(1).join("/");
732
+ const stripped = rest.replace(/\.(js|mjs|cjs|d\.ts)$/, "");
733
+ for (const base of [`src/${stripped}`, stripped]) {
734
+ const hit = accept(tryFile(path4.join(unitDir, base)));
735
+ if (hit) return hit;
736
+ }
737
+ }
738
+ return void 0;
739
+ }
740
+ function exportsTargets(exp) {
741
+ const out = [];
742
+ const walk = (value) => {
743
+ if (typeof value === "string") {
744
+ if (!value.endsWith(".d.ts")) out.push(value);
745
+ } else if (value && typeof value === "object") {
746
+ for (const v of Object.values(value)) walk(v);
747
+ }
748
+ };
749
+ walk(exp);
750
+ return out;
751
+ }
752
+ function relTo(dir, file) {
753
+ return file.startsWith(dir + "/") ? file.slice(dir.length + 1) : void 0;
754
+ }
755
+
756
+ // ../core/src/manifest/crontab.ts
757
+ import fs3 from "fs";
758
+ import path5 from "path";
759
+ function isCrontabFile(relPath) {
760
+ return /(^|\/)crontab$|\.cron$|(^|\/)cron\.d\//.test(relPath);
761
+ }
762
+ function parseCrontab(root, manifestRel) {
763
+ const callers = [];
764
+ const text = fs3.readFileSync(path5.join(root, manifestRel), "utf8");
765
+ for (const rawLine of text.split(/\r?\n/)) {
766
+ const line = rawLine.trim();
767
+ if (!line || line.startsWith("#")) continue;
768
+ const fields = line.split(/\s+/);
769
+ const schedule = line.startsWith("@") ? fields[0] : fields.slice(0, 5).join(" ");
770
+ for (const ref of extractFileRefs(line)) {
771
+ const resolved = tryFile(path5.resolve(root, ref));
772
+ if (resolved) {
773
+ callers.push({
774
+ file: toPosix(resolved),
775
+ manifest: manifestRel,
776
+ kind: "crontab",
777
+ detail: `cron '${schedule}' runs ${ref}`
778
+ });
779
+ }
780
+ }
781
+ }
782
+ return callers;
783
+ }
784
+
785
+ // ../core/src/manifest/githubActions.ts
786
+ import fs4 from "fs";
787
+ import path6 from "path";
788
+ import { parse as parseYaml2 } from "yaml";
789
+ function parseGithubWorkflow(root, manifestRel, warnings) {
790
+ const callers = [];
791
+ let doc;
792
+ try {
793
+ doc = parseYaml2(fs4.readFileSync(path6.join(root, manifestRel), "utf8"));
794
+ } catch {
795
+ warnings.push(`Could not parse workflow ${manifestRel}; its callers are unknown.`);
796
+ return callers;
797
+ }
798
+ for (const [jobName, job] of Object.entries(doc?.jobs ?? {})) {
799
+ for (const step of job?.steps ?? []) {
800
+ if (typeof step?.run !== "string") continue;
801
+ for (const ref of extractFileRefs(step.run)) {
802
+ const resolved = tryFile(path6.resolve(root, ref));
803
+ if (resolved) {
804
+ callers.push({
805
+ file: toPosix(resolved),
806
+ manifest: manifestRel,
807
+ kind: "github-actions",
808
+ detail: `GitHub Actions job '${jobName}' runs ${ref}`
809
+ });
810
+ }
811
+ }
812
+ }
813
+ }
814
+ return callers;
815
+ }
816
+
817
+ // ../core/src/manifest/k8s.ts
818
+ import fs5 from "fs";
819
+ import path7 from "path";
820
+ import { parseAllDocuments } from "yaml";
821
+ function parseK8sManifest(root, manifestRel, warnings) {
822
+ const callers = [];
823
+ let docs;
824
+ try {
825
+ docs = parseAllDocuments(fs5.readFileSync(path7.join(root, manifestRel), "utf8"));
826
+ } catch {
827
+ warnings.push(`Could not parse yaml ${manifestRel}; its callers are unknown.`);
828
+ return callers;
829
+ }
830
+ for (const doc of docs) {
831
+ const obj = doc?.toJS?.();
832
+ if (!obj || typeof obj !== "object" || !obj["apiVersion"] || !obj["kind"]) continue;
833
+ const kind = String(obj["kind"]);
834
+ for (const container of containersOf(obj)) {
835
+ const cmd = [...container.command ?? [], ...container.args ?? []].filter((c) => typeof c === "string").join(" ");
836
+ for (const ref of extractFileRefs(cmd)) {
837
+ const resolved = tryFile(path7.resolve(root, ref));
838
+ if (resolved) {
839
+ callers.push({
840
+ file: toPosix(resolved),
841
+ manifest: manifestRel,
842
+ kind: "k8s",
843
+ detail: `k8s ${kind} container runs ${ref}`
844
+ });
845
+ }
846
+ }
847
+ }
848
+ }
849
+ return callers;
850
+ }
851
+ function containersOf(obj) {
852
+ const out = [];
853
+ const walk = (value) => {
854
+ if (Array.isArray(value)) {
855
+ for (const v of value) walk(v);
856
+ } else if (value && typeof value === "object") {
857
+ const rec = value;
858
+ if (Array.isArray(rec["containers"])) {
859
+ for (const c of rec["containers"]) {
860
+ if (c && typeof c === "object") out.push(c);
861
+ }
862
+ }
863
+ for (const v of Object.values(rec)) walk(v);
864
+ }
865
+ };
866
+ walk(obj);
867
+ return out;
868
+ }
869
+
870
+ // ../core/src/manifest/configRefs.ts
871
+ import fs6 from "fs";
872
+ import path8 from "path";
873
+ import { parse as parseYaml3 } from "yaml";
874
+ function parseDockerfile(root, manifestRel) {
875
+ const callers = [];
876
+ const text = fs6.readFileSync(path8.join(root, manifestRel), "utf8");
877
+ for (const line of text.split(/\r?\n/)) {
878
+ const m = /^\s*(CMD|ENTRYPOINT)\s+(.*)$/i.exec(line);
879
+ if (!m) continue;
880
+ let command = m[2].trim();
881
+ if (command.startsWith("[")) {
882
+ try {
883
+ const arr = JSON.parse(command);
884
+ command = arr.filter((a) => typeof a === "string").join(" ");
885
+ } catch {
886
+ }
887
+ }
888
+ for (const ref of extractFileRefs(command)) {
889
+ const resolved = tryFile(path8.resolve(root, ref));
890
+ if (resolved) {
891
+ callers.push({
892
+ file: toPosix(resolved),
893
+ manifest: manifestRel,
894
+ kind: "dockerfile",
895
+ detail: `Dockerfile ${m[1].toUpperCase()} runs ${ref}`
896
+ });
897
+ }
898
+ }
899
+ }
900
+ return callers;
901
+ }
902
+ function parseServerless(root, manifestRel, warnings) {
903
+ const callers = [];
904
+ let doc;
905
+ try {
906
+ doc = parseYaml3(fs6.readFileSync(path8.join(root, manifestRel), "utf8"));
907
+ } catch {
908
+ warnings.push(`Could not parse ${manifestRel}; its handlers are unknown.`);
909
+ return callers;
910
+ }
911
+ for (const [fnName, fn] of Object.entries(doc?.functions ?? {})) {
912
+ if (typeof fn?.handler !== "string") continue;
913
+ const fileBase = fn.handler.slice(0, fn.handler.lastIndexOf("."));
914
+ const resolved = tryFile(path8.resolve(root, fileBase));
915
+ if (resolved) {
916
+ callers.push({
917
+ file: toPosix(resolved),
918
+ manifest: manifestRel,
919
+ kind: "serverless",
920
+ detail: `serverless function '${fnName}' handler`
921
+ });
922
+ }
923
+ }
924
+ return callers;
925
+ }
926
+ function parseWebpackConfig(root, manifestRel) {
927
+ const callers = [];
928
+ const text = fs6.readFileSync(path8.join(root, manifestRel), "utf8");
929
+ const entryMatch = /\bentry\s*:\s*(\{[^}]*\}|\[[^\]]*\]|['"][^'"]+['"])/s.exec(text);
930
+ if (!entryMatch) return callers;
931
+ for (const str of entryMatch[1].matchAll(/['"]([^'"]+)['"]/g)) {
932
+ const resolved = tryFile(path8.resolve(root, str[1]));
933
+ if (resolved) {
934
+ callers.push({
935
+ file: toPosix(resolved),
936
+ manifest: manifestRel,
937
+ kind: "webpack",
938
+ detail: `webpack entry`
939
+ });
940
+ }
941
+ }
942
+ return callers;
943
+ }
944
+
945
+ // ../core/src/model/signals.ts
946
+ function kill(id, weight, description) {
947
+ return { id, source: "static", direction: "kill", weight, description };
948
+ }
949
+ function spare(id, weight, description) {
950
+ return { id, source: "static", direction: "spare", weight: -Math.abs(weight), description };
951
+ }
952
+ var SIGNAL_LIST = [
953
+ // Base signals — one per category; they carry the category's base score so
954
+ // the JSON output is fully self-describing (score is just Σ evidence.weight).
955
+ kill("static:unreachable-file", 60, "File is not reachable from any entry point"),
956
+ kill("static:unreferenced-export", 65, "Exported symbol is never imported anywhere in the repo"),
957
+ kill("static:unused-dependency", 70, "Declared dependency is never imported"),
958
+ kill("static:route-registered", 35, "Route is registered; static analysis cannot observe calls"),
959
+ // Kill signals (raise confidence).
960
+ kill("static:deprecated-jsdoc", 15, "Marked @deprecated"),
961
+ kill("static:removal-comment", 10, "A nearby comment asks for removal"),
962
+ kill("static:test-only-reference", 10, "Only reachable from test files"),
963
+ kill("static:no-client-path-match", 10, "No string literal in the repo matches the route path"),
964
+ kill("static:controller-not-in-module", 45, "Controller is not registered in any @Module"),
965
+ kill(
966
+ "static:route-file-unreachable",
967
+ 45,
968
+ "The file registering this route is itself unreachable, so the route is never mounted"
969
+ ),
970
+ // Spare signals (hidden callers — lower confidence, never silently drop).
971
+ spare(
972
+ "static:dynamic-import-in-dir",
973
+ 25,
974
+ "A dynamic import/require may load files in this directory"
975
+ ),
976
+ spare(
977
+ "static:dynamic-import-exact-dir",
978
+ 40,
979
+ "A dynamic import/require targets exactly this directory"
980
+ ),
981
+ spare("static:string-dispatch", 20, "Symbol appears in a string-keyed dispatch table"),
982
+ spare("static:script-reference", 60, "Referenced by a package.json script"),
983
+ spare("static:config-reference", 60, "Referenced by a build/deploy config"),
984
+ spare("static:manifest-reference", 60, "Referenced by a caller manifest (cron/CI/k8s)"),
985
+ spare("static:job-registration", 50, "File registers scheduled jobs or queue workers"),
986
+ spare(
987
+ "static:namespace-import-ambiguity",
988
+ 15,
989
+ "Imported via namespace whose usage could not be fully traced"
990
+ ),
991
+ spare("static:cjs-dynamic-pattern", 20, "File assigns computed CommonJS exports"),
992
+ spare(
993
+ "static:client-path-match",
994
+ 30,
995
+ "A string literal elsewhere in the repo matches the route path"
996
+ ),
997
+ spare("static:dynamic-route-prefix", 15, "Route prefix could not be resolved statically"),
998
+ spare(
999
+ "static:types-package-pairing",
1000
+ 60,
1001
+ "@types package pairs with a runtime package that is in use"
1002
+ ),
1003
+ spare(
1004
+ "static:public-api-reexport",
1005
+ 30,
1006
+ "Re-exported by a package entry point; external consumers are invisible to static analysis"
1007
+ ),
1008
+ // Reserved for the paid evidence layer — registered now so the scorer and
1009
+ // JSON contract already understand them.
1010
+ {
1011
+ id: "prod:zero-requests-90d",
1012
+ source: "production",
1013
+ direction: "kill",
1014
+ weight: 35,
1015
+ description: "No production requests observed in 90 days"
1016
+ },
1017
+ {
1018
+ id: "prod:request-seen",
1019
+ source: "production",
1020
+ direction: "spare",
1021
+ weight: -100,
1022
+ description: "Production traffic observed"
1023
+ }
1024
+ ];
1025
+ var SIGNALS = new Map(SIGNAL_LIST.map((s) => [s.id, s]));
1026
+ var BASE_SIGNALS = {
1027
+ "unreachable-file": "static:unreachable-file",
1028
+ "unused-export": "static:unreferenced-export",
1029
+ "unused-dependency": "static:unused-dependency",
1030
+ "dead-route": "static:route-registered"
1031
+ };
1032
+ var STATIC_CAPS = {
1033
+ "dead-route": 70,
1034
+ "unused-export": 85,
1035
+ "unused-dependency": 85,
1036
+ "unreachable-file": 85
1037
+ };
1038
+ var STATIC_CAP_REASON = "no runtime evidence \u2014 connect telemetry to confirm";
1039
+ function evidenceFor(signalId, detail, location) {
1040
+ const spec = SIGNALS.get(signalId);
1041
+ if (!spec) {
1042
+ throw new Error(`Unknown signal id: ${signalId}`);
1043
+ }
1044
+ return {
1045
+ signal: spec.id,
1046
+ source: spec.source,
1047
+ direction: spec.direction,
1048
+ weight: spec.weight,
1049
+ detail,
1050
+ ...location ? { location } : {}
1051
+ };
1052
+ }
1053
+
1054
+ // ../core/src/model/finding.ts
1055
+ function targetKey(target) {
1056
+ switch (target.kind) {
1057
+ case "route":
1058
+ return `route:${target.framework}:${target.method} ${target.path}`;
1059
+ case "export":
1060
+ return `export:${target.file}#${target.symbol}`;
1061
+ case "dependency":
1062
+ return `dependency:${target.depType}:${target.name}`;
1063
+ case "file":
1064
+ return `file:${target.file}`;
1065
+ }
1066
+ }
1067
+ function describeTarget(target) {
1068
+ switch (target.kind) {
1069
+ case "route":
1070
+ return `${target.method} ${target.path}`;
1071
+ case "export":
1072
+ return `export '${target.symbol}' in ${target.file}`;
1073
+ case "dependency":
1074
+ return `${target.name} (${target.depType})`;
1075
+ case "file":
1076
+ return target.file;
1077
+ }
1078
+ }
1079
+
1080
+ // ../core/src/util/hash.ts
1081
+ import { createHash } from "crypto";
1082
+ function stableId(...parts) {
1083
+ return createHash("sha256").update(parts.join("|")).digest("hex").slice(0, 12);
1084
+ }
1085
+
1086
+ // ../core/src/detectors/types.ts
1087
+ function makeFinding(category, target, location, workspace, baseDetail) {
1088
+ return {
1089
+ id: stableId(targetKey(target), workspace),
1090
+ category,
1091
+ target,
1092
+ location,
1093
+ workspace,
1094
+ evidence: [evidenceFor(BASE_SIGNALS[category], baseDetail)],
1095
+ confidence: { score: 0, level: "low" }
1096
+ };
1097
+ }
1098
+
1099
+ // ../core/src/detectors/unreachableFiles.ts
1100
+ var unreachableFiles = {
1101
+ name: "unreachable-files",
1102
+ detect: () => true,
1103
+ extract(ctx) {
1104
+ const findings = [];
1105
+ const entryFiles = new Set(ctx.entryPoints.map((e) => e.file));
1106
+ const entryCount = ctx.entryPoints.filter((e) => e.tag !== "test").length;
1107
+ for (const file of ctx.unitFiles()) {
1108
+ if (entryFiles.has(file)) continue;
1109
+ const rel = ctx.rel(file);
1110
+ if (!ctx.reach.all.has(file)) {
1111
+ findings.push(
1112
+ makeFinding(
1113
+ "unreachable-file",
1114
+ { kind: "file", file: rel },
1115
+ { file: rel, line: 1 },
1116
+ ctx.unit.name,
1117
+ `Not imported (directly or transitively) by any of ${entryCount} entry points`
1118
+ )
1119
+ );
1120
+ } else if (!ctx.reach.prod.has(file)) {
1121
+ const finding = makeFinding(
1122
+ "unreachable-file",
1123
+ { kind: "file", file: rel },
1124
+ { file: rel, line: 1 },
1125
+ ctx.unit.name,
1126
+ "Only reachable through test files; no production entry point imports it"
1127
+ );
1128
+ finding.evidence.push(
1129
+ evidenceFor("static:test-only-reference", "Reachable exclusively via test entry points")
1130
+ );
1131
+ findings.push(finding);
1132
+ }
1133
+ }
1134
+ return { findings };
1135
+ }
1136
+ };
1137
+
1138
+ // ../core/src/detectors/unusedExports.ts
1139
+ var unusedExports = {
1140
+ name: "unused-exports",
1141
+ detect: () => true,
1142
+ extract(ctx) {
1143
+ const findings = [];
1144
+ const entryFiles = new Set(ctx.entryPoints.map((e) => e.file));
1145
+ const publicEntryFiles = new Set(
1146
+ ctx.entryPoints.filter((e) => e.tag !== "test").map((e) => e.file)
1147
+ );
1148
+ for (const file of ctx.unitFiles()) {
1149
+ if (entryFiles.has(file)) continue;
1150
+ if (!ctx.reach.all.has(file)) continue;
1151
+ const rel = ctx.rel(file);
1152
+ if (isTestFile(rel)) continue;
1153
+ const pf = ctx.graph.files.get(file);
1154
+ const seenNames = /* @__PURE__ */ new Set();
1155
+ for (const exp of pf.exports) {
1156
+ if (seenNames.has(exp.name)) continue;
1157
+ seenNames.add(exp.name);
1158
+ if (ctx.graph.isExportUsed(file, exp.name)) continue;
1159
+ const finding = makeFinding(
1160
+ "unused-export",
1161
+ { kind: "export", symbol: exp.name, file: rel },
1162
+ { file: rel, line: exp.line },
1163
+ ctx.unit.name,
1164
+ exp.kind === "type" ? `Type export '${exp.name}' is never imported` : `'${exp.name}' is exported but never imported anywhere in the repo`
1165
+ );
1166
+ if (exp.deprecated) {
1167
+ finding.evidence.push(
1168
+ evidenceFor("static:deprecated-jsdoc", `'${exp.name}' is marked @deprecated`)
1169
+ );
1170
+ }
1171
+ if (ctx.graph.isReexportedThrough(file, exp.name, publicEntryFiles)) {
1172
+ finding.evidence.push(
1173
+ evidenceFor(
1174
+ "static:public-api-reexport",
1175
+ `'${exp.name}' is part of the package's public API via an entry-point re-export`
1176
+ )
1177
+ );
1178
+ }
1179
+ if (pf.hasCjsDynamicExport) {
1180
+ finding.evidence.push(
1181
+ evidenceFor(
1182
+ "static:cjs-dynamic-pattern",
1183
+ "File assigns computed CommonJS exports; the export set is not fully static"
1184
+ )
1185
+ );
1186
+ }
1187
+ findings.push(finding);
1188
+ }
1189
+ }
1190
+ return { findings };
1191
+ }
1192
+ };
1193
+
1194
+ // ../core/src/detectors/unusedDeps.ts
1195
+ import fs7 from "fs";
1196
+ import path9 from "path";
1197
+ var unusedDeps = {
1198
+ name: "unused-dependencies",
1199
+ detect: (unit) => Object.keys(unit.packageJson.dependencies ?? {}).length > 0 || Object.keys(unit.packageJson.devDependencies ?? {}).length > 0,
1200
+ extract(ctx) {
1201
+ const findings = [];
1202
+ const used = usedPackages(ctx);
1203
+ const pkgJsonRel = ctx.rel(`${ctx.unit.dir}/package.json`);
1204
+ const lineIndex = packageJsonLines(ctx.unit.dir);
1205
+ for (const depType of ["dependencies", "devDependencies"]) {
1206
+ for (const name of Object.keys(ctx.unit.packageJson[depType] ?? {})) {
1207
+ if (used.has(name)) continue;
1208
+ const finding = makeFinding(
1209
+ "unused-dependency",
1210
+ { kind: "dependency", name, depType },
1211
+ { file: pkgJsonRel, line: lineIndex.get(name) ?? 1 },
1212
+ ctx.unit.name,
1213
+ `'${name}' is declared in ${depType} but never imported`
1214
+ );
1215
+ if (name.startsWith("@types/")) {
1216
+ const runtime = runtimePackageFor(name);
1217
+ if (runtime === "node" || used.has(runtime)) {
1218
+ finding.evidence.push(
1219
+ evidenceFor(
1220
+ "static:types-package-pairing",
1221
+ `Type definitions for '${runtime}', which is in use`
1222
+ )
1223
+ );
1224
+ }
1225
+ }
1226
+ findings.push(finding);
1227
+ }
1228
+ }
1229
+ return { findings };
1230
+ }
1231
+ };
1232
+ function usedPackages(ctx) {
1233
+ const used = /* @__PURE__ */ new Set();
1234
+ for (const file of ctx.graph.files.keys()) {
1235
+ if (!isUnder(ctx.unit.dir, file)) continue;
1236
+ const pf = ctx.graph.files.get(file);
1237
+ for (const imp of pf.imports) {
1238
+ if (imp.resolvedPackage) {
1239
+ used.add(imp.resolvedPackage);
1240
+ } else if (!imp.specifier.startsWith(".") && !imp.specifier.startsWith("node:")) {
1241
+ used.add(packageNameOf(imp.specifier));
1242
+ }
1243
+ }
1244
+ }
1245
+ return used;
1246
+ }
1247
+ function runtimePackageFor(typesPackage) {
1248
+ const base = typesPackage.slice("@types/".length);
1249
+ return base.includes("__") ? `@${base.replace("__", "/")}` : base;
1250
+ }
1251
+ function packageJsonLines(unitDir) {
1252
+ const lines = /* @__PURE__ */ new Map();
1253
+ try {
1254
+ const text = fs7.readFileSync(path9.join(unitDir, "package.json"), "utf8");
1255
+ text.split(/\r?\n/).forEach((line, i) => {
1256
+ const m = /^\s*"((?:@[\w.-]+\/)?[\w.-]+)"\s*:/.exec(line);
1257
+ if (m && !lines.has(m[1])) lines.set(m[1], i + 1);
1258
+ });
1259
+ } catch {
1260
+ }
1261
+ return lines;
1262
+ }
1263
+
1264
+ // ../core/src/detectors/routes/express.ts
1265
+ import ts3 from "typescript";
1266
+ var HTTP_METHODS = /* @__PURE__ */ new Set(["get", "post", "put", "patch", "delete", "all", "head", "options"]);
1267
+ var CONVENTIONAL_RECEIVERS = /* @__PURE__ */ new Set(["app", "router", "server", "api", "fastify"]);
1268
+ var RECEIVER_FACTORIES = /* @__PURE__ */ new Set(["express", "fastify", "Fastify", "Router"]);
1269
+ var CLIENT_LITERAL_THRESHOLD = 3;
1270
+ var expressRoutes = {
1271
+ name: "express-routes",
1272
+ detect: (unit) => {
1273
+ const deps = { ...unit.packageJson.dependencies, ...unit.packageJson.devDependencies };
1274
+ return Boolean(deps["express"] || deps["fastify"]);
1275
+ },
1276
+ extract(ctx) {
1277
+ const registrations = [];
1278
+ const mounts = [];
1279
+ for (const file of ctx.unitFiles()) {
1280
+ if (isTestFile(ctx.rel(file))) continue;
1281
+ const pf = ctx.graph.files.get(file);
1282
+ collectFromFile(pf, registrations, mounts);
1283
+ }
1284
+ const prefixByFileReceiver = /* @__PURE__ */ new Map();
1285
+ const prefixByFile = /* @__PURE__ */ new Map();
1286
+ for (const mount of mounts) {
1287
+ const pf = ctx.graph.files.get(mount.file);
1288
+ const imported = pf.imports.find(
1289
+ (i) => i.resolvedFile && (i.names.some((n) => n.local === mount.localName) || i.namespaceLocal === mount.localName)
1290
+ );
1291
+ if (imported?.resolvedFile) {
1292
+ if (!prefixByFile.has(imported.resolvedFile)) {
1293
+ prefixByFile.set(imported.resolvedFile, mount.prefix);
1294
+ }
1295
+ } else if (!prefixByFileReceiver.has(`${mount.file}#${mount.localName}`)) {
1296
+ prefixByFileReceiver.set(`${mount.file}#${mount.localName}`, mount.prefix);
1297
+ }
1298
+ }
1299
+ const routeFiles = new Set(registrations.map((r) => r.file));
1300
+ const clientLiterals = [];
1301
+ for (const [file, pf] of ctx.graph.files) {
1302
+ if (routeFiles.has(file)) continue;
1303
+ for (const lit of pf.pathLiterals) clientLiterals.push({ value: lit.value, file });
1304
+ }
1305
+ const literalsMeaningful = clientLiterals.length >= CLIENT_LITERAL_THRESHOLD;
1306
+ const findings = [];
1307
+ const seen = /* @__PURE__ */ new Set();
1308
+ const framework = frameworkOf(ctx);
1309
+ for (const reg of registrations) {
1310
+ const prefix = prefixByFileReceiver.get(`${reg.file}#${reg.receiver}`) ?? prefixByFile.get(reg.file) ?? "";
1311
+ const fullPath = joinRoutePath(prefix, reg.path);
1312
+ const method = reg.method.toUpperCase();
1313
+ const key = `${method} ${fullPath} ${reg.file}`;
1314
+ if (seen.has(key)) continue;
1315
+ seen.add(key);
1316
+ const rel = ctx.rel(reg.file);
1317
+ const finding = makeFinding(
1318
+ "dead-route",
1319
+ { kind: "route", method, path: fullPath, framework },
1320
+ { file: rel, line: reg.line },
1321
+ ctx.unit.name,
1322
+ `${method} ${fullPath} is registered here; static analysis cannot observe whether it is called`
1323
+ );
1324
+ if (prefix === "<dynamic>") {
1325
+ finding.evidence.push(
1326
+ evidenceFor(
1327
+ "static:dynamic-route-prefix",
1328
+ "Mounted under a prefix that is not a string literal"
1329
+ )
1330
+ );
1331
+ }
1332
+ if (!ctx.reach.all.has(reg.file)) {
1333
+ finding.evidence.push(
1334
+ evidenceFor(
1335
+ "static:route-file-unreachable",
1336
+ `${rel} is not reachable from any entry point`
1337
+ )
1338
+ );
1339
+ } else if (literalsMeaningful && !fullPath.startsWith("<dynamic>")) {
1340
+ const match = findPathMatch(fullPath, clientLiterals);
1341
+ if (match) {
1342
+ finding.evidence.push(
1343
+ evidenceFor(
1344
+ "static:client-path-match",
1345
+ `'${match.value}' in ${ctx.rel(match.file)} matches this route`
1346
+ )
1347
+ );
1348
+ } else {
1349
+ finding.evidence.push(
1350
+ evidenceFor(
1351
+ "static:no-client-path-match",
1352
+ `None of ${clientLiterals.length} URL-like strings in the repo match this route`
1353
+ )
1354
+ );
1355
+ }
1356
+ }
1357
+ findings.push(finding);
1358
+ }
1359
+ return { findings, counted: { routes: seen.size } };
1360
+ }
1361
+ };
1362
+ function frameworkOf(ctx) {
1363
+ const deps = { ...ctx.unit.packageJson.dependencies, ...ctx.unit.packageJson.devDependencies };
1364
+ return deps["fastify"] && !deps["express"] ? "fastify" : "express";
1365
+ }
1366
+ function collectFromFile(pf, registrations, mounts) {
1367
+ const receivers = new Set(CONVENTIONAL_RECEIVERS);
1368
+ const seed = (node) => {
1369
+ if (ts3.isVariableDeclaration(node) && ts3.isIdentifier(node.name) && node.initializer && ts3.isCallExpression(node.initializer)) {
1370
+ const callee = node.initializer.expression;
1371
+ const isFactory = ts3.isIdentifier(callee) && RECEIVER_FACTORIES.has(callee.text) || ts3.isPropertyAccessExpression(callee) && callee.name.text === "Router";
1372
+ if (isFactory) receivers.add(node.name.text);
1373
+ }
1374
+ ts3.forEachChild(node, seed);
1375
+ };
1376
+ ts3.forEachChild(pf.ast, seed);
1377
+ const lineOf = (node) => pf.ast.getLineAndCharacterOfPosition(node.getStart(pf.ast)).line + 1;
1378
+ const visit = (node) => {
1379
+ if (ts3.isCallExpression(node) && ts3.isPropertyAccessExpression(node.expression)) {
1380
+ const methodName = node.expression.name.text;
1381
+ const base = node.expression.expression;
1382
+ if (HTTP_METHODS.has(methodName)) {
1383
+ const viaRoute = routeCallPath(base);
1384
+ if (viaRoute) {
1385
+ if (viaRoute.receiver && receivers.has(viaRoute.receiver)) {
1386
+ registrations.push({
1387
+ method: methodName,
1388
+ path: viaRoute.path,
1389
+ receiver: viaRoute.receiver,
1390
+ line: lineOf(node),
1391
+ file: pf.file
1392
+ });
1393
+ }
1394
+ } else if (ts3.isIdentifier(base) && receivers.has(base.text)) {
1395
+ const arg0 = node.arguments[0];
1396
+ if (arg0 && (ts3.isStringLiteral(arg0) || ts3.isNoSubstitutionTemplateLiteral(arg0)) && node.arguments.length >= 2 && node.arguments.slice(1).some(isHandlerish)) {
1397
+ registrations.push({
1398
+ method: methodName,
1399
+ path: arg0.text,
1400
+ receiver: base.text,
1401
+ line: lineOf(node),
1402
+ file: pf.file
1403
+ });
1404
+ }
1405
+ }
1406
+ }
1407
+ if (methodName === "route" && ts3.isIdentifier(base) && receivers.has(base.text)) {
1408
+ const arg0 = node.arguments[0];
1409
+ if (arg0 && ts3.isObjectLiteralExpression(arg0)) {
1410
+ const get = (prop) => {
1411
+ for (const p of arg0.properties) {
1412
+ if (ts3.isPropertyAssignment(p) && ts3.isIdentifier(p.name) && p.name.text === prop && (ts3.isStringLiteral(p.initializer) || ts3.isNoSubstitutionTemplateLiteral(p.initializer))) {
1413
+ return p.initializer.text;
1414
+ }
1415
+ }
1416
+ return void 0;
1417
+ };
1418
+ const url = get("url") ?? get("path");
1419
+ const method = get("method");
1420
+ if (url && method) {
1421
+ registrations.push({
1422
+ method: method.toLowerCase(),
1423
+ path: url,
1424
+ receiver: base.text,
1425
+ line: lineOf(node),
1426
+ file: pf.file
1427
+ });
1428
+ }
1429
+ }
1430
+ }
1431
+ if ((methodName === "use" || methodName === "register") && ts3.isIdentifier(base) && receivers.has(base.text) && node.arguments.length >= 1) {
1432
+ collectMount(node, methodName, pf.file, mounts);
1433
+ }
1434
+ }
1435
+ ts3.forEachChild(node, visit);
1436
+ };
1437
+ ts3.forEachChild(pf.ast, visit);
1438
+ }
1439
+ function routeCallPath(base) {
1440
+ if (ts3.isCallExpression(base) && ts3.isPropertyAccessExpression(base.expression) && base.expression.name.text === "route" && base.arguments.length >= 1) {
1441
+ const arg = base.arguments[0];
1442
+ if (ts3.isStringLiteral(arg) || ts3.isNoSubstitutionTemplateLiteral(arg)) {
1443
+ const receiverExpr = base.expression.expression;
1444
+ return {
1445
+ path: arg.text,
1446
+ ...ts3.isIdentifier(receiverExpr) ? { receiver: receiverExpr.text } : {}
1447
+ };
1448
+ }
1449
+ }
1450
+ return void 0;
1451
+ }
1452
+ function collectMount(node, methodName, file, mounts) {
1453
+ const args = node.arguments;
1454
+ if (methodName === "register") {
1455
+ if (args[0] && ts3.isIdentifier(args[0])) {
1456
+ let prefix = "";
1457
+ const opts = args[1];
1458
+ if (opts && ts3.isObjectLiteralExpression(opts)) {
1459
+ for (const p of opts.properties) {
1460
+ if (ts3.isPropertyAssignment(p) && ts3.isIdentifier(p.name) && p.name.text === "prefix") {
1461
+ prefix = ts3.isStringLiteral(p.initializer) ? p.initializer.text : "<dynamic>";
1462
+ }
1463
+ }
1464
+ }
1465
+ mounts.push({ prefix, localName: args[0].text, file });
1466
+ }
1467
+ return;
1468
+ }
1469
+ const first = args[0];
1470
+ if (ts3.isStringLiteral(first) || ts3.isNoSubstitutionTemplateLiteral(first)) {
1471
+ for (const arg of args.slice(1)) {
1472
+ if (ts3.isIdentifier(arg)) mounts.push({ prefix: first.text, localName: arg.text, file });
1473
+ }
1474
+ } else if (ts3.isIdentifier(first)) {
1475
+ mounts.push({ prefix: "", localName: first.text, file });
1476
+ } else if (args.length >= 2) {
1477
+ for (const arg of args.slice(1)) {
1478
+ if (ts3.isIdentifier(arg)) mounts.push({ prefix: "<dynamic>", localName: arg.text, file });
1479
+ }
1480
+ }
1481
+ }
1482
+ function isHandlerish(arg) {
1483
+ return !(ts3.isObjectLiteralExpression(arg) || ts3.isStringLiteral(arg) || ts3.isNoSubstitutionTemplateLiteral(arg) || ts3.isNumericLiteral(arg) || arg.kind === ts3.SyntaxKind.TrueKeyword || arg.kind === ts3.SyntaxKind.FalseKeyword);
1484
+ }
1485
+ function joinRoutePath(prefix, p) {
1486
+ if (!prefix) return p || "/";
1487
+ const joined = `${prefix}/${p}`.replace(/\/{2,}/g, "/");
1488
+ return joined.length > 1 ? joined.replace(/\/$/, "") : joined;
1489
+ }
1490
+ function findPathMatch(routePath, literals) {
1491
+ const paramIdx = routePath.search(/[:*]/);
1492
+ if (paramIdx === -1) {
1493
+ return literals.find((l) => l.value === routePath || l.value.startsWith(routePath + "?"));
1494
+ }
1495
+ const prefix = routePath.slice(0, paramIdx);
1496
+ return literals.find((l) => l.value.startsWith(prefix) && l.value.length > prefix.length);
1497
+ }
1498
+
1499
+ // ../core/src/detectors/routes/nest.ts
1500
+ import ts4 from "typescript";
1501
+ var FRAMEWORK = "nest";
1502
+ var METHOD_DECORATORS = {
1503
+ Get: "GET",
1504
+ Post: "POST",
1505
+ Put: "PUT",
1506
+ Patch: "PATCH",
1507
+ Delete: "DELETE",
1508
+ Options: "OPTIONS",
1509
+ Head: "HEAD",
1510
+ All: "ALL"
1511
+ };
1512
+ var nestRoutes = {
1513
+ name: "nest-routes",
1514
+ detect: (unit) => {
1515
+ const deps = { ...unit.packageJson.dependencies, ...unit.packageJson.devDependencies };
1516
+ return Boolean(deps["@nestjs/core"] || deps["@nestjs/common"]);
1517
+ },
1518
+ extract(ctx) {
1519
+ const controllers = [];
1520
+ const registeredIn = /* @__PURE__ */ new Map();
1521
+ for (const file of ctx.unitFiles()) {
1522
+ if (isTestFile(ctx.rel(file))) continue;
1523
+ const pf = ctx.graph.files.get(file);
1524
+ collectControllers(pf, controllers);
1525
+ collectModuleControllers(pf, registeredIn);
1526
+ }
1527
+ const findings = [];
1528
+ const seen = /* @__PURE__ */ new Set();
1529
+ let routeCount = 0;
1530
+ for (const ctrl of controllers) {
1531
+ const registered = isRegistered(ctx, ctrl, registeredIn.get(ctrl.name));
1532
+ for (const route of ctrl.routes) {
1533
+ const fullPath = joinRoutePath2(ctrl.prefix, route.path);
1534
+ routeCount++;
1535
+ const key = `${route.method} ${fullPath} ${ctrl.file}`;
1536
+ if (seen.has(key)) continue;
1537
+ seen.add(key);
1538
+ const rel = ctx.rel(ctrl.file);
1539
+ const finding = makeFinding(
1540
+ "dead-route",
1541
+ { kind: "route", method: route.method, path: fullPath, framework: FRAMEWORK },
1542
+ { file: rel, line: route.line },
1543
+ ctx.unit.name,
1544
+ `${route.method} ${fullPath} is declared on @Controller ${ctrl.name}; static analysis cannot observe whether it is called`
1545
+ );
1546
+ if (!registered) {
1547
+ finding.evidence.push(
1548
+ evidenceFor(
1549
+ "static:controller-not-in-module",
1550
+ `@Controller ${ctrl.name} is not listed in any @Module({ controllers }), so Nest never mounts it`,
1551
+ { file: rel, line: ctrl.line }
1552
+ )
1553
+ );
1554
+ }
1555
+ if (ctrl.prefix === "<dynamic>") {
1556
+ finding.evidence.push(
1557
+ evidenceFor(
1558
+ "static:dynamic-route-prefix",
1559
+ `@Controller ${ctrl.name} prefix is not a string literal`
1560
+ )
1561
+ );
1562
+ }
1563
+ findings.push(finding);
1564
+ }
1565
+ }
1566
+ return { findings, counted: { routes: routeCount } };
1567
+ }
1568
+ };
1569
+ function isRegistered(ctx, ctrl, moduleFiles) {
1570
+ if (!moduleFiles || moduleFiles.size === 0) return false;
1571
+ for (const moduleFile of moduleFiles) {
1572
+ if (moduleFile === ctrl.file) return true;
1573
+ const pf = ctx.graph.files.get(moduleFile);
1574
+ if (pf?.imports.some((imp) => imp.resolvedFile === ctrl.file)) return true;
1575
+ }
1576
+ return false;
1577
+ }
1578
+ function collectControllers(pf, out) {
1579
+ const lineOf = (node) => pf.ast.getLineAndCharacterOfPosition(node.getStart(pf.ast)).line + 1;
1580
+ const visit = (node) => {
1581
+ if (ts4.isClassDeclaration(node) && node.name) {
1582
+ const decorators = ts4.getDecorators?.(node) ?? [];
1583
+ const controllerDec = decorators.find((d) => decoratorName(d) === "Controller");
1584
+ if (controllerDec) {
1585
+ const prefix = controllerPrefix(controllerDec);
1586
+ const routes = [];
1587
+ for (const member of node.members) {
1588
+ if (!ts4.isMethodDeclaration(member)) continue;
1589
+ for (const dec of ts4.getDecorators?.(member) ?? []) {
1590
+ const verb = METHOD_DECORATORS[decoratorName(dec) ?? ""];
1591
+ if (verb) {
1592
+ routes.push({
1593
+ method: verb,
1594
+ path: decoratorStringArg(dec) ?? "",
1595
+ line: lineOf(member)
1596
+ });
1597
+ }
1598
+ }
1599
+ }
1600
+ if (routes.length > 0) {
1601
+ out.push({ name: node.name.text, prefix, routes, file: pf.file, line: lineOf(node) });
1602
+ }
1603
+ }
1604
+ }
1605
+ ts4.forEachChild(node, visit);
1606
+ };
1607
+ ts4.forEachChild(pf.ast, visit);
1608
+ }
1609
+ function collectModuleControllers(pf, out) {
1610
+ const visit = (node) => {
1611
+ if (ts4.isClassDeclaration(node)) {
1612
+ const moduleDec = (ts4.getDecorators?.(node) ?? []).find((d) => decoratorName(d) === "Module");
1613
+ if (moduleDec && ts4.isCallExpression(moduleDec.expression)) {
1614
+ const arg = moduleDec.expression.arguments[0];
1615
+ if (arg && ts4.isObjectLiteralExpression(arg)) {
1616
+ for (const prop of arg.properties) {
1617
+ if (ts4.isPropertyAssignment(prop) && ts4.isIdentifier(prop.name) && prop.name.text === "controllers" && ts4.isArrayLiteralExpression(prop.initializer)) {
1618
+ for (const el of prop.initializer.elements) {
1619
+ if (ts4.isIdentifier(el)) {
1620
+ let set = out.get(el.text);
1621
+ if (!set) out.set(el.text, set = /* @__PURE__ */ new Set());
1622
+ set.add(pf.file);
1623
+ }
1624
+ }
1625
+ }
1626
+ }
1627
+ }
1628
+ }
1629
+ }
1630
+ ts4.forEachChild(node, visit);
1631
+ };
1632
+ ts4.forEachChild(pf.ast, visit);
1633
+ }
1634
+ function decoratorName(dec) {
1635
+ const expr = ts4.isCallExpression(dec.expression) ? dec.expression.expression : dec.expression;
1636
+ return ts4.isIdentifier(expr) ? expr.text : void 0;
1637
+ }
1638
+ function decoratorStringArg(dec) {
1639
+ if (!ts4.isCallExpression(dec.expression)) return void 0;
1640
+ const arg = dec.expression.arguments[0];
1641
+ if (arg && (ts4.isStringLiteral(arg) || ts4.isNoSubstitutionTemplateLiteral(arg))) return arg.text;
1642
+ return void 0;
1643
+ }
1644
+ function controllerPrefix(dec) {
1645
+ if (!ts4.isCallExpression(dec.expression)) return "";
1646
+ const arg = dec.expression.arguments[0];
1647
+ if (!arg) return "";
1648
+ if (ts4.isStringLiteral(arg) || ts4.isNoSubstitutionTemplateLiteral(arg)) return arg.text;
1649
+ if (ts4.isObjectLiteralExpression(arg)) {
1650
+ for (const prop of arg.properties) {
1651
+ if (ts4.isPropertyAssignment(prop) && ts4.isIdentifier(prop.name) && prop.name.text === "path") {
1652
+ if (ts4.isStringLiteral(prop.initializer) || ts4.isNoSubstitutionTemplateLiteral(prop.initializer)) {
1653
+ return prop.initializer.text;
1654
+ }
1655
+ return "<dynamic>";
1656
+ }
1657
+ }
1658
+ return "";
1659
+ }
1660
+ return "<dynamic>";
1661
+ }
1662
+ function joinRoutePath2(prefix, p) {
1663
+ const a = prefix.replace(/^\/|\/$/g, "");
1664
+ const b = p.replace(/^\/|\/$/g, "");
1665
+ const joined = [a, b].filter(Boolean).join("/");
1666
+ return "/" + joined;
1667
+ }
1668
+
1669
+ // ../core/src/detectors/routes/next.ts
1670
+ var FRAMEWORK2 = "next";
1671
+ var CLIENT_LITERAL_THRESHOLD2 = 3;
1672
+ var NON_ROUTE_BASENAMES = /* @__PURE__ */ new Set([
1673
+ "_app",
1674
+ "_document",
1675
+ "_error",
1676
+ "layout",
1677
+ "loading",
1678
+ "error",
1679
+ "not-found",
1680
+ "template",
1681
+ "default",
1682
+ "global-error",
1683
+ "instrumentation",
1684
+ "middleware"
1685
+ ]);
1686
+ var nextRoutes = {
1687
+ name: "next-routes",
1688
+ detect: (unit) => {
1689
+ const deps = { ...unit.packageJson.dependencies, ...unit.packageJson.devDependencies };
1690
+ return Boolean(deps["next"]);
1691
+ },
1692
+ extract(ctx) {
1693
+ const routes = [];
1694
+ const routeFiles = /* @__PURE__ */ new Set();
1695
+ for (const file of ctx.unitFiles()) {
1696
+ const rel = relTo2(ctx.unit.dir, ctx.rel(file), ctx);
1697
+ if (!rel) continue;
1698
+ const parsed = routeFromFile(rel);
1699
+ if (parsed) {
1700
+ routes.push({ ...parsed, file });
1701
+ routeFiles.add(file);
1702
+ }
1703
+ }
1704
+ const clientLiterals = [];
1705
+ for (const [file, pf] of ctx.graph.files) {
1706
+ if (routeFiles.has(file)) continue;
1707
+ for (const lit of pf.pathLiterals) clientLiterals.push({ value: lit.value, file });
1708
+ }
1709
+ const literalsMeaningful = clientLiterals.length >= CLIENT_LITERAL_THRESHOLD2;
1710
+ const findings = [];
1711
+ const seen = /* @__PURE__ */ new Set();
1712
+ for (const route of routes) {
1713
+ const key = `${route.method} ${route.routePath}`;
1714
+ if (seen.has(key)) continue;
1715
+ seen.add(key);
1716
+ const rel = ctx.rel(route.file);
1717
+ const finding = makeFinding(
1718
+ "dead-route",
1719
+ { kind: "route", method: route.method, path: route.routePath, framework: FRAMEWORK2 },
1720
+ { file: rel, line: 1 },
1721
+ ctx.unit.name,
1722
+ `${route.method} ${route.routePath} is a Next.js route file; static analysis cannot observe whether it is visited`
1723
+ );
1724
+ if (literalsMeaningful && !route.routePath.includes("[")) {
1725
+ const match = findPathMatch2(route.routePath, clientLiterals);
1726
+ if (match) {
1727
+ finding.evidence.push(
1728
+ evidenceFor(
1729
+ "static:client-path-match",
1730
+ `'${match.value}' in ${ctx.rel(match.file)} links to this route`
1731
+ )
1732
+ );
1733
+ } else {
1734
+ finding.evidence.push(
1735
+ evidenceFor(
1736
+ "static:no-client-path-match",
1737
+ `No <Link>/router/redirect literal among ${clientLiterals.length} in the repo matches this route`
1738
+ )
1739
+ );
1740
+ }
1741
+ } else if (route.routePath.includes("[")) {
1742
+ finding.evidence.push(
1743
+ evidenceFor(
1744
+ "static:dynamic-route-prefix",
1745
+ "Route has dynamic segments; client-link matching is unreliable"
1746
+ )
1747
+ );
1748
+ }
1749
+ findings.push(finding);
1750
+ }
1751
+ return { findings, counted: { routes: seen.size } };
1752
+ }
1753
+ };
1754
+ function routeFromFile(rel) {
1755
+ const m = /^(?:src\/)?(pages|app)\/(.+)\.(?:[jt]sx?)$/.exec(rel);
1756
+ if (!m) return void 0;
1757
+ const router = m[1];
1758
+ let rest = m[2];
1759
+ const segments = rest.split("/");
1760
+ const basename = segments[segments.length - 1];
1761
+ if (NON_ROUTE_BASENAMES.has(basename)) return void 0;
1762
+ if (router === "app") {
1763
+ if (basename !== "page" && basename !== "route") return void 0;
1764
+ segments.pop();
1765
+ const method = basename === "route" ? "ALL" : "GET";
1766
+ return { routePath: toRoutePath(segments), method };
1767
+ }
1768
+ if (basename === "index") segments.pop();
1769
+ const isApi = segments[0] === "api";
1770
+ return { routePath: toRoutePath(segments), method: isApi ? "ALL" : "GET" };
1771
+ }
1772
+ function toRoutePath(segments) {
1773
+ const kept = segments.filter((s) => !(s.startsWith("(") && s.endsWith(")")));
1774
+ return "/" + kept.join("/");
1775
+ }
1776
+ function relTo2(unitDir, repoRel, ctx) {
1777
+ const unitRel = ctx.rel(unitDir);
1778
+ if (unitRel === "." || unitRel === "") return repoRel;
1779
+ return repoRel.startsWith(unitRel + "/") ? repoRel.slice(unitRel.length + 1) : void 0;
1780
+ }
1781
+ function findPathMatch2(routePath, literals) {
1782
+ return literals.find(
1783
+ (l) => l.value === routePath || l.value.startsWith(routePath + "?") || l.value.startsWith(routePath + "#") || routePath !== "/" && l.value.startsWith(routePath + "/")
1784
+ );
1785
+ }
1786
+
1787
+ // ../core/src/evidence/dynamicImports.ts
1788
+ import path10 from "path";
1789
+
1790
+ // ../core/src/evidence/types.ts
1791
+ function addOnce(finding, evidence) {
1792
+ if (!finding.evidence.some((e) => e.signal === evidence.signal)) {
1793
+ finding.evidence.push(evidence);
1794
+ }
1795
+ }
1796
+
1797
+ // ../core/src/evidence/dynamicImports.ts
1798
+ var dynamicImports = {
1799
+ name: "dynamic-imports",
1800
+ provide(ctx, findings) {
1801
+ const targets = [];
1802
+ for (const [file, pf] of ctx.graph.files) {
1803
+ for (const pattern of pf.dynamicPatterns) {
1804
+ targets.push({
1805
+ dir: dynamicDir(file, pattern.prefix),
1806
+ sourceFile: file,
1807
+ line: pattern.line
1808
+ });
1809
+ }
1810
+ }
1811
+ if (targets.length === 0) return;
1812
+ for (const finding of findings) {
1813
+ if (finding.category === "unused-dependency") continue;
1814
+ const findingFile = ctx.abs(finding.location.file);
1815
+ const findingDir = dirnamePosix(findingFile);
1816
+ const exact = targets.find((t) => t.dir === findingDir);
1817
+ const under = exact ?? targets.find((t) => isUnder(t.dir, findingFile));
1818
+ if (!under) continue;
1819
+ addOnce(
1820
+ finding,
1821
+ evidenceFor(
1822
+ exact ? "static:dynamic-import-exact-dir" : "static:dynamic-import-in-dir",
1823
+ `Dynamic import/require at ${ctx.rel(under.sourceFile)}:${under.line} may load this file at runtime`,
1824
+ { file: ctx.rel(under.sourceFile), line: under.line }
1825
+ )
1826
+ );
1827
+ }
1828
+ }
1829
+ };
1830
+ function dynamicDir(containingFile, prefix) {
1831
+ const baseDir = dirnamePosix(containingFile);
1832
+ if (!prefix || !prefix.startsWith(".")) return baseDir;
1833
+ const resolved = toPosix(path10.resolve(baseDir, prefix));
1834
+ return prefix.endsWith("/") ? resolved : dirnamePosix(resolved);
1835
+ }
1836
+
1837
+ // ../core/src/evidence/stringDispatch.ts
1838
+ import ts5 from "typescript";
1839
+ var stringDispatch = {
1840
+ name: "string-dispatch",
1841
+ provide(ctx, findings) {
1842
+ const bySymbol = indexExportFindings(ctx, findings);
1843
+ if (bySymbol.size === 0) return;
1844
+ for (const [file, pf] of ctx.graph.files) {
1845
+ const importedLocals = /* @__PURE__ */ new Map();
1846
+ for (const imp of pf.imports) {
1847
+ if (!imp.resolvedFile) continue;
1848
+ for (const n of imp.names) {
1849
+ importedLocals.set(n.local, { file: imp.resolvedFile, imported: n.imported });
1850
+ }
1851
+ }
1852
+ if (importedLocals.size === 0) continue;
1853
+ const visit = (node) => {
1854
+ if (ts5.isObjectLiteralExpression(node) && node.properties.length >= 2) {
1855
+ for (const prop of node.properties) {
1856
+ let local;
1857
+ if (ts5.isShorthandPropertyAssignment(prop)) local = prop.name.text;
1858
+ else if (ts5.isPropertyAssignment(prop) && ts5.isIdentifier(prop.initializer)) {
1859
+ local = prop.initializer.text;
1860
+ }
1861
+ const ref = local ? importedLocals.get(local) : void 0;
1862
+ if (ref) {
1863
+ const finding = bySymbol.get(`${ctx.rel(ref.file)}#${ref.imported}`);
1864
+ if (finding) {
1865
+ addOnce(
1866
+ finding,
1867
+ evidenceFor(
1868
+ "static:string-dispatch",
1869
+ `'${ref.imported}' appears in a dispatch table in ${ctx.rel(file)}`
1870
+ )
1871
+ );
1872
+ }
1873
+ }
1874
+ }
1875
+ }
1876
+ ts5.forEachChild(node, visit);
1877
+ };
1878
+ ts5.forEachChild(pf.ast, visit);
1879
+ }
1880
+ }
1881
+ };
1882
+ function indexExportFindings(ctx, findings) {
1883
+ const index = /* @__PURE__ */ new Map();
1884
+ for (const f of findings) {
1885
+ if (f.category === "unused-export" && f.target.kind === "export") {
1886
+ index.set(`${f.target.file}#${f.target.symbol}`, f);
1887
+ }
1888
+ }
1889
+ return index;
1890
+ }
1891
+
1892
+ // ../core/src/evidence/packageScripts.ts
1893
+ var packageScripts = {
1894
+ name: "package-scripts",
1895
+ provide(ctx, findings) {
1896
+ for (const finding of findings) {
1897
+ if (finding.category !== "unused-dependency" || finding.target.kind !== "dependency")
1898
+ continue;
1899
+ const depName = finding.target.name;
1900
+ const unit = ctx.units.find((u) => u.name === finding.workspace) ?? ctx.units[0];
1901
+ const hit = scriptReferencing(unit, depName) ?? scriptReferencing(ctx.units[0], depName);
1902
+ if (hit) {
1903
+ addOnce(
1904
+ finding,
1905
+ evidenceFor(
1906
+ "static:script-reference",
1907
+ `Referenced by package.json script '${hit.script}': ${truncate(hit.command)}`
1908
+ )
1909
+ );
1910
+ }
1911
+ }
1912
+ }
1913
+ };
1914
+ function scriptReferencing(unit, depName) {
1915
+ const binName = depName.includes("/") ? depName.split("/").pop() : depName;
1916
+ const re = new RegExp(`(^|[\\s"'=/])${escapeRe(binName)}($|[\\s"'@])`);
1917
+ for (const [script, command] of Object.entries(unit.packageJson.scripts ?? {})) {
1918
+ if (command.includes(depName) || re.test(command)) return { script, command };
1919
+ }
1920
+ return void 0;
1921
+ }
1922
+ function escapeRe(s) {
1923
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1924
+ }
1925
+ function truncate(s) {
1926
+ return s.length > 60 ? s.slice(0, 57) + "\u2026" : s;
1927
+ }
1928
+
1929
+ // ../core/src/evidence/jobRegistration.ts
1930
+ import ts6 from "typescript";
1931
+ var JOB_PACKAGES = /* @__PURE__ */ new Set([
1932
+ "node-cron",
1933
+ "cron",
1934
+ "bull",
1935
+ "bullmq",
1936
+ "agenda",
1937
+ "bree",
1938
+ "node-schedule",
1939
+ "toad-scheduler"
1940
+ ]);
1941
+ var JOB_CALL_NAMES = /* @__PURE__ */ new Set(["schedule", "scheduleJob", "process", "add", "every"]);
1942
+ var JOB_CTOR_NAMES = /* @__PURE__ */ new Set(["Queue", "Worker", "CronJob", "Agenda", "Bree"]);
1943
+ var jobRegistration = {
1944
+ name: "job-registration",
1945
+ provide(ctx, findings) {
1946
+ const jobFiles = /* @__PURE__ */ new Map();
1947
+ for (const [file, pf] of ctx.graph.files) {
1948
+ const jobPkg = pf.imports.map((i) => i.resolvedPackage ?? packageNameOf(i.specifier)).find((p) => JOB_PACKAGES.has(p));
1949
+ if (jobPkg && registersJobs(pf.ast)) jobFiles.set(file, jobPkg);
1950
+ }
1951
+ if (jobFiles.size === 0) return;
1952
+ for (const finding of findings) {
1953
+ if (finding.category === "unused-dependency") continue;
1954
+ const pkg2 = jobFiles.get(ctx.abs(finding.location.file));
1955
+ if (pkg2) {
1956
+ addOnce(
1957
+ finding,
1958
+ evidenceFor(
1959
+ "static:job-registration",
1960
+ `File registers scheduled work via '${pkg2}' \u2014 it may run without ever being imported`
1961
+ )
1962
+ );
1963
+ }
1964
+ }
1965
+ }
1966
+ };
1967
+ function registersJobs(ast) {
1968
+ let found = false;
1969
+ const visit = (node) => {
1970
+ if (found) return;
1971
+ if (ts6.isCallExpression(node) && ts6.isPropertyAccessExpression(node.expression) && JOB_CALL_NAMES.has(node.expression.name.text)) {
1972
+ found = true;
1973
+ return;
1974
+ }
1975
+ if (ts6.isNewExpression(node)) {
1976
+ const callee = node.expression;
1977
+ const name = ts6.isIdentifier(callee) ? callee.text : ts6.isPropertyAccessExpression(callee) ? callee.name.text : void 0;
1978
+ if (name && JOB_CTOR_NAMES.has(name)) {
1979
+ found = true;
1980
+ return;
1981
+ }
1982
+ }
1983
+ ts6.forEachChild(node, visit);
1984
+ };
1985
+ ts6.forEachChild(ast, visit);
1986
+ return found;
1987
+ }
1988
+
1989
+ // ../core/src/evidence/comments.ts
1990
+ var PROXIMITY_LINES = 3;
1991
+ var comments = {
1992
+ name: "comments",
1993
+ provide(ctx, findings) {
1994
+ for (const finding of findings) {
1995
+ const pf = ctx.graph.files.get(ctx.abs(finding.location.file));
1996
+ if (!pf || pf.commentMarkers.length === 0) continue;
1997
+ for (const marker of pf.commentMarkers) {
1998
+ const near = Math.abs(marker.line - finding.location.line) <= PROXIMITY_LINES || // Whole-file findings: a marker anywhere in the first lines of the file.
1999
+ finding.category === "unreachable-file" && marker.line <= PROXIMITY_LINES + 1;
2000
+ if (!near) continue;
2001
+ addOnce(
2002
+ finding,
2003
+ evidenceFor(
2004
+ marker.type === "deprecated" ? "static:deprecated-jsdoc" : "static:removal-comment",
2005
+ `${marker.type === "deprecated" ? "Deprecation" : "Removal"} marker at line ${marker.line}: ${truncate2(marker.text)}`,
2006
+ { file: finding.location.file, line: marker.line }
2007
+ )
2008
+ );
2009
+ }
2010
+ }
2011
+ }
2012
+ };
2013
+ function truncate2(s) {
2014
+ return s.length > 70 ? s.slice(0, 67) + "\u2026" : s;
2015
+ }
2016
+
2017
+ // ../core/src/evidence/manifestReference.ts
2018
+ var manifestReference = {
2019
+ name: "manifest-reference",
2020
+ provide(ctx, findings) {
2021
+ if (ctx.manifestCallers.length === 0) return;
2022
+ const byFile = new Map(ctx.manifestCallers.map((c) => [c.file, c]));
2023
+ for (const finding of findings) {
2024
+ if (finding.category === "unused-dependency") continue;
2025
+ const caller = byFile.get(ctx.abs(finding.location.file));
2026
+ if (caller) {
2027
+ addOnce(
2028
+ finding,
2029
+ evidenceFor("static:manifest-reference", `${caller.detail} (${caller.manifest})`)
2030
+ );
2031
+ }
2032
+ }
2033
+ }
2034
+ };
2035
+
2036
+ // ../core/src/scoring/score.ts
2037
+ function levelFor(score) {
2038
+ if (score >= 80) return "high";
2039
+ if (score >= 50) return "medium";
2040
+ return "low";
2041
+ }
2042
+ function scoreFinding(category, evidence) {
2043
+ const raw = evidence.reduce((sum, e) => sum + e.weight, 0);
2044
+ const allStatic = evidence.every((e) => e.source === "static");
2045
+ const cap = allStatic ? STATIC_CAPS[category] : 100;
2046
+ const score = Math.max(0, Math.min(cap, Math.round(raw)));
2047
+ const result = { score, level: levelFor(score) };
2048
+ if (allStatic && raw > cap) {
2049
+ result.capped = { at: cap, reason: STATIC_CAP_REASON };
2050
+ }
2051
+ return result;
2052
+ }
2053
+
2054
+ // ../core/src/index.ts
2055
+ var DEFAULT_INCLUDE = ["**/*.{js,jsx,ts,tsx,mjs,cjs,mts,cts}"];
2056
+ var DEFAULT_EXCLUDE = [
2057
+ "**/node_modules/**",
2058
+ "**/dist/**",
2059
+ "**/build/**",
2060
+ "**/out/**",
2061
+ "**/coverage/**",
2062
+ "**/.git/**",
2063
+ "**/.next/**",
2064
+ "**/vendor/**",
2065
+ "**/*.d.ts",
2066
+ "**/*.min.js"
2067
+ ];
2068
+ var ALL_CATEGORIES = [
2069
+ "dead-route",
2070
+ "unused-export",
2071
+ "unused-dependency",
2072
+ "unreachable-file"
2073
+ ];
2074
+ var DETECTORS = [
2075
+ expressRoutes,
2076
+ nestRoutes,
2077
+ nextRoutes,
2078
+ unreachableFiles,
2079
+ unusedExports,
2080
+ unusedDeps
2081
+ ];
2082
+ var FRAMEWORK_DETECTORS = {
2083
+ express: expressRoutes,
2084
+ fastify: expressRoutes,
2085
+ nest: nestRoutes,
2086
+ next: nextRoutes
2087
+ };
2088
+ var PROVIDERS = [
2089
+ dynamicImports,
2090
+ stringDispatch,
2091
+ packageScripts,
2092
+ jobRegistration,
2093
+ manifestReference,
2094
+ comments
2095
+ ];
2096
+ async function scan(options) {
2097
+ const startedAt = Date.now();
2098
+ const root = absPosix(options.path);
2099
+ if (!fs8.existsSync(root) || !fs8.statSync(root).isDirectory()) {
2100
+ throw new ScanError("INVALID_PATH", `Not a directory: ${options.path}`);
2101
+ }
2102
+ const warnings = [];
2103
+ const { units: allUnits, warnings: wsWarnings } = await discoverWorkspaces(root);
2104
+ warnings.push(...wsWarnings);
2105
+ const units = options.workspaces && options.workspaces.length > 0 ? allUnits.filter((u, i) => i === 0 || options.workspaces.includes(u.name)) : allUnits;
2106
+ const files = await glob2([...DEFAULT_INCLUDE, ...options.include ?? []], {
2107
+ cwd: root,
2108
+ ignore: [...DEFAULT_EXCLUDE, ...options.exclude ?? []],
2109
+ absolute: true,
2110
+ dot: true
2111
+ });
2112
+ if (files.length === 0) {
2113
+ throw new ScanError("NO_SOURCE_FILES", `No JS/TS source files found under ${options.path}`);
2114
+ }
2115
+ const graph = new ModuleGraph();
2116
+ for (const file of files.map(toPosix).sort()) {
2117
+ try {
2118
+ graph.addFile(parseFile(file, fs8.readFileSync(file, "utf8")));
2119
+ } catch {
2120
+ warnings.push(`Could not parse ${relPosix(root, file)}; it is excluded from analysis.`);
2121
+ }
2122
+ }
2123
+ const scannedFiles = new Set(graph.files.keys());
2124
+ const wsEntries = units.filter((u) => u.name !== ".").map((u) => ({ name: u.name, dir: u.dir, entryFile: guessUnitEntry(u, scannedFiles) }));
2125
+ graph.link(new Resolver(root, wsEntries, scannedFiles, warnings));
2126
+ const manifestCallers = await collectManifestCallers(root, scannedFiles, warnings);
2127
+ const entriesByUnit = /* @__PURE__ */ new Map();
2128
+ for (const unit of units) {
2129
+ entriesByUnit.set(unit, discoverEntryPoints(unit, scannedFiles, warnings));
2130
+ }
2131
+ const rootUnit = units[0];
2132
+ for (const caller of manifestCallers) {
2133
+ pushEntry(entriesByUnit, unitForFile(units, caller.file), {
2134
+ file: caller.file,
2135
+ reason: "manifest",
2136
+ detail: caller.detail
2137
+ });
2138
+ }
2139
+ for (const ref of options.entries ?? []) {
2140
+ const resolved = resolveEntryRef(root, ref, scannedFiles) ?? tryFile(path11.resolve(root, ref));
2141
+ if (resolved) {
2142
+ pushEntry(entriesByUnit, unitForFile(units, toPosix(resolved)), {
2143
+ file: toPosix(resolved),
2144
+ reason: "cli-flag",
2145
+ detail: "--entry"
2146
+ });
2147
+ } else {
2148
+ warnings.push(`--entry '${ref}' does not match a scanned file.`);
2149
+ }
2150
+ }
2151
+ for (const unit of units) {
2152
+ const eps = entriesByUnit.get(unit);
2153
+ if (!eps.some((e) => e.tag !== "test" && e.reason !== "config")) {
2154
+ const guess = guessUnitEntry(unit, scannedFiles);
2155
+ if (guess)
2156
+ pushEntry(entriesByUnit, unit, { file: guess, reason: "main", detail: "convention" });
2157
+ }
2158
+ }
2159
+ const allEntryPoints = [...entriesByUnit.values()].flat();
2160
+ const reach = {
2161
+ all: graph.reachable(allEntryPoints.map((e) => e.file)),
2162
+ prod: graph.reachable(allEntryPoints.filter((e) => e.tag !== "test").map((e) => e.file))
2163
+ };
2164
+ const resolvedOptions = {
2165
+ categories: new Set(options.categories?.length ? options.categories : ALL_CATEGORIES),
2166
+ frameworks: new Set(options.frameworks ?? [])
2167
+ };
2168
+ const fileOwner = /* @__PURE__ */ new Map();
2169
+ for (const file of scannedFiles) fileOwner.set(file, unitForFile(units, file));
2170
+ let findings = [];
2171
+ let routesTotal = 0;
2172
+ for (const unit of units) {
2173
+ const ctx = {
2174
+ root,
2175
+ units,
2176
+ unit,
2177
+ graph,
2178
+ entryPoints: allEntryPoints,
2179
+ unitEntryPoints: entriesByUnit.get(unit) ?? [],
2180
+ manifestCallers,
2181
+ reach,
2182
+ options: resolvedOptions,
2183
+ warnings,
2184
+ rel: (file) => relPosix(root, file),
2185
+ unitFiles: () => [...scannedFiles].filter((f) => fileOwner.get(f) === unit)
2186
+ };
2187
+ const forcedDetectors = new Set(
2188
+ [...resolvedOptions.frameworks].map((f) => FRAMEWORK_DETECTORS[f]).filter(Boolean)
2189
+ );
2190
+ for (const detector of DETECTORS) {
2191
+ if (!forcedDetectors.has(detector) && !detector.detect(unit)) continue;
2192
+ const result = detector.extract(ctx);
2193
+ findings.push(...result.findings);
2194
+ routesTotal += result.counted?.routes ?? 0;
2195
+ }
2196
+ }
2197
+ const providerCtx = {
2198
+ root,
2199
+ units,
2200
+ graph,
2201
+ manifestCallers,
2202
+ entryPoints: allEntryPoints,
2203
+ warnings,
2204
+ rel: (file) => relPosix(root, file),
2205
+ abs: (relPath) => `${root}/${relPath}`
2206
+ };
2207
+ for (const provider of PROVIDERS) {
2208
+ provider.provide(providerCtx, findings);
2209
+ }
2210
+ for (const finding of findings) {
2211
+ finding.confidence = scoreFinding(finding.category, finding.evidence);
2212
+ }
2213
+ findings = findings.filter((f) => !(f.category === "dead-route" && f.evidence.length === 1));
2214
+ findings = findings.filter((f) => resolvedOptions.categories.has(f.category));
2215
+ const categoryOrder = new Map(ALL_CATEGORIES.map((c, i) => [c, i]));
2216
+ findings.sort(
2217
+ (a, b) => categoryOrder.get(a.category) - categoryOrder.get(b.category) || b.confidence.score - a.confidence.score || a.location.file.localeCompare(b.location.file) || a.location.line - b.location.line
2218
+ );
2219
+ return {
2220
+ schemaVersion: 1,
2221
+ tool: { name: "deadwood", version: options.toolVersion ?? "0.0.0" },
2222
+ scannedAt: (/* @__PURE__ */ new Date()).toISOString(),
2223
+ root,
2224
+ workspaces: units.map((u) => u.name),
2225
+ findings,
2226
+ stats: computeStats(graph, units, findings, routesTotal, Date.now() - startedAt),
2227
+ warnings: [...new Set(warnings)]
2228
+ };
2229
+ }
2230
+ function pushEntry(map, unit, entry) {
2231
+ const list = map.get(unit);
2232
+ if (!list) {
2233
+ map.set(unit, [entry]);
2234
+ } else if (!list.some((e) => e.file === entry.file)) {
2235
+ list.push(entry);
2236
+ }
2237
+ }
2238
+ function guessUnitEntry(unit, scannedFiles) {
2239
+ if (typeof unit.packageJson.main === "string") {
2240
+ const mapped = resolveEntryRef(unit.dir, unit.packageJson.main, scannedFiles);
2241
+ if (mapped) return mapped;
2242
+ }
2243
+ for (const candidate of ["src/index", "index", "src/main", "src/app", "app", "server", "main"]) {
2244
+ const hit = tryFile(path11.join(unit.dir, candidate));
2245
+ if (hit && scannedFiles.has(toPosix(hit))) return toPosix(hit);
2246
+ }
2247
+ return void 0;
2248
+ }
2249
+ async function collectManifestCallers(root, scannedFiles, warnings) {
2250
+ const callers = [];
2251
+ const manifestFiles = await glob2(
2252
+ [
2253
+ ".github/workflows/*.{yml,yaml}",
2254
+ "**/*.{yml,yaml}",
2255
+ "**/Dockerfile*",
2256
+ "**/crontab",
2257
+ "**/*.cron",
2258
+ "**/cron.d/**",
2259
+ "webpack.config.{js,cjs,mjs,ts}"
2260
+ ],
2261
+ { cwd: root, ignore: ["**/node_modules/**", "**/dist/**", "**/.git/**"], dot: true }
2262
+ );
2263
+ for (const rel of manifestFiles.map(toPosix).sort()) {
2264
+ try {
2265
+ if (rel.startsWith(".github/workflows/")) {
2266
+ callers.push(...parseGithubWorkflow(root, rel, warnings));
2267
+ } else if (/(^|\/)serverless\.(yml|yaml)$/.test(rel)) {
2268
+ callers.push(...parseServerless(root, rel, warnings));
2269
+ } else if (/\.(yml|yaml)$/.test(rel)) {
2270
+ callers.push(...parseK8sManifest(root, rel, warnings));
2271
+ } else if (/(^|\/)Dockerfile/.test(rel)) {
2272
+ callers.push(...parseDockerfile(root, rel));
2273
+ } else if (isCrontabFile(rel)) {
2274
+ callers.push(...parseCrontab(root, rel));
2275
+ } else if (/(^|\/)webpack\.config\./.test(rel)) {
2276
+ callers.push(...parseWebpackConfig(root, rel));
2277
+ }
2278
+ } catch {
2279
+ warnings.push(`Could not read manifest ${rel}; its callers are unknown.`);
2280
+ }
2281
+ }
2282
+ return callers.filter((c) => scannedFiles.has(c.file));
2283
+ }
2284
+ function computeStats(graph, units, findings, routesTotal, durationMs) {
2285
+ const flagged = (category) => findings.filter((f) => f.category === category && f.confidence.score >= 50).length;
2286
+ let exportsTotal = 0;
2287
+ for (const pf of graph.files.values()) {
2288
+ exportsTotal += new Set(pf.exports.map((e) => e.name)).size;
2289
+ }
2290
+ let depsTotal = 0;
2291
+ for (const u of units) {
2292
+ depsTotal += Object.keys(u.packageJson.dependencies ?? {}).length + Object.keys(u.packageJson.devDependencies ?? {}).length;
2293
+ }
2294
+ return {
2295
+ filesScanned: graph.files.size,
2296
+ durationMs,
2297
+ routes: { total: routesTotal, flagged: flagged("dead-route") },
2298
+ exports: { total: exportsTotal, flagged: flagged("unused-export") },
2299
+ dependencies: { total: depsTotal, flagged: flagged("unused-dependency") },
2300
+ files: { total: graph.files.size, flagged: flagged("unreachable-file") }
2301
+ };
2302
+ }
2303
+
2304
+ // src/failOn.ts
2305
+ var LEVEL_RANK = { low: 0, medium: 1, high: 2 };
2306
+ function parseFailOn(spec) {
2307
+ const m = /^(high|medium|low)(?::(\d+))?$/.exec(spec);
2308
+ if (!m) {
2309
+ throw new Error(`Invalid --fail-on '${spec}' (expected e.g. 'high', 'medium', 'high:5')`);
2310
+ }
2311
+ return { level: m[1], min: m[2] ? Number(m[2]) : 1 };
2312
+ }
2313
+ function evaluateFailOn(findings, spec) {
2314
+ const count = findings.filter(
2315
+ (f) => LEVEL_RANK[f.confidence.level] >= LEVEL_RANK[spec.level]
2316
+ ).length;
2317
+ return { count, tripped: count >= spec.min };
2318
+ }
2319
+
2320
+ // src/reporters/json.ts
2321
+ function renderJson(result) {
2322
+ return JSON.stringify(result, null, 2) + "\n";
2323
+ }
2324
+
2325
+ // src/reporters/md.ts
2326
+ var CATEGORY_TITLES = {
2327
+ "dead-route": "Routes that appear dead",
2328
+ "unused-export": "Exports that appear unused",
2329
+ "unused-dependency": "Dependencies that appear unused",
2330
+ "unreachable-file": "Files that appear unreachable"
2331
+ };
2332
+ function renderMd(result) {
2333
+ const lines = [];
2334
+ const { stats } = result;
2335
+ lines.push(`## \u2620 deadwood report`);
2336
+ lines.push("");
2337
+ if (stats.routes.total > 0) {
2338
+ const pct = Math.round(stats.routes.flagged / stats.routes.total * 100);
2339
+ lines.push(
2340
+ `**${pct}% of routes appear dead** (${stats.routes.flagged} of ${stats.routes.total})`
2341
+ );
2342
+ }
2343
+ const summary = [];
2344
+ if (stats.exports.total > 0) summary.push(`${stats.exports.flagged} unused exports`);
2345
+ if (stats.dependencies.total > 0)
2346
+ summary.push(`${stats.dependencies.flagged} unused dependencies`);
2347
+ if (stats.files.total > 0) summary.push(`${stats.files.flagged} unreachable files`);
2348
+ if (summary.length > 0)
2349
+ lines.push(`${summary.join(" \xB7 ")} \u2014 ${stats.filesScanned} files scanned`);
2350
+ lines.push("");
2351
+ for (const category of Object.keys(CATEGORY_TITLES)) {
2352
+ const group = result.findings.filter((f) => f.category === category);
2353
+ if (group.length === 0) continue;
2354
+ lines.push(`### ${CATEGORY_TITLES[category]} (${group.length})`);
2355
+ lines.push("");
2356
+ lines.push("| Confidence | Target | Location | Top evidence |");
2357
+ lines.push("|---|---|---|---|");
2358
+ for (const f of group) {
2359
+ const top = [...f.evidence].sort((a, b) => Math.abs(b.weight) - Math.abs(a.weight))[0];
2360
+ lines.push(
2361
+ `| ${f.confidence.score} ${f.confidence.level} | ${escapeMd(describeTarget(f.target))} | \`${f.location.file}:${f.location.line}\` | ${escapeMd(top?.detail ?? "")} |`
2362
+ );
2363
+ }
2364
+ lines.push("");
2365
+ }
2366
+ if (result.findings.some((f) => f.confidence.capped)) {
2367
+ lines.push(
2368
+ `> \u26A0 Confidence is capped for static-only findings \u2014 no runtime evidence. Connect production telemetry to confirm.`
2369
+ );
2370
+ lines.push("");
2371
+ }
2372
+ for (const warning of result.warnings) {
2373
+ lines.push(`> \u26A0 ${escapeMd(warning)}`);
2374
+ }
2375
+ return lines.join("\n") + "\n";
2376
+ }
2377
+ function escapeMd(s) {
2378
+ return s.replace(/\|/g, "\\|");
2379
+ }
2380
+
2381
+ // src/reporters/pretty.ts
2382
+ import pc from "picocolors";
2383
+ var CATEGORY_TITLES2 = {
2384
+ "dead-route": "ROUTES THAT APPEAR DEAD",
2385
+ "unused-export": "EXPORTS THAT APPEAR UNUSED",
2386
+ "unused-dependency": "DEPENDENCIES THAT APPEAR UNUSED",
2387
+ "unreachable-file": "FILES THAT APPEAR UNREACHABLE"
2388
+ };
2389
+ var RULE_WIDTH = 72;
2390
+ function renderPretty(result, useColor) {
2391
+ const c = pc.createColors(useColor);
2392
+ const lines = [];
2393
+ const { stats } = result;
2394
+ lines.push("");
2395
+ lines.push(` ${c.bold("\u2620 deadwood")} ${c.dim(`v${result.tool.version}`)}`);
2396
+ const meta = [
2397
+ `${stats.filesScanned} files scanned`,
2398
+ formatDuration(stats.durationMs),
2399
+ result.workspaces.length > 1 ? `${result.workspaces.length} workspaces` : void 0
2400
+ ].filter(Boolean);
2401
+ lines.push(` ${c.dim(meta.join(" \xB7 "))}`);
2402
+ lines.push(` ${c.dim("\u2500".repeat(RULE_WIDTH))}`);
2403
+ lines.push("");
2404
+ pushShock(lines, c, stats.routes, "of your routes appear dead", true);
2405
+ pushShock(lines, c, stats.exports, "exports appear unused");
2406
+ pushShock(lines, c, stats.dependencies, "dependencies appear unused");
2407
+ pushShock(lines, c, stats.files, "files appear unreachable");
2408
+ if (result.findings.length === 0) {
2409
+ lines.push(
2410
+ ` ${c.green("\u2713")} No dead code findings. Either pristine \u2014 or hiding from static analysis.`
2411
+ );
2412
+ }
2413
+ for (const category of Object.keys(CATEGORY_TITLES2)) {
2414
+ const group = result.findings.filter((f) => f.category === category);
2415
+ if (group.length === 0) continue;
2416
+ const actionable = group.filter((f) => f.confidence.score >= 50).length;
2417
+ const counts = actionable === group.length ? `${group.length}` : `${group.length} \xB7 ${actionable} actionable`;
2418
+ lines.push("");
2419
+ lines.push(sectionRule(c, CATEGORY_TITLES2[category], counts));
2420
+ lines.push("");
2421
+ for (const finding of group) {
2422
+ const lv = finding.confidence.level;
2423
+ lines.push(
2424
+ ` ${dot(c, lv)} ${scoreLabel(c, finding.confidence.score, lv)} ${c.dim("\u2502")} ${c.bold(
2425
+ describeTarget(finding.target)
2426
+ )}`
2427
+ );
2428
+ lines.push(
2429
+ ` ${c.dim("\u2502")} ${c.dim(`${finding.location.file}:${finding.location.line}`)}`
2430
+ );
2431
+ const top = topEvidence(finding);
2432
+ for (const ev of top) {
2433
+ const mark = ev.direction === "kill" ? c.red("\u25AA") : c.yellow("\u25AB");
2434
+ lines.push(` ${c.dim("\u2502")} ${mark} ${c.dim(ev.detail)}`);
2435
+ }
2436
+ const more = finding.evidence.length - top.length;
2437
+ if (more > 0) {
2438
+ lines.push(
2439
+ ` ${c.dim("\u2502")} ${c.dim(`\u2026 ${more} more signal${more > 1 ? "s" : ""} (see --json)`)}`
2440
+ );
2441
+ }
2442
+ lines.push("");
2443
+ }
2444
+ }
2445
+ lines.push(` ${c.dim("\u2500".repeat(RULE_WIDTH))}`);
2446
+ const anyStaticOnly = result.findings.some((f) => f.evidence.every((e) => e.source === "static"));
2447
+ if (anyStaticOnly) {
2448
+ lines.push(
2449
+ ` ${c.yellow("\u26A0")} Confidence is capped for static-only findings ${c.dim("(routes 70, others 85)")} \u2014`
2450
+ );
2451
+ lines.push(` no runtime evidence. Connect production telemetry to confirm.`);
2452
+ }
2453
+ if (result.findings.some((f) => f.confidence.score < 50)) {
2454
+ lines.push(
2455
+ ` ${c.dim("\u2139")} ${c.dim("actionable = medium confidence or higher; headline counts only these")}`
2456
+ );
2457
+ }
2458
+ for (const warning of result.warnings) {
2459
+ lines.push(` ${c.yellow("\u26A0")} ${c.dim(warning)}`);
2460
+ }
2461
+ lines.push("");
2462
+ lines.push(
2463
+ ` ${c.dim("\u2192 --min-confidence medium \xB7 --json for machines \xB7 --fail-on high for CI")}`
2464
+ );
2465
+ lines.push("");
2466
+ return lines.join("\n");
2467
+ }
2468
+ function pushShock(lines, c, stat, label, withPercent = false) {
2469
+ if (stat.total === 0) return;
2470
+ const skull = stat.flagged > 0 ? c.red("\u2620") : c.green("\u2713");
2471
+ if (withPercent) {
2472
+ const pct = Math.round(stat.flagged / stat.total * 100);
2473
+ lines.push(
2474
+ ` ${skull} ${c.bold(`${pct}%`)} ${label} ${c.dim(`(${stat.flagged} of ${stat.total})`)}`
2475
+ );
2476
+ } else {
2477
+ lines.push(` ${skull} ${c.bold(String(stat.flagged))} ${c.dim("of")} ${stat.total} ${label}`);
2478
+ }
2479
+ }
2480
+ function sectionRule(c, title, counts) {
2481
+ const fill = Math.max(2, RULE_WIDTH - title.length - counts.length - 8);
2482
+ return ` ${c.dim("\u2500\u2500")} ${c.bold(title)} ${c.dim("\u2500".repeat(fill))} ${c.dim(counts)}`;
2483
+ }
2484
+ function dot(c, level) {
2485
+ if (level === "high") return c.red("\u25CF");
2486
+ if (level === "medium") return c.yellow("\u25CF");
2487
+ return c.dim("\u25CB");
2488
+ }
2489
+ function scoreLabel(c, score, level) {
2490
+ const text = `${String(score).padStart(3)} ${level.padEnd(6)}`;
2491
+ if (level === "high") return c.red(c.bold(text));
2492
+ if (level === "medium") return c.yellow(text);
2493
+ return c.dim(text);
2494
+ }
2495
+ function topEvidence(finding) {
2496
+ return [...finding.evidence].sort((a, b) => Math.abs(b.weight) - Math.abs(a.weight)).slice(0, 2);
2497
+ }
2498
+ function formatDuration(ms) {
2499
+ return ms < 1e3 ? `${ms}ms` : `${(ms / 1e3).toFixed(1)}s`;
2500
+ }
2501
+
2502
+ // src/index.ts
2503
+ var pkg = createRequire(import.meta.url)("../package.json");
2504
+ var CATEGORIES = [
2505
+ "dead-route",
2506
+ "unused-export",
2507
+ "unused-dependency",
2508
+ "unreachable-file"
2509
+ ];
2510
+ var LEVEL_RANK2 = { low: 0, medium: 1, high: 2 };
2511
+ var program = new Command();
2512
+ program.name("deadwood").description("Find dead code and prove it \u2014 scans a JS/TS repo and prints a shock report").version(pkg.version).argument("[path]", "directory to scan", ".").option("--json", "machine-readable ScanResult on stdout").option("--md", "markdown report (PR comments / Slack)").option("--fail-on <spec>", "CI gate: 'high', 'medium', or 'high:5' (exit 1 when tripped)").option("--include <glob...>", "extra include globs").option("--exclude <glob...>", "extra exclude globs").addOption(new Option("--category <category...>", "limit findings").choices(CATEGORIES)).option("--workspace <name...>", "limit monorepo scan to these packages").addOption(
2513
+ new Option("--framework <framework...>", "force route framework detection").choices([
2514
+ "express",
2515
+ "fastify",
2516
+ "nest",
2517
+ "next"
2518
+ ])
2519
+ ).option("--entry <file...>", "extra entry points (false-positive escape hatch)").addOption(
2520
+ new Option("--min-confidence <level>", "hide findings below this level").choices(["low", "medium", "high"]).default("low")
2521
+ ).option("--no-color", "disable colors").action(async (path12, opts) => {
2522
+ await run(path12, opts);
2523
+ });
2524
+ async function run(path12, opts) {
2525
+ if (opts.json && opts.md) {
2526
+ process.stderr.write("error: --json and --md are mutually exclusive\n");
2527
+ process.exitCode = 2;
2528
+ return;
2529
+ }
2530
+ const failOnSpec = (() => {
2531
+ try {
2532
+ return opts.failOn ? parseFailOn(opts.failOn) : void 0;
2533
+ } catch (err) {
2534
+ process.stderr.write(`error: ${err.message}
2535
+ `);
2536
+ process.exitCode = 2;
2537
+ return null;
2538
+ }
2539
+ })();
2540
+ if (failOnSpec === null) return;
2541
+ const machineOutput = Boolean(opts.json || opts.md);
2542
+ const spinner = ora({
2543
+ text: "scanning\u2026",
2544
+ stream: process.stderr,
2545
+ isEnabled: !machineOutput && process.stderr.isTTY === true
2546
+ });
2547
+ try {
2548
+ spinner.start();
2549
+ const result = await scan({
2550
+ path: path12,
2551
+ include: opts.include,
2552
+ exclude: opts.exclude,
2553
+ categories: opts.category,
2554
+ frameworks: opts.framework,
2555
+ entries: opts.entry,
2556
+ workspaces: opts.workspace,
2557
+ toolVersion: pkg.version
2558
+ });
2559
+ spinner.stop();
2560
+ const gate = failOnSpec ? evaluateFailOn(result.findings, failOnSpec) : void 0;
2561
+ const displayed = filterForDisplay(result, opts.minConfidence);
2562
+ const useColor = opts.color && pc2.isColorSupported && process.stdout.isTTY === true;
2563
+ const output = opts.json ? renderJson(displayed) : opts.md ? renderMd(displayed) : renderPretty(displayed, useColor);
2564
+ process.stdout.write(output);
2565
+ if (opts.json) {
2566
+ for (const warning of result.warnings) process.stderr.write(`warning: ${warning}
2567
+ `);
2568
+ }
2569
+ if (gate?.tripped) {
2570
+ process.stderr.write(
2571
+ `deadwood: --fail-on ${opts.failOn} tripped (${gate.count} finding${gate.count === 1 ? "" : "s"} at/above '${failOnSpec.level}')
2572
+ `
2573
+ );
2574
+ process.exitCode = 1;
2575
+ }
2576
+ } catch (err) {
2577
+ spinner.stop();
2578
+ if (err instanceof ScanError) {
2579
+ process.stderr.write(`error (${err.code}): ${err.message}
2580
+ `);
2581
+ } else {
2582
+ process.stderr.write(
2583
+ `error: ${err instanceof Error ? err.stack ?? err.message : String(err)}
2584
+ `
2585
+ );
2586
+ }
2587
+ process.exitCode = 2;
2588
+ }
2589
+ }
2590
+ function filterForDisplay(result, minConfidence) {
2591
+ if (minConfidence === "low") return result;
2592
+ const min = LEVEL_RANK2[minConfidence];
2593
+ const findings = result.findings.filter((f) => LEVEL_RANK2[f.confidence.level] >= min);
2594
+ return { ...result, findings };
2595
+ }
2596
+ await program.parseAsync(process.argv);
2597
+ //# sourceMappingURL=index.js.map