@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,348 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { readBoundedFileText } from "../utils/boundedRead.js";
|
|
4
|
+
import { deduplicateIssuesById, makeCodeRotIssue } from "../utils/issueFactories.js";
|
|
5
|
+
import { splitLines } from "../utils/textLines.js";
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// v0.3.0 Batch 4 — test-rot detector.
|
|
8
|
+
//
|
|
9
|
+
// Bounded (per-file MAX_READ_BYTES skip), regex/structural only.
|
|
10
|
+
//
|
|
11
|
+
// Two spec bullets are intentionally SKIPPED here to avoid duplicate
|
|
12
|
+
// reporting with other detectors (documented in the Batch 4 spec itself):
|
|
13
|
+
// - "fixtures not referenced by tests" is implemented once, in
|
|
14
|
+
// deadCodeCandidateDetector.ts's findUnreferencedFixtures(), not here.
|
|
15
|
+
// - "test commands documented in docs but missing from package.json
|
|
16
|
+
// scripts" overlaps with staleCommandReferenceDetector.ts's existing
|
|
17
|
+
// doc-command check and is intentionally not re-implemented here.
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
const DETECTOR_ID = "test-rot";
|
|
20
|
+
const MAX_READ_BYTES = 200_000;
|
|
21
|
+
const RELATIVE_IMPORT_PATTERN = /(?:from|require\()\s*["'](\.\.?\/[^"']+)["']/g;
|
|
22
|
+
const NPM_RUN_PATTERN = /npm run ([\w:.-]+)/g;
|
|
23
|
+
const SKIP_PATTERN = /\b(describe|it|test)\.skip\s*\(/g;
|
|
24
|
+
const ONLY_PATTERN = /\b(describe|it|test)\.only\s*\(/g;
|
|
25
|
+
const TEST_TITLE_VERSION_PATTERN = /\b(describe|it|test)\s*\(\s*["'`][^"'`]*\bv(\d+\.\d+\.\d+)\b/;
|
|
26
|
+
const HISTORICAL_HEDGE_PATTERN = /\b(regression|historical|legacy)\b/i;
|
|
27
|
+
const RESOLVE_EXTENSIONS = ["", ".ts", ".tsx", ".js", ".mjs", ".cjs"];
|
|
28
|
+
const VITEST_RUN_ARG_PATTERN = /vitest run ([^\s&|]+)/g;
|
|
29
|
+
export const TEST_ROT_DETECTOR = {
|
|
30
|
+
id: DETECTOR_ID,
|
|
31
|
+
auditType: "code-rot",
|
|
32
|
+
title: "Test rot",
|
|
33
|
+
description: "Detects test files importing missing source files, stale npm-run references, committed .skip/.only usage, stale version mentions in test titles, and package scripts referencing missing test paths.",
|
|
34
|
+
supportedIncludeAreas: ["tests", "cli", "package"],
|
|
35
|
+
shouldSkip: (ctx) => {
|
|
36
|
+
if (!ctx.config.include.includes("tests")) {
|
|
37
|
+
return { skip: true, reason: "--include does not select tests; this detector's checks are test-file-based." };
|
|
38
|
+
}
|
|
39
|
+
return { skip: false };
|
|
40
|
+
},
|
|
41
|
+
run: (ctx) => {
|
|
42
|
+
const issues = [];
|
|
43
|
+
const scriptNames = new Set(ctx.sourceOfTruth.commands.allScriptNames);
|
|
44
|
+
const currentVersion = ctx.sourceOfTruth.package?.version ?? null;
|
|
45
|
+
for (const testFile of ctx.inventory.testFiles) {
|
|
46
|
+
const content = readBoundedFileText(ctx.target.rootPath, testFile.relativePath, testFile.sizeBytes, MAX_READ_BYTES);
|
|
47
|
+
if (content === null)
|
|
48
|
+
continue;
|
|
49
|
+
// tests/audits/codeRot/ is this project's own audit-detector test
|
|
50
|
+
// suite. Its whole purpose is to construct FIXTURE content (via
|
|
51
|
+
// writeFile() string arguments) that mimics the exact textual
|
|
52
|
+
// patterns this file's checks look for -- fake relative imports,
|
|
53
|
+
// fake "npm run <command>" references, "describe.skip("/"it.only(",
|
|
54
|
+
// and version-numbered test titles like `it("... v0.2.1 ...")`.
|
|
55
|
+
// Scanning those fixture strings as if they were real test-file
|
|
56
|
+
// content produces false positives against this repo's own accurate
|
|
57
|
+
// tests (confirmed via self-testing on every one of the checks
|
|
58
|
+
// below). This whole subdirectory is therefore excluded from this
|
|
59
|
+
// detector's per-file checks -- a project without an audit-detector
|
|
60
|
+
// test suite of its own would never hit this exclusion.
|
|
61
|
+
if (testFile.relativePath.startsWith("tests/audits/codeRot/"))
|
|
62
|
+
continue;
|
|
63
|
+
issues.push(...findMissingSourceImports(ctx, testFile.relativePath, content));
|
|
64
|
+
issues.push(...findStaleNpmRunReferences(testFile.relativePath, content, scriptNames));
|
|
65
|
+
issues.push(...findSkipUsage(testFile.relativePath, content));
|
|
66
|
+
issues.push(...findOnlyUsage(testFile.relativePath, content));
|
|
67
|
+
if (currentVersion)
|
|
68
|
+
issues.push(...findStaleVersionMentions(testFile.relativePath, content, currentVersion));
|
|
69
|
+
}
|
|
70
|
+
if (ctx.config.include.includes("package") && ctx.sourceOfTruth.package) {
|
|
71
|
+
issues.push(...findScriptsReferencingMissingTestPaths(ctx));
|
|
72
|
+
}
|
|
73
|
+
return deduplicateIssuesById(issues);
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
function findMissingSourceImports(ctx, testRelativePath, content) {
|
|
77
|
+
const issues = [];
|
|
78
|
+
const testDir = path.dirname(testRelativePath);
|
|
79
|
+
RELATIVE_IMPORT_PATTERN.lastIndex = 0;
|
|
80
|
+
for (const match of content.matchAll(RELATIVE_IMPORT_PATTERN)) {
|
|
81
|
+
const specifier = match[1];
|
|
82
|
+
const resolvedBase = path.join(testDir, specifier).replace(/\\/g, "/");
|
|
83
|
+
const strippedBase = resolvedBase.replace(/\.[cm]?[jt]sx?$/, "");
|
|
84
|
+
const resolved = RESOLVE_EXTENSIONS.some((ext) => fs.existsSync(path.join(ctx.target.rootPath, `${strippedBase}${ext}`)));
|
|
85
|
+
if (resolved)
|
|
86
|
+
continue;
|
|
87
|
+
issues.push(makeCodeRotIssue({
|
|
88
|
+
auditType: "code-rot",
|
|
89
|
+
detectorId: DETECTOR_ID,
|
|
90
|
+
idCues: [testRelativePath, "missing-source-import", specifier],
|
|
91
|
+
title: `Test "${testRelativePath}" imports a missing source file: "${specifier}"`,
|
|
92
|
+
description: `${testRelativePath} imports "${specifier}", which does not resolve to an existing file on disk (checked common TS/JS extensions relative to the test file).`,
|
|
93
|
+
severity: "medium",
|
|
94
|
+
confidence: "high",
|
|
95
|
+
falsePositiveRisk: "low",
|
|
96
|
+
category: "test-rot",
|
|
97
|
+
recommendedAction: "Fix or remove the stale import.",
|
|
98
|
+
suggestedFixStrategy: `Update the import specifier "${specifier}" in ${testRelativePath} to point to an existing file.`,
|
|
99
|
+
validationCommands: [`npx vitest run ${testRelativePath}`],
|
|
100
|
+
releaseBlocking: false,
|
|
101
|
+
implementationBlocking: false,
|
|
102
|
+
autoFixEligible: false,
|
|
103
|
+
evidence: [
|
|
104
|
+
{
|
|
105
|
+
kind: "file",
|
|
106
|
+
message: "Relative import specifier does not resolve to an existing file.",
|
|
107
|
+
filePath: testRelativePath,
|
|
108
|
+
excerpt: specifier,
|
|
109
|
+
source: DETECTOR_ID,
|
|
110
|
+
confidence: "high",
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
affectedFiles: [testRelativePath],
|
|
114
|
+
}));
|
|
115
|
+
}
|
|
116
|
+
return issues;
|
|
117
|
+
}
|
|
118
|
+
function findStaleNpmRunReferences(testRelativePath, content, scriptNames) {
|
|
119
|
+
const issues = [];
|
|
120
|
+
const seen = new Set();
|
|
121
|
+
// Scanned per-line (not whole-content) so a line can be cheaply excluded
|
|
122
|
+
// when it's clearly constructing FIXTURE content for another detector's
|
|
123
|
+
// test (e.g. `writeFile(root, "README.md", "Run \`npm run fake-cmd\`
|
|
124
|
+
// ...")`) rather than a real reference to an npm command. This is the
|
|
125
|
+
// exact shape used throughout this repo's own audit-detector test files
|
|
126
|
+
// (see tests/audits/codeRot/*.test.ts) -- without this exclusion, this
|
|
127
|
+
// detector flags this repo's own accurate test suite as if its
|
|
128
|
+
// synthetic fixture strings were real stale command references, a false
|
|
129
|
+
// positive confirmed via self-testing.
|
|
130
|
+
const lines = splitLines(content);
|
|
131
|
+
for (const line of lines) {
|
|
132
|
+
if (/\bwriteFile\s*\(/.test(line))
|
|
133
|
+
continue;
|
|
134
|
+
NPM_RUN_PATTERN.lastIndex = 0;
|
|
135
|
+
for (const match of line.matchAll(NPM_RUN_PATTERN)) {
|
|
136
|
+
const scriptName = match[1];
|
|
137
|
+
if (scriptNames.has(scriptName) || seen.has(scriptName))
|
|
138
|
+
continue;
|
|
139
|
+
seen.add(scriptName);
|
|
140
|
+
issues.push(makeCodeRotIssue({
|
|
141
|
+
auditType: "code-rot",
|
|
142
|
+
detectorId: DETECTOR_ID,
|
|
143
|
+
idCues: [testRelativePath, "stale-npm-run-reference", scriptName],
|
|
144
|
+
title: `Test "${testRelativePath}" references "npm run ${scriptName}", which is not a current package.json script`,
|
|
145
|
+
description: `${testRelativePath} references "npm run ${scriptName}" but no such script exists in package.json.`,
|
|
146
|
+
severity: "medium",
|
|
147
|
+
confidence: "medium",
|
|
148
|
+
falsePositiveRisk: "medium",
|
|
149
|
+
category: "test-rot",
|
|
150
|
+
recommendedAction: "Update the test to reference a current script name, or add the missing script.",
|
|
151
|
+
suggestedFixStrategy: `Correct the "npm run ${scriptName}" reference in ${testRelativePath}.`,
|
|
152
|
+
validationCommands: [`npx vitest run ${testRelativePath}`],
|
|
153
|
+
releaseBlocking: false,
|
|
154
|
+
implementationBlocking: false,
|
|
155
|
+
autoFixEligible: false,
|
|
156
|
+
evidence: [
|
|
157
|
+
{
|
|
158
|
+
kind: "file",
|
|
159
|
+
message: `Referenced command "npm run ${scriptName}" not found in package.json scripts.`,
|
|
160
|
+
filePath: testRelativePath,
|
|
161
|
+
source: DETECTOR_ID,
|
|
162
|
+
confidence: "medium",
|
|
163
|
+
},
|
|
164
|
+
],
|
|
165
|
+
affectedFiles: [testRelativePath],
|
|
166
|
+
}));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return issues;
|
|
170
|
+
}
|
|
171
|
+
function findSkipUsage(testRelativePath, content) {
|
|
172
|
+
const issues = [];
|
|
173
|
+
const lines = splitLines(content);
|
|
174
|
+
for (let i = 0; i < lines.length; i++) {
|
|
175
|
+
SKIP_PATTERN.lastIndex = 0;
|
|
176
|
+
const match = SKIP_PATTERN.exec(lines[i]);
|
|
177
|
+
if (!match)
|
|
178
|
+
continue;
|
|
179
|
+
issues.push(makeCodeRotIssue({
|
|
180
|
+
auditType: "code-rot",
|
|
181
|
+
detectorId: DETECTOR_ID,
|
|
182
|
+
idCues: [testRelativePath, "skip-usage", String(i + 1)],
|
|
183
|
+
title: `Committed "${match[1]}.skip(" in "${testRelativePath}"`,
|
|
184
|
+
description: `${testRelativePath}:${i + 1} uses "${match[1]}.skip(" -- confirm this is intentional and not accidentally committed.`,
|
|
185
|
+
severity: "medium",
|
|
186
|
+
confidence: "high",
|
|
187
|
+
falsePositiveRisk: "low",
|
|
188
|
+
category: "test-rot",
|
|
189
|
+
recommendedAction: "Remove .skip if the test should run, or add a comment explaining why it is skipped.",
|
|
190
|
+
suggestedFixStrategy: `Remove "${match[1]}.skip(" at ${testRelativePath}:${i + 1} or document why it is intentionally skipped.`,
|
|
191
|
+
validationCommands: [`npx vitest run ${testRelativePath}`],
|
|
192
|
+
releaseBlocking: false,
|
|
193
|
+
implementationBlocking: false,
|
|
194
|
+
autoFixEligible: false,
|
|
195
|
+
evidence: [
|
|
196
|
+
{
|
|
197
|
+
kind: "file",
|
|
198
|
+
message: `Found "${match[1]}.skip(" usage.`,
|
|
199
|
+
filePath: testRelativePath,
|
|
200
|
+
line: i + 1,
|
|
201
|
+
excerpt: lines[i].trim().slice(0, 200),
|
|
202
|
+
source: DETECTOR_ID,
|
|
203
|
+
confidence: "high",
|
|
204
|
+
},
|
|
205
|
+
],
|
|
206
|
+
affectedFiles: [testRelativePath],
|
|
207
|
+
}));
|
|
208
|
+
}
|
|
209
|
+
return issues;
|
|
210
|
+
}
|
|
211
|
+
function findOnlyUsage(testRelativePath, content) {
|
|
212
|
+
const issues = [];
|
|
213
|
+
const lines = splitLines(content);
|
|
214
|
+
for (let i = 0; i < lines.length; i++) {
|
|
215
|
+
ONLY_PATTERN.lastIndex = 0;
|
|
216
|
+
const match = ONLY_PATTERN.exec(lines[i]);
|
|
217
|
+
if (!match)
|
|
218
|
+
continue;
|
|
219
|
+
issues.push(makeCodeRotIssue({
|
|
220
|
+
auditType: "code-rot",
|
|
221
|
+
detectorId: DETECTOR_ID,
|
|
222
|
+
idCues: [testRelativePath, "only-usage", String(i + 1)],
|
|
223
|
+
title: `Committed "${match[1]}.only(" in "${testRelativePath}"`,
|
|
224
|
+
description: `${testRelativePath}:${i + 1} uses "${match[1]}.only(" -- this silently excludes every other test in a CI run and is almost always accidental.`,
|
|
225
|
+
severity: "high",
|
|
226
|
+
confidence: "high",
|
|
227
|
+
falsePositiveRisk: "low",
|
|
228
|
+
category: "test-rot",
|
|
229
|
+
recommendedAction: "Remove .only before committing/merging.",
|
|
230
|
+
suggestedFixStrategy: `Remove "${match[1]}.only(" at ${testRelativePath}:${i + 1}.`,
|
|
231
|
+
validationCommands: [`npx vitest run ${testRelativePath}`],
|
|
232
|
+
releaseBlocking: true,
|
|
233
|
+
implementationBlocking: false,
|
|
234
|
+
autoFixEligible: false,
|
|
235
|
+
evidence: [
|
|
236
|
+
{
|
|
237
|
+
kind: "file",
|
|
238
|
+
message: `Found "${match[1]}.only(" usage.`,
|
|
239
|
+
filePath: testRelativePath,
|
|
240
|
+
line: i + 1,
|
|
241
|
+
excerpt: lines[i].trim().slice(0, 200),
|
|
242
|
+
source: DETECTOR_ID,
|
|
243
|
+
confidence: "high",
|
|
244
|
+
},
|
|
245
|
+
],
|
|
246
|
+
affectedFiles: [testRelativePath],
|
|
247
|
+
}));
|
|
248
|
+
}
|
|
249
|
+
return issues;
|
|
250
|
+
}
|
|
251
|
+
function findStaleVersionMentions(testRelativePath, content, currentVersion) {
|
|
252
|
+
const issues = [];
|
|
253
|
+
const lines = splitLines(content);
|
|
254
|
+
for (let i = 0; i < lines.length; i++) {
|
|
255
|
+
const line = lines[i];
|
|
256
|
+
const match = line.match(TEST_TITLE_VERSION_PATTERN);
|
|
257
|
+
if (!match)
|
|
258
|
+
continue;
|
|
259
|
+
const mentionedVersion = match[2];
|
|
260
|
+
if (mentionedVersion === currentVersion)
|
|
261
|
+
continue;
|
|
262
|
+
if (HISTORICAL_HEDGE_PATTERN.test(line))
|
|
263
|
+
continue;
|
|
264
|
+
issues.push(makeCodeRotIssue({
|
|
265
|
+
auditType: "code-rot",
|
|
266
|
+
detectorId: DETECTOR_ID,
|
|
267
|
+
idCues: [testRelativePath, "stale-version-mention", String(i + 1), mentionedVersion],
|
|
268
|
+
title: `Test title in "${testRelativePath}" mentions version v${mentionedVersion}, not the current package version`,
|
|
269
|
+
description: `${testRelativePath}:${i + 1} mentions "v${mentionedVersion}" in a test title, but the current package version is "${currentVersion}". If this is intentionally a historical/regression reference, mention that in the title.`,
|
|
270
|
+
severity: "low",
|
|
271
|
+
confidence: "low",
|
|
272
|
+
falsePositiveRisk: "medium",
|
|
273
|
+
category: "test-rot",
|
|
274
|
+
recommendedAction: "Confirm whether the version mention should be updated or is intentionally historical.",
|
|
275
|
+
suggestedFixStrategy: `Update the test title in ${testRelativePath} or add "regression"/"historical" wording if intentional.`,
|
|
276
|
+
validationCommands: [`npx vitest run ${testRelativePath}`],
|
|
277
|
+
releaseBlocking: false,
|
|
278
|
+
implementationBlocking: false,
|
|
279
|
+
autoFixEligible: false,
|
|
280
|
+
evidence: [
|
|
281
|
+
{
|
|
282
|
+
kind: "file",
|
|
283
|
+
message: `Test title mentions "v${mentionedVersion}", current package version is "${currentVersion}".`,
|
|
284
|
+
filePath: testRelativePath,
|
|
285
|
+
line: i + 1,
|
|
286
|
+
excerpt: line.trim().slice(0, 200),
|
|
287
|
+
source: DETECTOR_ID,
|
|
288
|
+
confidence: "low",
|
|
289
|
+
},
|
|
290
|
+
],
|
|
291
|
+
affectedFiles: [testRelativePath],
|
|
292
|
+
}));
|
|
293
|
+
}
|
|
294
|
+
return issues;
|
|
295
|
+
}
|
|
296
|
+
function findScriptsReferencingMissingTestPaths(ctx) {
|
|
297
|
+
const issues = [];
|
|
298
|
+
const pkg = ctx.sourceOfTruth.package;
|
|
299
|
+
if (!pkg)
|
|
300
|
+
return issues;
|
|
301
|
+
const knownPaths = new Set(ctx.inventory.files.map((f) => f.relativePath));
|
|
302
|
+
const knownDirPrefixes = new Set(ctx.inventory.files.flatMap((f) => {
|
|
303
|
+
const parts = f.relativePath.split("/");
|
|
304
|
+
const prefixes = [];
|
|
305
|
+
for (let i = 1; i < parts.length; i++)
|
|
306
|
+
prefixes.push(parts.slice(0, i).join("/"));
|
|
307
|
+
return prefixes;
|
|
308
|
+
}));
|
|
309
|
+
for (const [scriptName, command] of Object.entries(pkg.scripts)) {
|
|
310
|
+
VITEST_RUN_ARG_PATTERN.lastIndex = 0;
|
|
311
|
+
for (const match of command.matchAll(VITEST_RUN_ARG_PATTERN)) {
|
|
312
|
+
const referencedPath = match[1].replace(/^["']|["']$/g, "");
|
|
313
|
+
if (knownPaths.has(referencedPath) || knownDirPrefixes.has(referencedPath))
|
|
314
|
+
continue;
|
|
315
|
+
if (fs.existsSync(path.join(ctx.target.rootPath, referencedPath)))
|
|
316
|
+
continue;
|
|
317
|
+
issues.push(makeCodeRotIssue({
|
|
318
|
+
auditType: "code-rot",
|
|
319
|
+
detectorId: DETECTOR_ID,
|
|
320
|
+
idCues: ["script-references-missing-test-path", scriptName, referencedPath],
|
|
321
|
+
title: `Script "${scriptName}" references a test path that does not exist: "${referencedPath}"`,
|
|
322
|
+
description: `package.json script "${scriptName}" runs "vitest run ${referencedPath}", but "${referencedPath}" does not exist in the project.`,
|
|
323
|
+
severity: "medium",
|
|
324
|
+
confidence: "high",
|
|
325
|
+
falsePositiveRisk: "low",
|
|
326
|
+
category: "test-rot",
|
|
327
|
+
recommendedAction: "Fix the script's test path, or restore/remove the referenced tests.",
|
|
328
|
+
suggestedFixStrategy: `Edit package.json's "${scriptName}" script to reference an existing test path.`,
|
|
329
|
+
validationCommands: [`npm run ${scriptName}`],
|
|
330
|
+
releaseBlocking: false,
|
|
331
|
+
implementationBlocking: false,
|
|
332
|
+
autoFixEligible: false,
|
|
333
|
+
evidence: [
|
|
334
|
+
{
|
|
335
|
+
kind: "file",
|
|
336
|
+
message: `Script command references a test path that does not exist.`,
|
|
337
|
+
filePath: "package.json",
|
|
338
|
+
excerpt: `"${scriptName}": "... vitest run ${referencedPath} ..."`,
|
|
339
|
+
source: DETECTOR_ID,
|
|
340
|
+
confidence: "high",
|
|
341
|
+
},
|
|
342
|
+
],
|
|
343
|
+
affectedFiles: ["package.json"],
|
|
344
|
+
}));
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return issues;
|
|
348
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// v0.3.0 Batch 4 — shared bounded file-read helper.
|
|
5
|
+
//
|
|
6
|
+
// Every Batch 3 detector duplicated its own local readBoundedText() helper.
|
|
7
|
+
// Batch 4 factors the identical logic out once so the seven new detectors
|
|
8
|
+
// don't each re-implement it. Behavior is unchanged: returns null (never
|
|
9
|
+
// throws) for an oversized file or an unreadable path.
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
export function readBoundedFileText(root, relativePath, sizeBytes, maxBytes) {
|
|
12
|
+
if (sizeBytes > maxBytes)
|
|
13
|
+
return null;
|
|
14
|
+
try {
|
|
15
|
+
return fs.readFileSync(path.join(root, relativePath), "utf8");
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function escapeRegExp(value) {
|
|
22
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
23
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// v0.3.0 Batch 3 — deterministic doc-claim regex patterns.
|
|
3
|
+
//
|
|
4
|
+
// Shared by docsCodeMismatchDetector.ts and packageReleaseRotDetector.ts.
|
|
5
|
+
// Every pattern here matches a narrow, explicit phrase rather than inferring
|
|
6
|
+
// meaning from prose -- per Batch 3 scope, no LLM and no broad semantic
|
|
7
|
+
// interpretation. Ambiguous or ISF-style hedged wording ("not yet
|
|
8
|
+
// published", "planned for a later version") deliberately does not match
|
|
9
|
+
// these "unhedged claim" patterns.
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// An unhedged claim that publication/release/tagging has already happened.
|
|
12
|
+
// A nearby hedge word ("not yet", "has not", "no") should be checked by the
|
|
13
|
+
// caller before treating a match as a real claim -- see
|
|
14
|
+
// isHedgedNearby() below.
|
|
15
|
+
export const PUBLISHED_OR_RELEASED_CLAIM_PATTERN = /\b(is now published|has been published to npm|npm publish (?:has |)(?:completed|succeeded)|published to npm|github release (?:has been created|exists|created)|release has been created|has been tagged|git tag (?:has been created|created))\b/i;
|
|
16
|
+
// Common hedge words that, if present in the same line/window, indicate the
|
|
17
|
+
// author is explicitly describing something as NOT having happened yet --
|
|
18
|
+
// used to suppress a false match on "is now published" when the actual
|
|
19
|
+
// sentence is "... has not been published to npm" etc.
|
|
20
|
+
const HEDGE_PATTERN = /\b(not yet|has not|have not|hasn't|haven't|no(?:t)?\b.{0,20}\byet\b|never)\b/i;
|
|
21
|
+
export function isHedgedNearby(windowText) {
|
|
22
|
+
return HEDGE_PATTERN.test(windowText);
|
|
23
|
+
}
|
|
24
|
+
// Matches "current package is v1.2.3" / "current version is `1.2.3`" /
|
|
25
|
+
// "package is version 1.2.3" style phrasing used across this project's own
|
|
26
|
+
// docs (README.md, docs/CURRENT_STATE.md, docs/PROJECT_OVERVIEW.md).
|
|
27
|
+
export const CURRENT_PACKAGE_VERSION_PATTERNS = [
|
|
28
|
+
/current package is `?v?(\d+\.\d+\.\d+)`?/i,
|
|
29
|
+
/current package (?:version )?(?:is|remains) `?v?(\d+\.\d+\.\d+)`?/i,
|
|
30
|
+
/package is version `?(\d+\.\d+\.\d+)`?/i,
|
|
31
|
+
];
|
|
32
|
+
// A scoped (non-full) --checks run being described as if it were a complete
|
|
33
|
+
// release gate. Matches the bare phrase; callers must additionally check
|
|
34
|
+
// isNegatedNearby() before treating a match as a real (mistaken) claim --
|
|
35
|
+
// this project's own docs correctly say things like "rather than described
|
|
36
|
+
// as a full release gate" and "it is not the same as a full release gate",
|
|
37
|
+
// which contain the phrase precisely BECAUSE they are correctly denying it.
|
|
38
|
+
export const FULL_RELEASE_GATE_FOR_SCOPED_CHECKS_PATTERN = /scoped[^.\n]{0,80}(full release gate|full audit coverage|complete release gate)/i;
|
|
39
|
+
// Negation cues that, appearing in the same sentence as a
|
|
40
|
+
// FULL_RELEASE_GATE_FOR_SCOPED_CHECKS_PATTERN match, indicate the sentence
|
|
41
|
+
// is correctly denying the claim rather than making it.
|
|
42
|
+
const NEGATION_PATTERN = /\b(not|rather than|isn't|is not|n't)\b/i;
|
|
43
|
+
export function isNegatedNearby(windowText) {
|
|
44
|
+
return NEGATION_PATTERN.test(windowText);
|
|
45
|
+
}
|
|
46
|
+
// Feature-implementation claim phrasing: "<subject> is implemented" /
|
|
47
|
+
// "<subject> is current" vs "<subject> is planned" / "<subject> is future".
|
|
48
|
+
//
|
|
49
|
+
// Deliberately narrow (subject-anchored, not a freestanding scan for
|
|
50
|
+
// "current"/"planned" anywhere in a line) -- this project's own docs
|
|
51
|
+
// legitimately use both words in the same sentence when correctly
|
|
52
|
+
// describing a roadmap (e.g. "separates the implemented baseline from
|
|
53
|
+
// planned work"), and a broad co-occurrence scan would flag that as
|
|
54
|
+
// ambiguous noise. Anchoring both patterns to the same feature subject
|
|
55
|
+
// keeps false positives near zero: a real ambiguous match requires the
|
|
56
|
+
// SAME subject to be described as both implemented and planned.
|
|
57
|
+
export function currentClaimPatternFor(subjectPattern) {
|
|
58
|
+
return new RegExp(`${subjectPattern}[^.\\n]{0,40}\\b(is implemented|is current|is now implemented)\\b`, "i");
|
|
59
|
+
}
|
|
60
|
+
export function plannedClaimPatternFor(subjectPattern) {
|
|
61
|
+
return new RegExp(`${subjectPattern}[^.\\n]{0,40}\\b(is planned|is future|is not yet implemented|not implemented)\\b`, "i");
|
|
62
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { splitLines } from "./textLines.js";
|
|
2
|
+
// Skips optional leading "npm run" flags (e.g. "npm run --silent <script>")
|
|
3
|
+
// before capturing the actual script name -- without this, "--silent" itself
|
|
4
|
+
// gets captured as if it were the script name.
|
|
5
|
+
const NPM_RUN_PATTERN = /npm run (?:--\S+\s+)*([\w:.-]+)/g;
|
|
6
|
+
// Cues that mark a nearby command reference as planned/future/intentionally-
|
|
7
|
+
// unimplemented rather than a current, working example. Deliberately
|
|
8
|
+
// conservative wording match (whole word/phrase), not broad semantic
|
|
9
|
+
// inference. "avoid" covers this project's own "commands to avoid unless
|
|
10
|
+
// truly necessary" roadmap convention for commands that are deliberately
|
|
11
|
+
// never meant to exist.
|
|
12
|
+
const PLANNED_LABEL_PATTERN = /\b(planned|future|roadmap|not implemented|not yet implemented|later version|coming soon|avoid)\b/i;
|
|
13
|
+
// How many lines above the reference line to include when checking for a
|
|
14
|
+
// planned-label cue, in addition to the enclosing-heading check below.
|
|
15
|
+
// Covers a preceding sentence or list-intro line that isn't itself a
|
|
16
|
+
// markdown heading (e.g. "Commands to avoid unless truly necessary:") and
|
|
17
|
+
// may be more than a few lines above the last item in a long bullet list.
|
|
18
|
+
const PLANNED_LABEL_WINDOW_LINES = 15;
|
|
19
|
+
const HEADING_PATTERN = /^(#{1,6})\s+(.*)$/;
|
|
20
|
+
export function extractDocCommandReferences(content) {
|
|
21
|
+
const lines = splitLines(content);
|
|
22
|
+
const refs = [];
|
|
23
|
+
const seenOnLine = new Set();
|
|
24
|
+
// Tracks the most recently seen heading text at each markdown heading
|
|
25
|
+
// depth (1-6) as we scan top to bottom. A reference nested arbitrarily
|
|
26
|
+
// deep under a "## Planned ..." section inherits that section's framing
|
|
27
|
+
// even when the nearest local text (a sibling "### v0.4.0 ..." heading,
|
|
28
|
+
// say) doesn't itself contain a planned-label word -- this is what makes
|
|
29
|
+
// the check robust to roadmap documents with many nested version
|
|
30
|
+
// subsections, without an unbounded backward text scan.
|
|
31
|
+
const headingStack = new Array(7).fill(undefined);
|
|
32
|
+
for (let i = 0; i < lines.length; i++) {
|
|
33
|
+
const line = lines[i];
|
|
34
|
+
const headingMatch = line.match(HEADING_PATTERN);
|
|
35
|
+
if (headingMatch) {
|
|
36
|
+
const depth = headingMatch[1].length;
|
|
37
|
+
headingStack[depth] = headingMatch[2];
|
|
38
|
+
// A new heading at depth N implicitly starts a fresh subsection for
|
|
39
|
+
// every deeper level -- clear them so a later, unrelated deep
|
|
40
|
+
// heading under a different parent doesn't leak stale context.
|
|
41
|
+
for (let d = depth + 1; d < headingStack.length; d++)
|
|
42
|
+
headingStack[d] = undefined;
|
|
43
|
+
}
|
|
44
|
+
NPM_RUN_PATTERN.lastIndex = 0;
|
|
45
|
+
for (const match of line.matchAll(NPM_RUN_PATTERN)) {
|
|
46
|
+
const command = match[1];
|
|
47
|
+
const key = `${i}:${command}`;
|
|
48
|
+
if (seenOnLine.has(key))
|
|
49
|
+
continue;
|
|
50
|
+
seenOnLine.add(key);
|
|
51
|
+
const windowStart = Math.max(0, i - PLANNED_LABEL_WINDOW_LINES);
|
|
52
|
+
const localWindow = lines.slice(windowStart, i + 1).join(" ");
|
|
53
|
+
const headingContext = headingStack.filter((h) => h !== undefined).join(" ");
|
|
54
|
+
const combined = `${headingContext} ${localWindow}`;
|
|
55
|
+
refs.push({
|
|
56
|
+
command,
|
|
57
|
+
lineNumber: i + 1,
|
|
58
|
+
excerpt: line.trim().slice(0, 200),
|
|
59
|
+
isLabeledPlanned: PLANNED_LABEL_PATTERN.test(combined),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return refs;
|
|
64
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// v0.3.0 Batch 4 — shared file-path pattern helpers.
|
|
4
|
+
//
|
|
5
|
+
// Small, deterministic path helpers reused by the Batch 4 candidate-based
|
|
6
|
+
// detectors (duplicate implementation, dead code, architecture drift).
|
|
7
|
+
// Deliberately forward-slash only -- inventory relativePath values are
|
|
8
|
+
// already forward-slash normalized by projectInventory.ts.
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Returns the first path segment (e.g. "src/audits/foo.ts" -> "src") or null
|
|
11
|
+
// for a root-level file with no directory segment.
|
|
12
|
+
export function firstPathSegment(relativePath) {
|
|
13
|
+
const idx = relativePath.indexOf("/");
|
|
14
|
+
return idx === -1 ? null : relativePath.slice(0, idx);
|
|
15
|
+
}
|
|
16
|
+
// Returns the first two path segments joined (e.g. "src/audits/foo.ts" ->
|
|
17
|
+
// "src/audits"), or the single segment if there is no second one.
|
|
18
|
+
export function firstTwoPathSegments(relativePath) {
|
|
19
|
+
const parts = relativePath.split("/");
|
|
20
|
+
return parts.length <= 2 ? (parts[0] ?? relativePath) : `${parts[0]}/${parts[1]}`;
|
|
21
|
+
}
|
|
22
|
+
export function baseNameNoExt(relativePath) {
|
|
23
|
+
const basename = path.basename(relativePath);
|
|
24
|
+
const ext = path.extname(basename);
|
|
25
|
+
return ext ? basename.slice(0, -ext.length) : basename;
|
|
26
|
+
}
|
|
27
|
+
// Common, generic infra-ish basenames that are expected to repeat across
|
|
28
|
+
// unrelated directories legitimately (barrels, shared type files, etc.) --
|
|
29
|
+
// excluded from the "same basename across unrelated roots" duplicate-
|
|
30
|
+
// candidate heuristic to keep false positives near zero.
|
|
31
|
+
export const GENERIC_INFRA_BASENAMES = new Set([
|
|
32
|
+
"index",
|
|
33
|
+
"types",
|
|
34
|
+
"type",
|
|
35
|
+
"config",
|
|
36
|
+
"utils",
|
|
37
|
+
"util",
|
|
38
|
+
"helpers",
|
|
39
|
+
"helper",
|
|
40
|
+
"constants",
|
|
41
|
+
"constant",
|
|
42
|
+
"setup",
|
|
43
|
+
"fixtures",
|
|
44
|
+
"fixture",
|
|
45
|
+
"errors",
|
|
46
|
+
"error",
|
|
47
|
+
]);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// v0.3.0 Batch 3 — shared code-rot issue construction helpers.
|
|
3
|
+
//
|
|
4
|
+
// Issue ids are content-addressed (detectorId + a stable, relative-path-only
|
|
5
|
+
// cue) rather than counter-based, so the same underlying problem always
|
|
6
|
+
// produces the same id across runs -- this is what makes de-duplication and
|
|
7
|
+
// "stable issue ids" (Batch 3 spec 3.8) trivial: two candidate issues with
|
|
8
|
+
// the same id are the same issue.
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Builds a deterministic id from a detector id and one or more relative-path
|
|
11
|
+
// or short-string cues. Never embeds an absolute path -- callers must pass
|
|
12
|
+
// already-relative paths (e.g. from InventoryFileEntry.relativePath).
|
|
13
|
+
export function makeIssueId(detectorId, ...cues) {
|
|
14
|
+
const sanitizedCues = cues.map((cue) => cue.replace(/\\/g, "/"));
|
|
15
|
+
return [detectorId, ...sanitizedCues].join(":");
|
|
16
|
+
}
|
|
17
|
+
export function makeCodeRotIssue(input) {
|
|
18
|
+
const { idCues, evidence, affectedFiles, ...rest } = input;
|
|
19
|
+
return {
|
|
20
|
+
...rest,
|
|
21
|
+
id: makeIssueId(rest.detectorId, ...idCues),
|
|
22
|
+
evidence: evidence ?? [],
|
|
23
|
+
affectedFiles: affectedFiles ?? [],
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
// Deduplicates a list of already-built issues by id, keeping the first
|
|
27
|
+
// occurrence and preserving overall array order (stable de-duplication).
|
|
28
|
+
export function deduplicateIssuesById(issues) {
|
|
29
|
+
const seen = new Set();
|
|
30
|
+
const result = [];
|
|
31
|
+
for (const issue of issues) {
|
|
32
|
+
if (seen.has(issue.id))
|
|
33
|
+
continue;
|
|
34
|
+
seen.add(issue.id);
|
|
35
|
+
result.push(issue);
|
|
36
|
+
}
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// v0.3.0 Batch 3 (moved in Batch 6) — shared line-splitting helper.
|
|
3
|
+
//
|
|
4
|
+
// The canonical implementation now lives in src/audits/core/textLines.ts
|
|
5
|
+
// (core is lower-level than codeRot, and src/audits/core/sourceOfTruth.ts
|
|
6
|
+
// needed the same helper -- see that file's header comment). This module is
|
|
7
|
+
// kept as a transparent re-export so every existing codeRot detector/util
|
|
8
|
+
// that imports `splitLines` from "../utils/textLines.js" (or
|
|
9
|
+
// "./textLines.js") keeps working unchanged -- named-export re-exports are
|
|
10
|
+
// transparent to importers, there is no behavior difference.
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
export { splitLines } from "../../core/textLines.js";
|