@dailephd/my-dev-kit-lab 0.2.2 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -7
- package/dist/scripts/audits/runAudit.js +102 -0
- package/dist/src/audits/codeRot/codeRotDetectors.js +17 -0
- package/dist/src/audits/codeRot/detectors/architectureDriftDetector.js +271 -0
- package/dist/src/audits/codeRot/detectors/crossPlatformRotDetector.js +214 -0
- package/dist/src/audits/codeRot/detectors/deadCodeCandidateDetector.js +377 -0
- package/dist/src/audits/codeRot/detectors/dependencyEnvironmentRotDetector.js +244 -0
- package/dist/src/audits/codeRot/detectors/docsCodeMismatchDetector.js +219 -0
- package/dist/src/audits/codeRot/detectors/duplicateImplementationDetector.js +350 -0
- package/dist/src/audits/codeRot/detectors/packageReleaseRotDetector.js +325 -0
- package/dist/src/audits/codeRot/detectors/securityValidationAssumptionRotDetector.js +385 -0
- package/dist/src/audits/codeRot/detectors/staleCommandReferenceDetector.js +135 -0
- package/dist/src/audits/codeRot/detectors/testRotDetector.js +410 -0
- package/dist/src/audits/codeRot/utils/boundedRead.js +23 -0
- package/dist/src/audits/codeRot/utils/docClaimPatterns.js +62 -0
- package/dist/src/audits/codeRot/utils/docCommandReferences.js +64 -0
- package/dist/src/audits/codeRot/utils/filePatternUtils.js +79 -0
- package/dist/src/audits/codeRot/utils/issueFactories.js +38 -0
- package/dist/src/audits/codeRot/utils/sourceFactsLookup.js +53 -0
- package/dist/src/audits/codeRot/utils/textLines.js +12 -0
- package/dist/src/audits/core/auditConfig.js +125 -0
- package/dist/src/audits/core/auditExitCode.js +75 -0
- package/dist/src/audits/core/auditIssue.js +20 -0
- package/dist/src/audits/core/auditRegistry.js +40 -0
- package/dist/src/audits/core/auditRunner.js +74 -0
- package/dist/src/audits/core/auditTarget.js +19 -0
- package/dist/src/audits/core/auditTypes.js +30 -0
- package/dist/src/audits/core/collectSourceFacts.js +142 -0
- package/dist/src/audits/core/languageAnalyzerRegistry.js +33 -0
- package/dist/src/audits/core/projectInventory.js +458 -0
- package/dist/src/audits/core/sourceFacts.js +17 -0
- package/dist/src/audits/core/sourceOfTruth.js +339 -0
- package/dist/src/audits/core/textLines.js +24 -0
- package/dist/src/audits/core/typescriptJavaScriptAnalyzer.js +299 -0
- package/dist/src/audits/report/auditReportModel.js +253 -0
- package/dist/src/audits/report/auditReportPaths.js +13 -0
- package/dist/src/audits/report/renderAuditJsonReport.js +30 -0
- package/dist/src/audits/report/renderAuditTextReport.js +181 -0
- package/dist/src/audits/report/sanitizeAuditText.js +39 -0
- package/dist/src/audits/report/writeAuditReports.js +27 -0
- package/dist/src/core/resolveCommand.js +53 -2
- package/package.json +10 -6
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { readBoundedFileText } from "../utils/boundedRead.js";
|
|
2
|
+
import { deduplicateIssuesById, makeCodeRotIssue } from "../utils/issueFactories.js";
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// v0.3.0 Batch 4 — cross-platform rot detector.
|
|
5
|
+
//
|
|
6
|
+
// Read-only with respect to the target project throughout -- never
|
|
7
|
+
// modifies scripts or source. Bounded (per-file MAX_READ_BYTES skip).
|
|
8
|
+
//
|
|
9
|
+
// Simplification made after self-testing against this repo's own source:
|
|
10
|
+
// the spec's ".split(\"/\")" raw-path-splitting sub-check was DROPPED. This
|
|
11
|
+
// repo's own project inventory (src/audits/core/projectInventory.ts)
|
|
12
|
+
// guarantees relativePath values are always forward-slash normalized, so
|
|
13
|
+
// splitting an already-normalized relativePath on "/" (as several of this
|
|
14
|
+
// batch's own new detector files legitimately do, e.g.
|
|
15
|
+
// filePatternUtils.ts's firstTwoPathSegments()) is the correct, intentional
|
|
16
|
+
// pattern here, not a cross-platform bug -- a bare regex cannot distinguish
|
|
17
|
+
// that from a genuinely unsafe raw OS path split without semantic analysis.
|
|
18
|
+
// The much narrower, high-signal ".split(\"\\\\\")" (raw double-backslash
|
|
19
|
+
// Windows-style split) sub-check is kept, since that pattern has near-zero
|
|
20
|
+
// legitimate use in this codebase.
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
const DETECTOR_ID = "cross-platform-rot";
|
|
23
|
+
const MAX_READ_BYTES = 200_000;
|
|
24
|
+
const WINDOWS_MENTION_PATTERN = /\bWindows\b/;
|
|
25
|
+
const POSIX_ONLY_BINARY_PATTERN = /(^|&&)\s*(rm -rf|cp -r|mv |grep |sed |cat |find \.)/;
|
|
26
|
+
const SPLIT_NEWLINE_LITERAL_PATTERN = /\.split\(\s*["']\\n["']\s*\)/g;
|
|
27
|
+
const RAW_BACKSLASH_SPLIT_PATTERN = /\.split\(\s*["']\\\\["']\s*\)/g;
|
|
28
|
+
const SHELL_TRUE_PATTERN = /shell:\s*true/;
|
|
29
|
+
const EXEC_SPAWN_CALL_PATTERN = /\b(exec|spawn|execFile\w*|spawnSync|execSync|execFileSync)\s*\(/;
|
|
30
|
+
export const CROSS_PLATFORM_ROT_DETECTOR = {
|
|
31
|
+
id: DETECTOR_ID,
|
|
32
|
+
auditType: "code-rot",
|
|
33
|
+
title: "Cross-platform rot",
|
|
34
|
+
description: "Detects POSIX-only shell syntax in npm scripts when Windows support is claimed, raw .split(\"\\n\") usage outside the canonical splitLines() helper, raw backslash path splitting, and exec/spawn calls with shell: true.",
|
|
35
|
+
supportedIncludeAreas: ["cli", "package", "docs", "tests"],
|
|
36
|
+
shouldSkip: (ctx) => {
|
|
37
|
+
if (!ctx.config.include.includes("cli") && !ctx.config.include.includes("package")) {
|
|
38
|
+
return { skip: true, reason: "--include selects neither cli nor package; this detector's checks are script/source-based." };
|
|
39
|
+
}
|
|
40
|
+
return { skip: false };
|
|
41
|
+
},
|
|
42
|
+
run: (ctx) => {
|
|
43
|
+
const issues = [];
|
|
44
|
+
const pkg = ctx.sourceOfTruth.package;
|
|
45
|
+
let docsClaimWindowsSupport = false;
|
|
46
|
+
if (ctx.config.include.includes("docs")) {
|
|
47
|
+
docsClaimWindowsSupport = docsClaimWindows(ctx);
|
|
48
|
+
}
|
|
49
|
+
if (ctx.config.include.includes("package") && pkg) {
|
|
50
|
+
issues.push(...findPosixOnlyScriptSyntax(pkg.scripts, docsClaimWindowsSupport));
|
|
51
|
+
}
|
|
52
|
+
if (ctx.config.include.includes("cli")) {
|
|
53
|
+
issues.push(...scanSourceFiles(ctx));
|
|
54
|
+
}
|
|
55
|
+
return deduplicateIssuesById(issues);
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
function docsClaimWindows(ctx) {
|
|
59
|
+
for (const docFile of ctx.inventory.docsFiles) {
|
|
60
|
+
const content = readBoundedFileText(ctx.target.rootPath, docFile.relativePath, docFile.sizeBytes, MAX_READ_BYTES);
|
|
61
|
+
if (content !== null && WINDOWS_MENTION_PATTERN.test(content))
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
function findPosixOnlyScriptSyntax(scripts, docsClaimWindowsSupport) {
|
|
67
|
+
const issues = [];
|
|
68
|
+
for (const [scriptName, command] of Object.entries(scripts)) {
|
|
69
|
+
const segments = command.split("&&").map((s) => s.trim());
|
|
70
|
+
for (const segment of segments) {
|
|
71
|
+
if (!POSIX_ONLY_BINARY_PATTERN.test(segment))
|
|
72
|
+
continue;
|
|
73
|
+
issues.push(makeCodeRotIssue({
|
|
74
|
+
auditType: "code-rot",
|
|
75
|
+
detectorId: DETECTOR_ID,
|
|
76
|
+
idCues: ["posix-only-script-syntax", scriptName],
|
|
77
|
+
title: `Script "${scriptName}" appears to use POSIX-only shell syntax`,
|
|
78
|
+
description: `package.json script "${scriptName}" contains a segment that looks like a bare POSIX shell command ("${segment.slice(0, 60)}"), which is not guaranteed to work on Windows without a POSIX shell shim.${docsClaimWindowsSupport ? " Docs explicitly claim Windows support." : ""}`,
|
|
79
|
+
severity: docsClaimWindowsSupport ? "high" : "medium",
|
|
80
|
+
confidence: "medium",
|
|
81
|
+
falsePositiveRisk: "medium",
|
|
82
|
+
category: "cross-platform-rot",
|
|
83
|
+
recommendedAction: "Replace the POSIX-only command with a cross-platform Node script or a guarded/portable equivalent.",
|
|
84
|
+
suggestedFixStrategy: `Rewrite the "${scriptName}" script segment to avoid relying on a POSIX-only shell binary.`,
|
|
85
|
+
validationCommands: [`npm run ${scriptName}`],
|
|
86
|
+
releaseBlocking: false,
|
|
87
|
+
implementationBlocking: false,
|
|
88
|
+
autoFixEligible: false,
|
|
89
|
+
evidence: [
|
|
90
|
+
{
|
|
91
|
+
kind: "reference",
|
|
92
|
+
message: "Script command segment matches a bare POSIX-only shell binary pattern.",
|
|
93
|
+
filePath: "package.json",
|
|
94
|
+
excerpt: segment.slice(0, 200),
|
|
95
|
+
source: DETECTOR_ID,
|
|
96
|
+
confidence: "medium",
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
affectedFiles: ["package.json"],
|
|
100
|
+
}));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return issues;
|
|
104
|
+
}
|
|
105
|
+
function scanSourceFiles(ctx) {
|
|
106
|
+
const issues = [];
|
|
107
|
+
const auditSourceFiles = ctx.inventory.sourceFiles.filter((f) => f.relativePath.startsWith("src/audits/"));
|
|
108
|
+
for (const file of auditSourceFiles) {
|
|
109
|
+
if (path_basename(file.relativePath) === "textLines.ts")
|
|
110
|
+
continue; // canonical implementation, allowed
|
|
111
|
+
const content = readBoundedFileText(ctx.target.rootPath, file.relativePath, file.sizeBytes, MAX_READ_BYTES);
|
|
112
|
+
if (content === null)
|
|
113
|
+
continue;
|
|
114
|
+
SPLIT_NEWLINE_LITERAL_PATTERN.lastIndex = 0;
|
|
115
|
+
if (SPLIT_NEWLINE_LITERAL_PATTERN.test(content)) {
|
|
116
|
+
issues.push(makeCodeRotIssue({
|
|
117
|
+
auditType: "code-rot",
|
|
118
|
+
detectorId: DETECTOR_ID,
|
|
119
|
+
idCues: [file.relativePath, "raw-split-newline"],
|
|
120
|
+
title: `"${file.relativePath}" uses raw .split("\\n") instead of the canonical splitLines() helper`,
|
|
121
|
+
description: `"${file.relativePath}" contains a raw ".split(\\"\\n\\")"/".split('\\n')" call. This project's own convention (see src/audits/codeRot/utils/textLines.ts) requires splitLines() for all doc/text line-splitting, because a bare split leaves a trailing "\\r" on every line for CRLF-authored files.`,
|
|
122
|
+
severity: "medium",
|
|
123
|
+
confidence: "high",
|
|
124
|
+
falsePositiveRisk: "low",
|
|
125
|
+
category: "cross-platform-rot",
|
|
126
|
+
recommendedAction: `Replace the raw .split("\\n") in "${file.relativePath}" with splitLines() from ../utils/textLines.js.`,
|
|
127
|
+
suggestedFixStrategy: `Import and use splitLines() in "${file.relativePath}".`,
|
|
128
|
+
validationCommands: ["npm run audit -- --types code-rot --include cli"],
|
|
129
|
+
releaseBlocking: false,
|
|
130
|
+
implementationBlocking: false,
|
|
131
|
+
autoFixEligible: false,
|
|
132
|
+
evidence: [
|
|
133
|
+
{
|
|
134
|
+
kind: "file",
|
|
135
|
+
message: "Raw .split(\"\\n\") usage found outside the canonical splitLines() implementation.",
|
|
136
|
+
filePath: file.relativePath,
|
|
137
|
+
source: DETECTOR_ID,
|
|
138
|
+
confidence: "high",
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
affectedFiles: [file.relativePath],
|
|
142
|
+
}));
|
|
143
|
+
}
|
|
144
|
+
RAW_BACKSLASH_SPLIT_PATTERN.lastIndex = 0;
|
|
145
|
+
if (RAW_BACKSLASH_SPLIT_PATTERN.test(content)) {
|
|
146
|
+
issues.push(makeCodeRotIssue({
|
|
147
|
+
auditType: "code-rot",
|
|
148
|
+
detectorId: DETECTOR_ID,
|
|
149
|
+
idCues: [file.relativePath, "raw-backslash-split"],
|
|
150
|
+
title: `"${file.relativePath}" raw-splits a path on a literal backslash`,
|
|
151
|
+
description: `"${file.relativePath}" contains a raw backslash .split() call, which assumes Windows-style backslash separators and will silently fail to split forward-slash paths (e.g. on POSIX or already-normalized relative paths).`,
|
|
152
|
+
severity: "medium",
|
|
153
|
+
confidence: "medium",
|
|
154
|
+
falsePositiveRisk: "medium",
|
|
155
|
+
category: "cross-platform-rot",
|
|
156
|
+
recommendedAction: `Use path.sep/path.normalize or a forward-slash-normalized path representation instead of a raw backslash split in "${file.relativePath}".`,
|
|
157
|
+
suggestedFixStrategy: `Replace the raw backslash split in "${file.relativePath}" with a portable path-handling helper.`,
|
|
158
|
+
validationCommands: ["npm run audit -- --types code-rot --include cli"],
|
|
159
|
+
releaseBlocking: false,
|
|
160
|
+
implementationBlocking: false,
|
|
161
|
+
autoFixEligible: false,
|
|
162
|
+
evidence: [
|
|
163
|
+
{
|
|
164
|
+
kind: "file",
|
|
165
|
+
message: "Raw backslash-splitting pattern found in source.",
|
|
166
|
+
filePath: file.relativePath,
|
|
167
|
+
source: DETECTOR_ID,
|
|
168
|
+
confidence: "medium",
|
|
169
|
+
},
|
|
170
|
+
],
|
|
171
|
+
affectedFiles: [file.relativePath],
|
|
172
|
+
}));
|
|
173
|
+
}
|
|
174
|
+
SHELL_TRUE_PATTERN.lastIndex = 0;
|
|
175
|
+
const shellTrueMatch = SHELL_TRUE_PATTERN.exec(content);
|
|
176
|
+
if (shellTrueMatch && EXEC_SPAWN_CALL_PATTERN.test(nearbyWindow(content, shellTrueMatch.index))) {
|
|
177
|
+
issues.push(makeCodeRotIssue({
|
|
178
|
+
auditType: "code-rot",
|
|
179
|
+
detectorId: DETECTOR_ID,
|
|
180
|
+
idCues: [file.relativePath, "shell-true-usage"],
|
|
181
|
+
title: `"${file.relativePath}" appears to invoke a subprocess with shell: true`,
|
|
182
|
+
description: `"${file.relativePath}" contains a "shell: true" option near an exec/spawn call. This is a known risk pattern (shell metacharacter injection via unsanitized arguments).`,
|
|
183
|
+
severity: "high",
|
|
184
|
+
confidence: "medium",
|
|
185
|
+
falsePositiveRisk: "medium",
|
|
186
|
+
category: "cross-platform-rot",
|
|
187
|
+
recommendedAction: `Remove "shell: true" and pass arguments as an array instead, in "${file.relativePath}".`,
|
|
188
|
+
suggestedFixStrategy: `Refactor the subprocess call in "${file.relativePath}" to avoid shell: true.`,
|
|
189
|
+
validationCommands: ["npm run audit -- --types code-rot --include cli"],
|
|
190
|
+
releaseBlocking: true,
|
|
191
|
+
implementationBlocking: false,
|
|
192
|
+
autoFixEligible: false,
|
|
193
|
+
evidence: [
|
|
194
|
+
{
|
|
195
|
+
kind: "file",
|
|
196
|
+
message: "shell: true found near an exec/spawn-family call.",
|
|
197
|
+
filePath: file.relativePath,
|
|
198
|
+
source: DETECTOR_ID,
|
|
199
|
+
confidence: "medium",
|
|
200
|
+
},
|
|
201
|
+
],
|
|
202
|
+
affectedFiles: [file.relativePath],
|
|
203
|
+
}));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return issues;
|
|
207
|
+
}
|
|
208
|
+
function nearbyWindow(content, index, radius = 150) {
|
|
209
|
+
return content.slice(Math.max(0, index - radius), Math.min(content.length, index + radius));
|
|
210
|
+
}
|
|
211
|
+
function path_basename(relativePath) {
|
|
212
|
+
const idx = relativePath.lastIndexOf("/");
|
|
213
|
+
return idx === -1 ? relativePath : relativePath.slice(idx + 1);
|
|
214
|
+
}
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { readBoundedFileText } from "../utils/boundedRead.js";
|
|
3
|
+
import { baseNameNoExt } from "../utils/filePatternUtils.js";
|
|
4
|
+
import { deduplicateIssuesById, makeCodeRotIssue } from "../utils/issueFactories.js";
|
|
5
|
+
import { collectRelativeImportBasenames, getParsedSourceFacts, indexSourceFactsByPath } from "../utils/sourceFactsLookup.js";
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// v0.3.0 Batch 4 — dead-code candidate detector.
|
|
8
|
+
//
|
|
9
|
+
// Every finding here is a candidate, not proof of dead code -- title wording
|
|
10
|
+
// stays hedged throughout. Bounded (per-file MAX_READ_BYTES skip), no
|
|
11
|
+
// subprocess, no exports/type-usage analysis (see SKIPPED note below).
|
|
12
|
+
//
|
|
13
|
+
// Explicitly SKIPPED (spec-documented, not implemented in this batch):
|
|
14
|
+
// - "exported symbols unused" -- there is no existing safe/cheap way in
|
|
15
|
+
// this repo to determine which exported symbols are referenced
|
|
16
|
+
// elsewhere without a real TypeScript program (ts-morph/checker), which
|
|
17
|
+
// is out of scope for a bounded regex-based detector. Not implemented.
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
const DETECTOR_ID = "dead-code-candidate";
|
|
20
|
+
const MAX_READ_BYTES = 200_000;
|
|
21
|
+
const OLD_DIR_SEGMENT_PATTERN = /^(old|deprecated|legacy|_archive)$/i;
|
|
22
|
+
const ORPHAN_GENERATED_PATTERN = /\.generated\.|\.bak$|\.orig$/;
|
|
23
|
+
const IMPORT_SPECIFIER_PATTERN = /(?:from|require\()\s*["']([^"']+)["']/g;
|
|
24
|
+
const PLANNED_LABEL_PATTERN = /\b(planned|future|roadmap|not implemented|not yet implemented|coming soon)\b/i;
|
|
25
|
+
const SETUP_FILE_PATTERN = /setup[tT]ests?|vitest\.setup|test-setup/i;
|
|
26
|
+
export const DEAD_CODE_CANDIDATE_DETECTOR = {
|
|
27
|
+
id: DETECTOR_ID,
|
|
28
|
+
auditType: "code-rot",
|
|
29
|
+
title: "Dead-code candidate",
|
|
30
|
+
description: "Detects candidate (not proven) unused files: scripts/ files not referenced by any package.json script, source files with no in-repo importer, files under old/deprecated/legacy directories not mentioned in docs, and orphaned generated-looking files.",
|
|
31
|
+
supportedIncludeAreas: ["cli", "package", "tests"],
|
|
32
|
+
shouldSkip: (ctx) => {
|
|
33
|
+
if (!ctx.config.include.includes("package") && !ctx.config.include.includes("tests")) {
|
|
34
|
+
return { skip: true, reason: "--include selects neither package nor tests; this detector's checks need at least one." };
|
|
35
|
+
}
|
|
36
|
+
return { skip: false };
|
|
37
|
+
},
|
|
38
|
+
run: (ctx) => {
|
|
39
|
+
const issues = [];
|
|
40
|
+
const pkg = ctx.sourceOfTruth.package;
|
|
41
|
+
const docsConcat = concatDocsContent(ctx);
|
|
42
|
+
if (ctx.config.include.includes("package") && pkg) {
|
|
43
|
+
issues.push(...findUnreferencedScriptFiles(ctx, pkg));
|
|
44
|
+
}
|
|
45
|
+
if (ctx.config.include.includes("cli") || ctx.config.include.includes("architecture")) {
|
|
46
|
+
issues.push(...findUnreferencedSourceFiles(ctx, pkg, docsConcat));
|
|
47
|
+
}
|
|
48
|
+
issues.push(...findOldOrDeprecatedFilesNotInDocs(ctx, docsConcat));
|
|
49
|
+
issues.push(...findOrphanedGeneratedFiles(ctx.inventory.files));
|
|
50
|
+
if (ctx.config.include.includes("tests")) {
|
|
51
|
+
issues.push(...findUnreferencedFixtures(ctx));
|
|
52
|
+
}
|
|
53
|
+
return deduplicateIssuesById(issues);
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
function concatDocsContent(ctx) {
|
|
57
|
+
const parts = [];
|
|
58
|
+
for (const docFile of ctx.inventory.docsFiles) {
|
|
59
|
+
const content = readBoundedFileText(ctx.target.rootPath, docFile.relativePath, docFile.sizeBytes, MAX_READ_BYTES);
|
|
60
|
+
if (content !== null)
|
|
61
|
+
parts.push(content);
|
|
62
|
+
}
|
|
63
|
+
return parts.join("\n");
|
|
64
|
+
}
|
|
65
|
+
function docsDescribeAsPlanned(docsConcat, relativePath) {
|
|
66
|
+
const base = baseNameNoExt(relativePath);
|
|
67
|
+
const idx = docsConcat.indexOf(base);
|
|
68
|
+
if (idx === -1)
|
|
69
|
+
return false;
|
|
70
|
+
const windowStart = Math.max(0, idx - 100);
|
|
71
|
+
const windowEnd = Math.min(docsConcat.length, idx + base.length + 100);
|
|
72
|
+
return PLANNED_LABEL_PATTERN.test(docsConcat.slice(windowStart, windowEnd));
|
|
73
|
+
}
|
|
74
|
+
function binTargetsAsSourceCandidates(pkg) {
|
|
75
|
+
const raw = pkg.bin === null ? [] : typeof pkg.bin === "string" ? [pkg.bin] : Object.values(pkg.bin);
|
|
76
|
+
const candidates = new Set();
|
|
77
|
+
for (const target of raw) {
|
|
78
|
+
candidates.add(target);
|
|
79
|
+
if (target.startsWith("dist/") && target.endsWith(".js")) {
|
|
80
|
+
candidates.add(`${target.slice("dist/".length, -".js".length)}.ts`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (pkg.main)
|
|
84
|
+
candidates.add(pkg.main);
|
|
85
|
+
if (pkg.module)
|
|
86
|
+
candidates.add(pkg.module);
|
|
87
|
+
if (pkg.types)
|
|
88
|
+
candidates.add(pkg.types);
|
|
89
|
+
return candidates;
|
|
90
|
+
}
|
|
91
|
+
function findUnreferencedScriptFiles(ctx, pkg) {
|
|
92
|
+
const scriptCommandsBlob = Object.values(pkg.scripts).join(" \n ");
|
|
93
|
+
const binCandidates = binTargetsAsSourceCandidates(pkg);
|
|
94
|
+
// A scripts/ file that is imported by another script (a shared helper
|
|
95
|
+
// module rather than a CLI entrypoint, e.g.
|
|
96
|
+
// scripts/security/resolveToolRoot.ts) is not dead code even though no
|
|
97
|
+
// package.json script command names it directly -- discovered via a real
|
|
98
|
+
// false positive during self-testing.
|
|
99
|
+
const referencedBasenames = buildReverseReferenceIndex(ctx);
|
|
100
|
+
const issues = [];
|
|
101
|
+
for (const file of ctx.inventory.scriptFiles) {
|
|
102
|
+
const base = baseNameNoExt(file.relativePath);
|
|
103
|
+
if (base.toLowerCase() === "index")
|
|
104
|
+
continue;
|
|
105
|
+
if (SETUP_FILE_PATTERN.test(path.basename(file.relativePath)))
|
|
106
|
+
continue;
|
|
107
|
+
if (binCandidates.has(file.relativePath))
|
|
108
|
+
continue;
|
|
109
|
+
if (scriptCommandsBlob.includes(file.relativePath) || scriptCommandsBlob.includes(base))
|
|
110
|
+
continue;
|
|
111
|
+
if (referencedBasenames.has(base.toLowerCase()))
|
|
112
|
+
continue;
|
|
113
|
+
issues.push(makeCodeRotIssue({
|
|
114
|
+
auditType: "code-rot",
|
|
115
|
+
detectorId: DETECTOR_ID,
|
|
116
|
+
idCues: ["unreferenced-script-file", file.relativePath],
|
|
117
|
+
title: `Script file "${file.relativePath}" is not referenced by any package.json script`,
|
|
118
|
+
description: `"${file.relativePath}" exists under scripts/ but no package.json script command mentions its path or basename. This is a candidate for dead code -- confirm it isn't invoked another way (e.g. imported by another script) before removing.`,
|
|
119
|
+
severity: "medium",
|
|
120
|
+
confidence: "medium",
|
|
121
|
+
falsePositiveRisk: "medium",
|
|
122
|
+
category: "dead-code-candidate",
|
|
123
|
+
recommendedAction: "Confirm the file is unused, then either add a script entry or remove the file.",
|
|
124
|
+
suggestedFixStrategy: `Remove "${file.relativePath}" or wire it up to a package.json script.`,
|
|
125
|
+
validationCommands: ["npm run audit -- --types code-rot --include package"],
|
|
126
|
+
releaseBlocking: false,
|
|
127
|
+
implementationBlocking: false,
|
|
128
|
+
autoFixEligible: false,
|
|
129
|
+
evidence: [
|
|
130
|
+
{
|
|
131
|
+
kind: "reference",
|
|
132
|
+
message: "No package.json script command references this file's path or basename.",
|
|
133
|
+
filePath: file.relativePath,
|
|
134
|
+
source: DETECTOR_ID,
|
|
135
|
+
confidence: "medium",
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
affectedFiles: [file.relativePath],
|
|
139
|
+
}));
|
|
140
|
+
}
|
|
141
|
+
return issues;
|
|
142
|
+
}
|
|
143
|
+
function buildReverseReferenceIndex(ctx) {
|
|
144
|
+
// v0.3.1 Batch 4 -- seeded with basenames the TypeScript/JavaScript
|
|
145
|
+
// analyzer already resolved from real import declarations (parsed files
|
|
146
|
+
// only). This only ever adds references, never removes them, so it can
|
|
147
|
+
// only reduce false positives relative to the regex-only scan below --
|
|
148
|
+
// safe even though ctx.sourceFacts is optional (returns an empty set when
|
|
149
|
+
// absent, e.g. in existing detector-only unit tests).
|
|
150
|
+
const referencedBasenames = collectRelativeImportBasenames(ctx.sourceFacts);
|
|
151
|
+
const allCandidateFiles = [
|
|
152
|
+
...ctx.inventory.sourceFiles,
|
|
153
|
+
...ctx.inventory.testFiles,
|
|
154
|
+
...ctx.inventory.scriptFiles,
|
|
155
|
+
];
|
|
156
|
+
for (const file of allCandidateFiles) {
|
|
157
|
+
const content = readBoundedFileText(ctx.target.rootPath, file.relativePath, file.sizeBytes, MAX_READ_BYTES);
|
|
158
|
+
if (content === null)
|
|
159
|
+
continue;
|
|
160
|
+
IMPORT_SPECIFIER_PATTERN.lastIndex = 0;
|
|
161
|
+
for (const match of content.matchAll(IMPORT_SPECIFIER_PATTERN)) {
|
|
162
|
+
const specifier = match[1];
|
|
163
|
+
if (!specifier.startsWith("."))
|
|
164
|
+
continue; // only relative/local specifiers are meaningful here
|
|
165
|
+
const specifierBase = baseNameNoExt(specifier.split("?")[0]);
|
|
166
|
+
referencedBasenames.add(specifierBase.toLowerCase());
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return referencedBasenames;
|
|
170
|
+
}
|
|
171
|
+
function findUnreferencedSourceFiles(ctx, pkg, docsConcat) {
|
|
172
|
+
const referencedBasenames = buildReverseReferenceIndex(ctx);
|
|
173
|
+
const binCandidates = pkg ? binTargetsAsSourceCandidates(pkg) : new Set();
|
|
174
|
+
const sourceFactsIndex = indexSourceFactsByPath(ctx.sourceFacts);
|
|
175
|
+
const issues = [];
|
|
176
|
+
for (const file of ctx.inventory.sourceFiles) {
|
|
177
|
+
if (file.sizeBytes > MAX_READ_BYTES)
|
|
178
|
+
continue;
|
|
179
|
+
// benchmarks/ and examples/ hold standalone fixture/example project
|
|
180
|
+
// content (often in other languages, e.g. Python) that is meant to be
|
|
181
|
+
// read by the tool under test, not imported by this project's own
|
|
182
|
+
// TS/JS import graph -- scanning them against a TS/JS import-specifier
|
|
183
|
+
// reverse-reference index produces false positives, confirmed via
|
|
184
|
+
// self-testing.
|
|
185
|
+
if (file.relativePath.startsWith("benchmarks/") || file.relativePath.startsWith("examples/"))
|
|
186
|
+
continue;
|
|
187
|
+
const base = baseNameNoExt(file.relativePath);
|
|
188
|
+
if (base.toLowerCase() === "index")
|
|
189
|
+
continue;
|
|
190
|
+
if (SETUP_FILE_PATTERN.test(path.basename(file.relativePath)))
|
|
191
|
+
continue;
|
|
192
|
+
if (binCandidates.has(file.relativePath))
|
|
193
|
+
continue;
|
|
194
|
+
if (docsDescribeAsPlanned(docsConcat, file.relativePath))
|
|
195
|
+
continue;
|
|
196
|
+
if (referencedBasenames.has(base.toLowerCase()))
|
|
197
|
+
continue;
|
|
198
|
+
const evidence = [
|
|
199
|
+
{
|
|
200
|
+
kind: "observation",
|
|
201
|
+
message: "No relative import/require specifier in the scanned source/test/script set resolves to this file's basename.",
|
|
202
|
+
filePath: file.relativePath,
|
|
203
|
+
source: DETECTOR_ID,
|
|
204
|
+
confidence: "low",
|
|
205
|
+
},
|
|
206
|
+
];
|
|
207
|
+
// v0.3.1 Batch 4 -- when the TypeScript/JavaScript analyzer actually
|
|
208
|
+
// parsed this file, surface its export count as extra candidate
|
|
209
|
+
// evidence (an exported symbol with zero detected external references
|
|
210
|
+
// is more suspicious than an unexported one). This never changes
|
|
211
|
+
// whether the issue is emitted, only its evidence -- the flagging
|
|
212
|
+
// decision above is unchanged from the existing basename-reference
|
|
213
|
+
// heuristic.
|
|
214
|
+
const facts = getParsedSourceFacts(sourceFactsIndex, file.relativePath);
|
|
215
|
+
if (facts) {
|
|
216
|
+
evidence.push(buildSourceFactsEvidence(facts));
|
|
217
|
+
}
|
|
218
|
+
issues.push(makeCodeRotIssue({
|
|
219
|
+
auditType: "code-rot",
|
|
220
|
+
detectorId: DETECTOR_ID,
|
|
221
|
+
idCues: ["unreferenced-source-file", file.relativePath],
|
|
222
|
+
title: `Source file "${file.relativePath}" has no detected in-repo importer`,
|
|
223
|
+
description: `No other source or test file's relative import/require specifier appears to reference "${file.relativePath}" by basename. This is a bounded regex-based candidate signal, not a type-level unused-file proof -- confirm manually (dynamic imports, re-exports, or CLI-only usage can evade this scan).`,
|
|
224
|
+
severity: "low",
|
|
225
|
+
confidence: "low",
|
|
226
|
+
falsePositiveRisk: "medium",
|
|
227
|
+
category: "dead-code-candidate",
|
|
228
|
+
recommendedAction: "Confirm the file is genuinely unused before removing it.",
|
|
229
|
+
suggestedFixStrategy: `Remove "${file.relativePath}" if confirmed unused, or wire it into an existing import graph.`,
|
|
230
|
+
validationCommands: ["npm run audit -- --types code-rot --include cli"],
|
|
231
|
+
releaseBlocking: false,
|
|
232
|
+
implementationBlocking: false,
|
|
233
|
+
autoFixEligible: false,
|
|
234
|
+
evidence,
|
|
235
|
+
affectedFiles: [file.relativePath],
|
|
236
|
+
}));
|
|
237
|
+
}
|
|
238
|
+
return issues;
|
|
239
|
+
}
|
|
240
|
+
// v0.3.1 Batch 4 -- describes a parsed file's source-facts shape as an
|
|
241
|
+
// evidence entry. Only called for parseStatus "parsed" facts (see callers),
|
|
242
|
+
// since file-level-only/parse-error facts carry empty fact arrays that
|
|
243
|
+
// would misleadingly read as "zero exports" rather than "unknown".
|
|
244
|
+
function buildSourceFactsEvidence(facts) {
|
|
245
|
+
return {
|
|
246
|
+
kind: "reference",
|
|
247
|
+
message: `Source facts: the TypeScript/JavaScript analyzer parsed this file and recorded ${facts.exports.length} export(s), ${facts.declarations.length} declaration(s), and ${facts.imports.length} import(s).`,
|
|
248
|
+
filePath: facts.relativePath,
|
|
249
|
+
source: DETECTOR_ID,
|
|
250
|
+
confidence: "medium",
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
function findOldOrDeprecatedFilesNotInDocs(ctx, docsConcat) {
|
|
254
|
+
const issues = [];
|
|
255
|
+
for (const file of ctx.inventory.files) {
|
|
256
|
+
const segments = file.relativePath.split("/");
|
|
257
|
+
const hasOldSegment = segments.slice(0, -1).some((seg) => OLD_DIR_SEGMENT_PATTERN.test(seg));
|
|
258
|
+
if (!hasOldSegment)
|
|
259
|
+
continue;
|
|
260
|
+
if (docsConcat.includes(file.relativePath) || docsConcat.includes(path.basename(file.relativePath)))
|
|
261
|
+
continue;
|
|
262
|
+
issues.push(makeCodeRotIssue({
|
|
263
|
+
auditType: "code-rot",
|
|
264
|
+
detectorId: DETECTOR_ID,
|
|
265
|
+
idCues: ["old-deprecated-file-not-in-docs", file.relativePath],
|
|
266
|
+
title: `File under an old/deprecated/legacy directory is not mentioned in docs: "${file.relativePath}"`,
|
|
267
|
+
description: `"${file.relativePath}" sits under a directory named old/deprecated/legacy/_archive, and no doc file mentions its path or basename. This is a candidate for dead code.`,
|
|
268
|
+
severity: "medium",
|
|
269
|
+
confidence: "medium",
|
|
270
|
+
falsePositiveRisk: "low",
|
|
271
|
+
category: "dead-code-candidate",
|
|
272
|
+
recommendedAction: "Confirm the file is genuinely obsolete, then remove it or document why it is retained.",
|
|
273
|
+
suggestedFixStrategy: `Remove "${file.relativePath}" or add a doc note explaining why it is kept.`,
|
|
274
|
+
validationCommands: ["npm run audit -- --types code-rot --include cli"],
|
|
275
|
+
releaseBlocking: false,
|
|
276
|
+
implementationBlocking: false,
|
|
277
|
+
autoFixEligible: false,
|
|
278
|
+
evidence: [
|
|
279
|
+
{
|
|
280
|
+
kind: "file",
|
|
281
|
+
message: "File is under an old/deprecated/legacy/_archive-named directory and is not mentioned in any doc file.",
|
|
282
|
+
filePath: file.relativePath,
|
|
283
|
+
source: DETECTOR_ID,
|
|
284
|
+
confidence: "medium",
|
|
285
|
+
},
|
|
286
|
+
],
|
|
287
|
+
affectedFiles: [file.relativePath],
|
|
288
|
+
}));
|
|
289
|
+
}
|
|
290
|
+
return issues;
|
|
291
|
+
}
|
|
292
|
+
function findOrphanedGeneratedFiles(files) {
|
|
293
|
+
const issues = [];
|
|
294
|
+
for (const file of files) {
|
|
295
|
+
if (file.likelyGenerated)
|
|
296
|
+
continue; // already caught by inventory's own generated category
|
|
297
|
+
if (!ORPHAN_GENERATED_PATTERN.test(path.basename(file.relativePath)))
|
|
298
|
+
continue;
|
|
299
|
+
issues.push(makeCodeRotIssue({
|
|
300
|
+
auditType: "code-rot",
|
|
301
|
+
detectorId: DETECTOR_ID,
|
|
302
|
+
idCues: ["orphaned-generated-looking-file", file.relativePath],
|
|
303
|
+
title: `Orphaned generated-looking file: "${file.relativePath}"`,
|
|
304
|
+
description: `"${file.relativePath}" has a generated/backup-looking name (.generated., .bak, .orig) but is not classified as generated content by the project inventory. This is a candidate for dead code.`,
|
|
305
|
+
severity: "low",
|
|
306
|
+
confidence: "medium",
|
|
307
|
+
falsePositiveRisk: "medium",
|
|
308
|
+
category: "dead-code-candidate",
|
|
309
|
+
recommendedAction: "Confirm the file is stale generated/backup content, then remove it.",
|
|
310
|
+
suggestedFixStrategy: `Remove "${file.relativePath}" if confirmed stale.`,
|
|
311
|
+
validationCommands: ["npm run audit -- --types code-rot --include cli"],
|
|
312
|
+
releaseBlocking: false,
|
|
313
|
+
implementationBlocking: false,
|
|
314
|
+
autoFixEligible: false,
|
|
315
|
+
evidence: [
|
|
316
|
+
{
|
|
317
|
+
kind: "file",
|
|
318
|
+
message: "Filename matches a generated/backup-looking pattern outside the inventory's own generated-file classification.",
|
|
319
|
+
filePath: file.relativePath,
|
|
320
|
+
source: DETECTOR_ID,
|
|
321
|
+
confidence: "medium",
|
|
322
|
+
},
|
|
323
|
+
],
|
|
324
|
+
affectedFiles: [file.relativePath],
|
|
325
|
+
}));
|
|
326
|
+
}
|
|
327
|
+
return issues;
|
|
328
|
+
}
|
|
329
|
+
// Housed here (not in testRotDetector.ts) per the Batch 4 spec's explicit
|
|
330
|
+
// instruction to implement this check exactly once to avoid duplicate
|
|
331
|
+
// reporting between the two detectors.
|
|
332
|
+
function findUnreferencedFixtures(ctx) {
|
|
333
|
+
const fixtureFiles = ctx.inventory.files.filter((f) => f.relativePath.includes("/fixtures/") || f.relativePath.startsWith("fixtures/"));
|
|
334
|
+
if (fixtureFiles.length === 0)
|
|
335
|
+
return [];
|
|
336
|
+
const testFilesContent = [];
|
|
337
|
+
for (const testFile of ctx.inventory.testFiles) {
|
|
338
|
+
const content = readBoundedFileText(ctx.target.rootPath, testFile.relativePath, testFile.sizeBytes, MAX_READ_BYTES);
|
|
339
|
+
if (content !== null)
|
|
340
|
+
testFilesContent.push(content);
|
|
341
|
+
}
|
|
342
|
+
const combinedTestContent = testFilesContent.join("\n");
|
|
343
|
+
const issues = [];
|
|
344
|
+
for (const fixture of fixtureFiles) {
|
|
345
|
+
const base = path.basename(fixture.relativePath);
|
|
346
|
+
if (combinedTestContent.includes(base))
|
|
347
|
+
continue;
|
|
348
|
+
issues.push(makeCodeRotIssue({
|
|
349
|
+
auditType: "code-rot",
|
|
350
|
+
detectorId: DETECTOR_ID,
|
|
351
|
+
idCues: ["unreferenced-fixture", fixture.relativePath],
|
|
352
|
+
title: `Fixture "${fixture.relativePath}" is not referenced by any test file`,
|
|
353
|
+
description: `"${fixture.relativePath}" is under a fixtures/ directory, but its basename does not appear in any test file's content. This is a weak candidate signal for an unused fixture.`,
|
|
354
|
+
severity: "info",
|
|
355
|
+
confidence: "low",
|
|
356
|
+
falsePositiveRisk: "high",
|
|
357
|
+
category: "dead-code-candidate",
|
|
358
|
+
recommendedAction: "Confirm the fixture is genuinely unused before removing it.",
|
|
359
|
+
suggestedFixStrategy: `Remove "${fixture.relativePath}" if confirmed unused.`,
|
|
360
|
+
validationCommands: ["npm run audit -- --types code-rot --include tests"],
|
|
361
|
+
releaseBlocking: false,
|
|
362
|
+
implementationBlocking: false,
|
|
363
|
+
autoFixEligible: false,
|
|
364
|
+
evidence: [
|
|
365
|
+
{
|
|
366
|
+
kind: "observation",
|
|
367
|
+
message: "Fixture basename does not appear in any test file's bounded content scan.",
|
|
368
|
+
filePath: fixture.relativePath,
|
|
369
|
+
source: DETECTOR_ID,
|
|
370
|
+
confidence: "low",
|
|
371
|
+
},
|
|
372
|
+
],
|
|
373
|
+
affectedFiles: [fixture.relativePath],
|
|
374
|
+
}));
|
|
375
|
+
}
|
|
376
|
+
return issues;
|
|
377
|
+
}
|