@dailephd/my-dev-kit-lab 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +36 -24
- package/dist/scripts/audits/runAudit.js +101 -0
- package/dist/scripts/security/validate.js +68 -29
- 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 +344 -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 +273 -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 +348 -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 +47 -0
- package/dist/src/audits/codeRot/utils/issueFactories.js +38 -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 +71 -0
- package/dist/src/audits/core/auditTarget.js +19 -0
- package/dist/src/audits/core/auditTypes.js +30 -0
- package/dist/src/audits/core/projectInventory.js +308 -0
- package/dist/src/audits/core/sourceOfTruth.js +339 -0
- package/dist/src/audits/core/textLines.js +24 -0
- package/dist/src/audits/report/auditReportModel.js +241 -0
- package/dist/src/audits/report/auditReportPaths.js +13 -0
- package/dist/src/audits/report/renderAuditJsonReport.js +29 -0
- package/dist/src/audits/report/renderAuditTextReport.js +165 -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/dist/src/securityValidation/attackScenarios/attackProfile.js +20 -0
- package/dist/src/securityValidation/attackScenarios/attackResult.js +37 -0
- package/dist/src/securityValidation/attackScenarios/attackRunner.js +153 -0
- package/dist/src/securityValidation/attackScenarios/attackScenario.js +21 -0
- package/dist/src/securityValidation/attackScenarios/boundedSourceScan.js +100 -0
- package/dist/src/securityValidation/attackScenarios/exploitEvidence.js +69 -0
- package/dist/src/securityValidation/attackScenarios/payloadCorpus.js +0 -0
- package/dist/src/securityValidation/attackScenarios/profiles/localToolProfile.js +10 -0
- package/dist/src/securityValidation/attackScenarios/profiles/nodeCliPackageProfile.js +20 -0
- package/dist/src/securityValidation/attackScenarios/profiles/npmPackageProfile.js +10 -0
- package/dist/src/securityValidation/attackScenarios/reportSchemaGuard.js +41 -0
- package/dist/src/securityValidation/attackScenarios/scenarios/configInjectionScenario.js +144 -0
- package/dist/src/securityValidation/attackScenarios/scenarios/networkAssumptionScenario.js +129 -0
- package/dist/src/securityValidation/attackScenarios/scenarios/outputBoundaryScenario.js +69 -0
- package/dist/src/securityValidation/attackScenarios/scenarios/packageBoundaryScenario.js +118 -0
- package/dist/src/securityValidation/attackScenarios/scenarios/pathTraversalScenario.js +119 -0
- package/dist/src/securityValidation/attackScenarios/scenarios/reportPoisoningScenario.js +176 -0
- package/dist/src/securityValidation/attackScenarios/scenarios/secretLeakageScenario.js +143 -0
- package/dist/src/securityValidation/attackScenarios/scenarios/subprocessInjectionScenario.js +117 -0
- package/dist/src/securityValidation/attackScenarios/scenarios/targetSandboxScenario.js +118 -0
- package/dist/src/securityValidation/attackScenarios/targetSnapshot.js +70 -0
- package/dist/src/securityValidation/packageChecks/forbiddenPackageContents.js +4 -1
- package/dist/src/securityValidation/report/renderSecurityReport.js +135 -13
- package/dist/src/securityValidation/types.js +7 -0
- package/dist/src/securityValidation/validate/cliOptions.js +182 -0
- package/dist/src/securityValidation/validate/runSecurityValidation.js +134 -35
- package/dist/src/securityValidation/validate/verdict.js +147 -0
- package/package.json +18 -17
|
@@ -0,0 +1,344 @@
|
|
|
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
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// v0.3.0 Batch 4 — dead-code candidate detector.
|
|
7
|
+
//
|
|
8
|
+
// Every finding here is a candidate, not proof of dead code -- title wording
|
|
9
|
+
// stays hedged throughout. Bounded (per-file MAX_READ_BYTES skip), no
|
|
10
|
+
// subprocess, no exports/type-usage analysis (see SKIPPED note below).
|
|
11
|
+
//
|
|
12
|
+
// Explicitly SKIPPED (spec-documented, not implemented in this batch):
|
|
13
|
+
// - "exported symbols unused" -- there is no existing safe/cheap way in
|
|
14
|
+
// this repo to determine which exported symbols are referenced
|
|
15
|
+
// elsewhere without a real TypeScript program (ts-morph/checker), which
|
|
16
|
+
// is out of scope for a bounded regex-based detector. Not implemented.
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
const DETECTOR_ID = "dead-code-candidate";
|
|
19
|
+
const MAX_READ_BYTES = 200_000;
|
|
20
|
+
const OLD_DIR_SEGMENT_PATTERN = /^(old|deprecated|legacy|_archive)$/i;
|
|
21
|
+
const ORPHAN_GENERATED_PATTERN = /\.generated\.|\.bak$|\.orig$/;
|
|
22
|
+
const IMPORT_SPECIFIER_PATTERN = /(?:from|require\()\s*["']([^"']+)["']/g;
|
|
23
|
+
const PLANNED_LABEL_PATTERN = /\b(planned|future|roadmap|not implemented|not yet implemented|coming soon)\b/i;
|
|
24
|
+
const SETUP_FILE_PATTERN = /setup[tT]ests?|vitest\.setup|test-setup/i;
|
|
25
|
+
export const DEAD_CODE_CANDIDATE_DETECTOR = {
|
|
26
|
+
id: DETECTOR_ID,
|
|
27
|
+
auditType: "code-rot",
|
|
28
|
+
title: "Dead-code candidate",
|
|
29
|
+
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.",
|
|
30
|
+
supportedIncludeAreas: ["cli", "package", "tests"],
|
|
31
|
+
shouldSkip: (ctx) => {
|
|
32
|
+
if (!ctx.config.include.includes("package") && !ctx.config.include.includes("tests")) {
|
|
33
|
+
return { skip: true, reason: "--include selects neither package nor tests; this detector's checks need at least one." };
|
|
34
|
+
}
|
|
35
|
+
return { skip: false };
|
|
36
|
+
},
|
|
37
|
+
run: (ctx) => {
|
|
38
|
+
const issues = [];
|
|
39
|
+
const pkg = ctx.sourceOfTruth.package;
|
|
40
|
+
const docsConcat = concatDocsContent(ctx);
|
|
41
|
+
if (ctx.config.include.includes("package") && pkg) {
|
|
42
|
+
issues.push(...findUnreferencedScriptFiles(ctx, pkg));
|
|
43
|
+
}
|
|
44
|
+
if (ctx.config.include.includes("cli") || ctx.config.include.includes("architecture")) {
|
|
45
|
+
issues.push(...findUnreferencedSourceFiles(ctx, pkg, docsConcat));
|
|
46
|
+
}
|
|
47
|
+
issues.push(...findOldOrDeprecatedFilesNotInDocs(ctx, docsConcat));
|
|
48
|
+
issues.push(...findOrphanedGeneratedFiles(ctx.inventory.files));
|
|
49
|
+
if (ctx.config.include.includes("tests")) {
|
|
50
|
+
issues.push(...findUnreferencedFixtures(ctx));
|
|
51
|
+
}
|
|
52
|
+
return deduplicateIssuesById(issues);
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
function concatDocsContent(ctx) {
|
|
56
|
+
const parts = [];
|
|
57
|
+
for (const docFile of ctx.inventory.docsFiles) {
|
|
58
|
+
const content = readBoundedFileText(ctx.target.rootPath, docFile.relativePath, docFile.sizeBytes, MAX_READ_BYTES);
|
|
59
|
+
if (content !== null)
|
|
60
|
+
parts.push(content);
|
|
61
|
+
}
|
|
62
|
+
return parts.join("\n");
|
|
63
|
+
}
|
|
64
|
+
function docsDescribeAsPlanned(docsConcat, relativePath) {
|
|
65
|
+
const base = baseNameNoExt(relativePath);
|
|
66
|
+
const idx = docsConcat.indexOf(base);
|
|
67
|
+
if (idx === -1)
|
|
68
|
+
return false;
|
|
69
|
+
const windowStart = Math.max(0, idx - 100);
|
|
70
|
+
const windowEnd = Math.min(docsConcat.length, idx + base.length + 100);
|
|
71
|
+
return PLANNED_LABEL_PATTERN.test(docsConcat.slice(windowStart, windowEnd));
|
|
72
|
+
}
|
|
73
|
+
function binTargetsAsSourceCandidates(pkg) {
|
|
74
|
+
const raw = pkg.bin === null ? [] : typeof pkg.bin === "string" ? [pkg.bin] : Object.values(pkg.bin);
|
|
75
|
+
const candidates = new Set();
|
|
76
|
+
for (const target of raw) {
|
|
77
|
+
candidates.add(target);
|
|
78
|
+
if (target.startsWith("dist/") && target.endsWith(".js")) {
|
|
79
|
+
candidates.add(`${target.slice("dist/".length, -".js".length)}.ts`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (pkg.main)
|
|
83
|
+
candidates.add(pkg.main);
|
|
84
|
+
if (pkg.module)
|
|
85
|
+
candidates.add(pkg.module);
|
|
86
|
+
if (pkg.types)
|
|
87
|
+
candidates.add(pkg.types);
|
|
88
|
+
return candidates;
|
|
89
|
+
}
|
|
90
|
+
function findUnreferencedScriptFiles(ctx, pkg) {
|
|
91
|
+
const scriptCommandsBlob = Object.values(pkg.scripts).join(" \n ");
|
|
92
|
+
const binCandidates = binTargetsAsSourceCandidates(pkg);
|
|
93
|
+
// A scripts/ file that is imported by another script (a shared helper
|
|
94
|
+
// module rather than a CLI entrypoint, e.g.
|
|
95
|
+
// scripts/security/resolveToolRoot.ts) is not dead code even though no
|
|
96
|
+
// package.json script command names it directly -- discovered via a real
|
|
97
|
+
// false positive during self-testing.
|
|
98
|
+
const referencedBasenames = buildReverseReferenceIndex(ctx);
|
|
99
|
+
const issues = [];
|
|
100
|
+
for (const file of ctx.inventory.scriptFiles) {
|
|
101
|
+
const base = baseNameNoExt(file.relativePath);
|
|
102
|
+
if (base.toLowerCase() === "index")
|
|
103
|
+
continue;
|
|
104
|
+
if (SETUP_FILE_PATTERN.test(path.basename(file.relativePath)))
|
|
105
|
+
continue;
|
|
106
|
+
if (binCandidates.has(file.relativePath))
|
|
107
|
+
continue;
|
|
108
|
+
if (scriptCommandsBlob.includes(file.relativePath) || scriptCommandsBlob.includes(base))
|
|
109
|
+
continue;
|
|
110
|
+
if (referencedBasenames.has(base.toLowerCase()))
|
|
111
|
+
continue;
|
|
112
|
+
issues.push(makeCodeRotIssue({
|
|
113
|
+
auditType: "code-rot",
|
|
114
|
+
detectorId: DETECTOR_ID,
|
|
115
|
+
idCues: ["unreferenced-script-file", file.relativePath],
|
|
116
|
+
title: `Script file "${file.relativePath}" is not referenced by any package.json script`,
|
|
117
|
+
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.`,
|
|
118
|
+
severity: "medium",
|
|
119
|
+
confidence: "medium",
|
|
120
|
+
falsePositiveRisk: "medium",
|
|
121
|
+
category: "dead-code-candidate",
|
|
122
|
+
recommendedAction: "Confirm the file is unused, then either add a script entry or remove the file.",
|
|
123
|
+
suggestedFixStrategy: `Remove "${file.relativePath}" or wire it up to a package.json script.`,
|
|
124
|
+
validationCommands: ["npm run audit -- --types code-rot --include package"],
|
|
125
|
+
releaseBlocking: false,
|
|
126
|
+
implementationBlocking: false,
|
|
127
|
+
autoFixEligible: false,
|
|
128
|
+
evidence: [
|
|
129
|
+
{
|
|
130
|
+
kind: "reference",
|
|
131
|
+
message: "No package.json script command references this file's path or basename.",
|
|
132
|
+
filePath: file.relativePath,
|
|
133
|
+
source: DETECTOR_ID,
|
|
134
|
+
confidence: "medium",
|
|
135
|
+
},
|
|
136
|
+
],
|
|
137
|
+
affectedFiles: [file.relativePath],
|
|
138
|
+
}));
|
|
139
|
+
}
|
|
140
|
+
return issues;
|
|
141
|
+
}
|
|
142
|
+
function buildReverseReferenceIndex(ctx) {
|
|
143
|
+
const referencedBasenames = new Set();
|
|
144
|
+
const allCandidateFiles = [
|
|
145
|
+
...ctx.inventory.sourceFiles,
|
|
146
|
+
...ctx.inventory.testFiles,
|
|
147
|
+
...ctx.inventory.scriptFiles,
|
|
148
|
+
];
|
|
149
|
+
for (const file of allCandidateFiles) {
|
|
150
|
+
const content = readBoundedFileText(ctx.target.rootPath, file.relativePath, file.sizeBytes, MAX_READ_BYTES);
|
|
151
|
+
if (content === null)
|
|
152
|
+
continue;
|
|
153
|
+
IMPORT_SPECIFIER_PATTERN.lastIndex = 0;
|
|
154
|
+
for (const match of content.matchAll(IMPORT_SPECIFIER_PATTERN)) {
|
|
155
|
+
const specifier = match[1];
|
|
156
|
+
if (!specifier.startsWith("."))
|
|
157
|
+
continue; // only relative/local specifiers are meaningful here
|
|
158
|
+
const specifierBase = baseNameNoExt(specifier.split("?")[0]);
|
|
159
|
+
referencedBasenames.add(specifierBase.toLowerCase());
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return referencedBasenames;
|
|
163
|
+
}
|
|
164
|
+
function findUnreferencedSourceFiles(ctx, pkg, docsConcat) {
|
|
165
|
+
const referencedBasenames = buildReverseReferenceIndex(ctx);
|
|
166
|
+
const binCandidates = pkg ? binTargetsAsSourceCandidates(pkg) : new Set();
|
|
167
|
+
const issues = [];
|
|
168
|
+
for (const file of ctx.inventory.sourceFiles) {
|
|
169
|
+
if (file.sizeBytes > MAX_READ_BYTES)
|
|
170
|
+
continue;
|
|
171
|
+
// benchmarks/ and examples/ hold standalone fixture/example project
|
|
172
|
+
// content (often in other languages, e.g. Python) that is meant to be
|
|
173
|
+
// read by the tool under test, not imported by this project's own
|
|
174
|
+
// TS/JS import graph -- scanning them against a TS/JS import-specifier
|
|
175
|
+
// reverse-reference index produces false positives, confirmed via
|
|
176
|
+
// self-testing.
|
|
177
|
+
if (file.relativePath.startsWith("benchmarks/") || file.relativePath.startsWith("examples/"))
|
|
178
|
+
continue;
|
|
179
|
+
const base = baseNameNoExt(file.relativePath);
|
|
180
|
+
if (base.toLowerCase() === "index")
|
|
181
|
+
continue;
|
|
182
|
+
if (SETUP_FILE_PATTERN.test(path.basename(file.relativePath)))
|
|
183
|
+
continue;
|
|
184
|
+
if (binCandidates.has(file.relativePath))
|
|
185
|
+
continue;
|
|
186
|
+
if (docsDescribeAsPlanned(docsConcat, file.relativePath))
|
|
187
|
+
continue;
|
|
188
|
+
if (referencedBasenames.has(base.toLowerCase()))
|
|
189
|
+
continue;
|
|
190
|
+
issues.push(makeCodeRotIssue({
|
|
191
|
+
auditType: "code-rot",
|
|
192
|
+
detectorId: DETECTOR_ID,
|
|
193
|
+
idCues: ["unreferenced-source-file", file.relativePath],
|
|
194
|
+
title: `Source file "${file.relativePath}" has no detected in-repo importer`,
|
|
195
|
+
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).`,
|
|
196
|
+
severity: "low",
|
|
197
|
+
confidence: "low",
|
|
198
|
+
falsePositiveRisk: "medium",
|
|
199
|
+
category: "dead-code-candidate",
|
|
200
|
+
recommendedAction: "Confirm the file is genuinely unused before removing it.",
|
|
201
|
+
suggestedFixStrategy: `Remove "${file.relativePath}" if confirmed unused, or wire it into an existing import graph.`,
|
|
202
|
+
validationCommands: ["npm run audit -- --types code-rot --include cli"],
|
|
203
|
+
releaseBlocking: false,
|
|
204
|
+
implementationBlocking: false,
|
|
205
|
+
autoFixEligible: false,
|
|
206
|
+
evidence: [
|
|
207
|
+
{
|
|
208
|
+
kind: "observation",
|
|
209
|
+
message: "No relative import/require specifier in the scanned source/test/script set resolves to this file's basename.",
|
|
210
|
+
filePath: file.relativePath,
|
|
211
|
+
source: DETECTOR_ID,
|
|
212
|
+
confidence: "low",
|
|
213
|
+
},
|
|
214
|
+
],
|
|
215
|
+
affectedFiles: [file.relativePath],
|
|
216
|
+
}));
|
|
217
|
+
}
|
|
218
|
+
return issues;
|
|
219
|
+
}
|
|
220
|
+
function findOldOrDeprecatedFilesNotInDocs(ctx, docsConcat) {
|
|
221
|
+
const issues = [];
|
|
222
|
+
for (const file of ctx.inventory.files) {
|
|
223
|
+
const segments = file.relativePath.split("/");
|
|
224
|
+
const hasOldSegment = segments.slice(0, -1).some((seg) => OLD_DIR_SEGMENT_PATTERN.test(seg));
|
|
225
|
+
if (!hasOldSegment)
|
|
226
|
+
continue;
|
|
227
|
+
if (docsConcat.includes(file.relativePath) || docsConcat.includes(path.basename(file.relativePath)))
|
|
228
|
+
continue;
|
|
229
|
+
issues.push(makeCodeRotIssue({
|
|
230
|
+
auditType: "code-rot",
|
|
231
|
+
detectorId: DETECTOR_ID,
|
|
232
|
+
idCues: ["old-deprecated-file-not-in-docs", file.relativePath],
|
|
233
|
+
title: `File under an old/deprecated/legacy directory is not mentioned in docs: "${file.relativePath}"`,
|
|
234
|
+
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.`,
|
|
235
|
+
severity: "medium",
|
|
236
|
+
confidence: "medium",
|
|
237
|
+
falsePositiveRisk: "low",
|
|
238
|
+
category: "dead-code-candidate",
|
|
239
|
+
recommendedAction: "Confirm the file is genuinely obsolete, then remove it or document why it is retained.",
|
|
240
|
+
suggestedFixStrategy: `Remove "${file.relativePath}" or add a doc note explaining why it is kept.`,
|
|
241
|
+
validationCommands: ["npm run audit -- --types code-rot --include cli"],
|
|
242
|
+
releaseBlocking: false,
|
|
243
|
+
implementationBlocking: false,
|
|
244
|
+
autoFixEligible: false,
|
|
245
|
+
evidence: [
|
|
246
|
+
{
|
|
247
|
+
kind: "file",
|
|
248
|
+
message: "File is under an old/deprecated/legacy/_archive-named directory and is not mentioned in any doc file.",
|
|
249
|
+
filePath: file.relativePath,
|
|
250
|
+
source: DETECTOR_ID,
|
|
251
|
+
confidence: "medium",
|
|
252
|
+
},
|
|
253
|
+
],
|
|
254
|
+
affectedFiles: [file.relativePath],
|
|
255
|
+
}));
|
|
256
|
+
}
|
|
257
|
+
return issues;
|
|
258
|
+
}
|
|
259
|
+
function findOrphanedGeneratedFiles(files) {
|
|
260
|
+
const issues = [];
|
|
261
|
+
for (const file of files) {
|
|
262
|
+
if (file.likelyGenerated)
|
|
263
|
+
continue; // already caught by inventory's own generated category
|
|
264
|
+
if (!ORPHAN_GENERATED_PATTERN.test(path.basename(file.relativePath)))
|
|
265
|
+
continue;
|
|
266
|
+
issues.push(makeCodeRotIssue({
|
|
267
|
+
auditType: "code-rot",
|
|
268
|
+
detectorId: DETECTOR_ID,
|
|
269
|
+
idCues: ["orphaned-generated-looking-file", file.relativePath],
|
|
270
|
+
title: `Orphaned generated-looking file: "${file.relativePath}"`,
|
|
271
|
+
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.`,
|
|
272
|
+
severity: "low",
|
|
273
|
+
confidence: "medium",
|
|
274
|
+
falsePositiveRisk: "medium",
|
|
275
|
+
category: "dead-code-candidate",
|
|
276
|
+
recommendedAction: "Confirm the file is stale generated/backup content, then remove it.",
|
|
277
|
+
suggestedFixStrategy: `Remove "${file.relativePath}" if confirmed stale.`,
|
|
278
|
+
validationCommands: ["npm run audit -- --types code-rot --include cli"],
|
|
279
|
+
releaseBlocking: false,
|
|
280
|
+
implementationBlocking: false,
|
|
281
|
+
autoFixEligible: false,
|
|
282
|
+
evidence: [
|
|
283
|
+
{
|
|
284
|
+
kind: "file",
|
|
285
|
+
message: "Filename matches a generated/backup-looking pattern outside the inventory's own generated-file classification.",
|
|
286
|
+
filePath: file.relativePath,
|
|
287
|
+
source: DETECTOR_ID,
|
|
288
|
+
confidence: "medium",
|
|
289
|
+
},
|
|
290
|
+
],
|
|
291
|
+
affectedFiles: [file.relativePath],
|
|
292
|
+
}));
|
|
293
|
+
}
|
|
294
|
+
return issues;
|
|
295
|
+
}
|
|
296
|
+
// Housed here (not in testRotDetector.ts) per the Batch 4 spec's explicit
|
|
297
|
+
// instruction to implement this check exactly once to avoid duplicate
|
|
298
|
+
// reporting between the two detectors.
|
|
299
|
+
function findUnreferencedFixtures(ctx) {
|
|
300
|
+
const fixtureFiles = ctx.inventory.files.filter((f) => f.relativePath.includes("/fixtures/") || f.relativePath.startsWith("fixtures/"));
|
|
301
|
+
if (fixtureFiles.length === 0)
|
|
302
|
+
return [];
|
|
303
|
+
const testFilesContent = [];
|
|
304
|
+
for (const testFile of ctx.inventory.testFiles) {
|
|
305
|
+
const content = readBoundedFileText(ctx.target.rootPath, testFile.relativePath, testFile.sizeBytes, MAX_READ_BYTES);
|
|
306
|
+
if (content !== null)
|
|
307
|
+
testFilesContent.push(content);
|
|
308
|
+
}
|
|
309
|
+
const combinedTestContent = testFilesContent.join("\n");
|
|
310
|
+
const issues = [];
|
|
311
|
+
for (const fixture of fixtureFiles) {
|
|
312
|
+
const base = path.basename(fixture.relativePath);
|
|
313
|
+
if (combinedTestContent.includes(base))
|
|
314
|
+
continue;
|
|
315
|
+
issues.push(makeCodeRotIssue({
|
|
316
|
+
auditType: "code-rot",
|
|
317
|
+
detectorId: DETECTOR_ID,
|
|
318
|
+
idCues: ["unreferenced-fixture", fixture.relativePath],
|
|
319
|
+
title: `Fixture "${fixture.relativePath}" is not referenced by any test file`,
|
|
320
|
+
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.`,
|
|
321
|
+
severity: "info",
|
|
322
|
+
confidence: "low",
|
|
323
|
+
falsePositiveRisk: "high",
|
|
324
|
+
category: "dead-code-candidate",
|
|
325
|
+
recommendedAction: "Confirm the fixture is genuinely unused before removing it.",
|
|
326
|
+
suggestedFixStrategy: `Remove "${fixture.relativePath}" if confirmed unused.`,
|
|
327
|
+
validationCommands: ["npm run audit -- --types code-rot --include tests"],
|
|
328
|
+
releaseBlocking: false,
|
|
329
|
+
implementationBlocking: false,
|
|
330
|
+
autoFixEligible: false,
|
|
331
|
+
evidence: [
|
|
332
|
+
{
|
|
333
|
+
kind: "observation",
|
|
334
|
+
message: "Fixture basename does not appear in any test file's bounded content scan.",
|
|
335
|
+
filePath: fixture.relativePath,
|
|
336
|
+
source: DETECTOR_ID,
|
|
337
|
+
confidence: "low",
|
|
338
|
+
},
|
|
339
|
+
],
|
|
340
|
+
affectedFiles: [fixture.relativePath],
|
|
341
|
+
}));
|
|
342
|
+
}
|
|
343
|
+
return issues;
|
|
344
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { readBoundedFileText } from "../utils/boundedRead.js";
|
|
2
|
+
import { deduplicateIssuesById, makeCodeRotIssue } from "../utils/issueFactories.js";
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// v0.3.0 Batch 4 — dependency/environment rot detector.
|
|
5
|
+
//
|
|
6
|
+
// Purely local: reads already-collected sourceOfTruth plus a bounded direct
|
|
7
|
+
// read of docs content. No npm install/audit/registry calls and no
|
|
8
|
+
// subprocess execution anywhere in this file (enforced by a structural
|
|
9
|
+
// test, see dependencyEnvironmentRotDetector.test.ts).
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
const DETECTOR_ID = "dependency-environment-rot";
|
|
12
|
+
const MAX_READ_BYTES = 200_000;
|
|
13
|
+
const OTHER_PACKAGE_MANAGER_INSTALL_PATTERN = /\b(yarn add|pnpm add|pnpm install)\b/gi;
|
|
14
|
+
const NPM_INSTALL_NEARBY_PATTERN = /\bnpm (install|ci)\b/i;
|
|
15
|
+
const NODE_POLICY_CLAIM_PATTERN = /latest[- ]two|node (?:lts )?policy/i;
|
|
16
|
+
const OPTIONAL_TOOL_PATTERN = /\b(codeql|semgrep|osv-scanner)\b/gi;
|
|
17
|
+
export const DEPENDENCY_ENVIRONMENT_ROT_DETECTOR = {
|
|
18
|
+
id: DETECTOR_ID,
|
|
19
|
+
auditType: "code-rot",
|
|
20
|
+
title: "Dependency/environment rot",
|
|
21
|
+
description: "Detects package-manager instruction mismatches against the lockfile, Node engine/CI version mismatches, unverifiable Node-policy claims, and scripts invoking optional external tools that are undocumented.",
|
|
22
|
+
supportedIncludeAreas: ["package", "docs", "cli"],
|
|
23
|
+
shouldSkip: (ctx) => {
|
|
24
|
+
if (!ctx.config.include.includes("package")) {
|
|
25
|
+
return { skip: true, reason: "--include does not select package; this detector's primary signal is package.json/lockfile/CI metadata." };
|
|
26
|
+
}
|
|
27
|
+
return { skip: false };
|
|
28
|
+
},
|
|
29
|
+
run: (ctx) => {
|
|
30
|
+
const issues = [];
|
|
31
|
+
const pkg = ctx.sourceOfTruth.package;
|
|
32
|
+
if (!pkg)
|
|
33
|
+
return issues;
|
|
34
|
+
if (ctx.config.include.includes("docs")) {
|
|
35
|
+
const docsConcat = concatDocsContent(ctx);
|
|
36
|
+
issues.push(...findPackageManagerMismatch(ctx, docsConcat));
|
|
37
|
+
issues.push(...findNodePolicyClaimMismatch(ctx, docsConcat));
|
|
38
|
+
issues.push(...findUndocumentedOptionalTools(ctx, docsConcat));
|
|
39
|
+
}
|
|
40
|
+
issues.push(...findNodeEngineCiMismatch(ctx));
|
|
41
|
+
return deduplicateIssuesById(issues);
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
function concatDocsContent(ctx) {
|
|
45
|
+
const parts = [];
|
|
46
|
+
for (const docFile of ctx.inventory.docsFiles) {
|
|
47
|
+
const content = readBoundedFileText(ctx.target.rootPath, docFile.relativePath, docFile.sizeBytes, MAX_READ_BYTES);
|
|
48
|
+
if (content !== null)
|
|
49
|
+
parts.push({ relativePath: docFile.relativePath, content });
|
|
50
|
+
}
|
|
51
|
+
return parts;
|
|
52
|
+
}
|
|
53
|
+
function findPackageManagerMismatch(ctx, docs) {
|
|
54
|
+
if (!ctx.sourceOfTruth.lockfile.present)
|
|
55
|
+
return [];
|
|
56
|
+
const issues = [];
|
|
57
|
+
for (const doc of docs) {
|
|
58
|
+
OTHER_PACKAGE_MANAGER_INSTALL_PATTERN.lastIndex = 0;
|
|
59
|
+
for (const match of doc.content.matchAll(OTHER_PACKAGE_MANAGER_INSTALL_PATTERN)) {
|
|
60
|
+
const matchIndex = match.index ?? 0;
|
|
61
|
+
const windowStart = Math.max(0, matchIndex - 150);
|
|
62
|
+
const windowEnd = Math.min(doc.content.length, matchIndex + match[0].length + 150);
|
|
63
|
+
const window = doc.content.slice(windowStart, windowEnd);
|
|
64
|
+
if (NPM_INSTALL_NEARBY_PATTERN.test(window))
|
|
65
|
+
continue;
|
|
66
|
+
issues.push(makeCodeRotIssue({
|
|
67
|
+
auditType: "code-rot",
|
|
68
|
+
detectorId: DETECTOR_ID,
|
|
69
|
+
idCues: [doc.relativePath, "package-manager-mismatch", match[0].toLowerCase()],
|
|
70
|
+
title: `${doc.relativePath} instructs "${match[0]}" as install method, but package-lock.json (npm) is present`,
|
|
71
|
+
description: `${doc.relativePath} mentions "${match[0]}" as an install instruction with no nearby "npm install"/"npm ci" mention, but this project has an npm package-lock.json as its lockfile.`,
|
|
72
|
+
severity: "medium",
|
|
73
|
+
confidence: "medium",
|
|
74
|
+
falsePositiveRisk: "medium",
|
|
75
|
+
category: "dependency-environment-rot",
|
|
76
|
+
recommendedAction: `Update ${doc.relativePath} to instruct "npm install" or "npm ci" instead.`,
|
|
77
|
+
suggestedFixStrategy: `Replace "${match[0]}" with the npm equivalent in ${doc.relativePath}.`,
|
|
78
|
+
validationCommands: ["npm run audit -- --types code-rot --include package,docs"],
|
|
79
|
+
releaseBlocking: false,
|
|
80
|
+
implementationBlocking: false,
|
|
81
|
+
autoFixEligible: false,
|
|
82
|
+
evidence: [
|
|
83
|
+
{
|
|
84
|
+
kind: "file",
|
|
85
|
+
message: `Doc references "${match[0]}" without a nearby npm install/ci mention, while package-lock.json (npm) is present.`,
|
|
86
|
+
filePath: doc.relativePath,
|
|
87
|
+
source: DETECTOR_ID,
|
|
88
|
+
confidence: "medium",
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
affectedFiles: [doc.relativePath, "package-lock.json"],
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return issues;
|
|
96
|
+
}
|
|
97
|
+
function findNodeEngineCiMismatch(ctx) {
|
|
98
|
+
const engines = ctx.sourceOfTruth.package?.engines;
|
|
99
|
+
const nodeEngine = engines?.node;
|
|
100
|
+
if (!nodeEngine)
|
|
101
|
+
return [];
|
|
102
|
+
const minMatch = nodeEngine.match(/>=?\s*(\d+)/);
|
|
103
|
+
if (!minMatch)
|
|
104
|
+
return [];
|
|
105
|
+
const minVersion = Number(minMatch[1]);
|
|
106
|
+
const issues = [];
|
|
107
|
+
for (const workflow of ctx.sourceOfTruth.ci.workflows) {
|
|
108
|
+
for (const versionStr of workflow.nodeVersionsReferenced) {
|
|
109
|
+
const versionNum = Number(versionStr);
|
|
110
|
+
if (Number.isNaN(versionNum))
|
|
111
|
+
continue;
|
|
112
|
+
if (versionNum >= minVersion)
|
|
113
|
+
continue;
|
|
114
|
+
issues.push(makeCodeRotIssue({
|
|
115
|
+
auditType: "code-rot",
|
|
116
|
+
detectorId: DETECTOR_ID,
|
|
117
|
+
idCues: [workflow.relativePath, "node-engine-ci-mismatch", versionStr],
|
|
118
|
+
title: `CI workflow "${workflow.relativePath}" uses Node ${versionStr}, below package.json's engines.node minimum (${nodeEngine})`,
|
|
119
|
+
description: `package.json declares "engines.node": "${nodeEngine}" (minimum ${minVersion}), but ${workflow.relativePath} references Node ${versionStr}.`,
|
|
120
|
+
severity: "medium",
|
|
121
|
+
confidence: "high",
|
|
122
|
+
falsePositiveRisk: "low",
|
|
123
|
+
category: "dependency-environment-rot",
|
|
124
|
+
recommendedAction: `Update ${workflow.relativePath}'s Node version matrix or relax package.json's engines.node constraint.`,
|
|
125
|
+
suggestedFixStrategy: `Align the Node version in ${workflow.relativePath} with package.json's "engines.node" field.`,
|
|
126
|
+
validationCommands: ["npm run audit -- --types code-rot --include package"],
|
|
127
|
+
releaseBlocking: false,
|
|
128
|
+
implementationBlocking: false,
|
|
129
|
+
autoFixEligible: false,
|
|
130
|
+
evidence: [
|
|
131
|
+
{
|
|
132
|
+
kind: "file",
|
|
133
|
+
message: `CI Node version (${versionStr}) is below package.json's engines.node minimum (${minVersion}).`,
|
|
134
|
+
filePath: workflow.relativePath,
|
|
135
|
+
expected: nodeEngine,
|
|
136
|
+
observed: versionStr,
|
|
137
|
+
source: DETECTOR_ID,
|
|
138
|
+
confidence: "high",
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
affectedFiles: [workflow.relativePath, "package.json"],
|
|
142
|
+
}));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return issues;
|
|
146
|
+
}
|
|
147
|
+
function findNodePolicyClaimMismatch(ctx, docs) {
|
|
148
|
+
const allWorkflowVersions = new Set();
|
|
149
|
+
for (const workflow of ctx.sourceOfTruth.ci.workflows) {
|
|
150
|
+
for (const v of workflow.nodeVersionsReferenced)
|
|
151
|
+
allWorkflowVersions.add(v);
|
|
152
|
+
}
|
|
153
|
+
// Only flag when the claim is checkable in a low-noise way: CI has exactly
|
|
154
|
+
// one pinned version total, yet docs explicitly claim a multi-version
|
|
155
|
+
// policy.
|
|
156
|
+
if (allWorkflowVersions.size !== 1)
|
|
157
|
+
return [];
|
|
158
|
+
const issues = [];
|
|
159
|
+
for (const doc of docs) {
|
|
160
|
+
if (!NODE_POLICY_CLAIM_PATTERN.test(doc.content))
|
|
161
|
+
continue;
|
|
162
|
+
issues.push(makeCodeRotIssue({
|
|
163
|
+
auditType: "code-rot",
|
|
164
|
+
detectorId: DETECTOR_ID,
|
|
165
|
+
idCues: [doc.relativePath, "node-policy-claim-mismatch"],
|
|
166
|
+
title: `${doc.relativePath} claims a multi-version Node policy, but CI pins exactly one Node version`,
|
|
167
|
+
description: `${doc.relativePath} mentions a "latest two"/Node LTS policy claim, but CI workflows collectively reference exactly one Node version (${[...allWorkflowVersions][0]}).`,
|
|
168
|
+
severity: "medium",
|
|
169
|
+
confidence: "medium",
|
|
170
|
+
falsePositiveRisk: "medium",
|
|
171
|
+
category: "dependency-environment-rot",
|
|
172
|
+
recommendedAction: `Update ${doc.relativePath} or expand the CI Node version matrix to match the claim.`,
|
|
173
|
+
suggestedFixStrategy: `Align the Node-version policy claim in ${doc.relativePath} with the actual CI matrix.`,
|
|
174
|
+
validationCommands: ["npm run audit -- --types code-rot --include package,docs"],
|
|
175
|
+
releaseBlocking: false,
|
|
176
|
+
implementationBlocking: false,
|
|
177
|
+
autoFixEligible: false,
|
|
178
|
+
evidence: [
|
|
179
|
+
{
|
|
180
|
+
kind: "observation",
|
|
181
|
+
message: "Doc claims a multi-version Node policy while CI references exactly one Node version.",
|
|
182
|
+
filePath: doc.relativePath,
|
|
183
|
+
source: DETECTOR_ID,
|
|
184
|
+
confidence: "medium",
|
|
185
|
+
},
|
|
186
|
+
],
|
|
187
|
+
affectedFiles: [doc.relativePath],
|
|
188
|
+
}));
|
|
189
|
+
}
|
|
190
|
+
return issues;
|
|
191
|
+
}
|
|
192
|
+
function findUndocumentedOptionalTools(ctx, docs) {
|
|
193
|
+
const pkg = ctx.sourceOfTruth.package;
|
|
194
|
+
if (!pkg)
|
|
195
|
+
return [];
|
|
196
|
+
const mentionedTools = new Set();
|
|
197
|
+
for (const [scriptName, command] of Object.entries(pkg.scripts)) {
|
|
198
|
+
OPTIONAL_TOOL_PATTERN.lastIndex = 0;
|
|
199
|
+
for (const match of command.matchAll(OPTIONAL_TOOL_PATTERN)) {
|
|
200
|
+
mentionedTools.add(`${match[1].toLowerCase()}::${scriptName}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (mentionedTools.size === 0)
|
|
204
|
+
return [];
|
|
205
|
+
const combinedDocsLower = docs.map((d) => d.content).join("\n").toLowerCase();
|
|
206
|
+
const issues = [];
|
|
207
|
+
const seenTools = new Set();
|
|
208
|
+
for (const entry of mentionedTools) {
|
|
209
|
+
const [tool, scriptName] = entry.split("::");
|
|
210
|
+
if (seenTools.has(tool))
|
|
211
|
+
continue;
|
|
212
|
+
if (combinedDocsLower.includes(tool))
|
|
213
|
+
continue;
|
|
214
|
+
seenTools.add(tool);
|
|
215
|
+
issues.push(makeCodeRotIssue({
|
|
216
|
+
auditType: "code-rot",
|
|
217
|
+
detectorId: DETECTOR_ID,
|
|
218
|
+
idCues: ["undocumented-optional-tool", tool],
|
|
219
|
+
title: `Script "${scriptName}" invokes external tool "${tool}", which is not mentioned in any doc`,
|
|
220
|
+
description: `package.json script "${scriptName}" invokes "${tool}", but no doc file mentions it as optional or required.`,
|
|
221
|
+
severity: "low",
|
|
222
|
+
confidence: "low",
|
|
223
|
+
falsePositiveRisk: "medium",
|
|
224
|
+
category: "dependency-environment-rot",
|
|
225
|
+
recommendedAction: `Document "${tool}" as an optional/required tool in the relevant docs.`,
|
|
226
|
+
suggestedFixStrategy: `Add a short mention of "${tool}" (optional/required, and how it's skipped when unavailable) to docs/COMMANDS.md.`,
|
|
227
|
+
validationCommands: ["npm run audit -- --types code-rot --include package,docs"],
|
|
228
|
+
releaseBlocking: false,
|
|
229
|
+
implementationBlocking: false,
|
|
230
|
+
autoFixEligible: false,
|
|
231
|
+
evidence: [
|
|
232
|
+
{
|
|
233
|
+
kind: "observation",
|
|
234
|
+
message: `"${tool}" appears in a package.json script command but not in any doc file.`,
|
|
235
|
+
filePath: "package.json",
|
|
236
|
+
source: DETECTOR_ID,
|
|
237
|
+
confidence: "low",
|
|
238
|
+
},
|
|
239
|
+
],
|
|
240
|
+
affectedFiles: ["package.json"],
|
|
241
|
+
}));
|
|
242
|
+
}
|
|
243
|
+
return issues;
|
|
244
|
+
}
|