@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,308 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { relativeWithinRoot } from "../../core/pathSafety.js";
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// v0.3.0 Batch 2 — deterministic project inventory scanner.
|
|
6
|
+
//
|
|
7
|
+
// Pure data collection: this module classifies files and produces counts. It
|
|
8
|
+
// deliberately never returns an AuditIssue -- that begins in a later
|
|
9
|
+
// detector batch. Reused pattern from src/core/pathSafety.ts for path
|
|
10
|
+
// normalization/containment (relativeWithinRoot already forward-slash
|
|
11
|
+
// normalizes, matching the "path separators normalize consistently"
|
|
12
|
+
// requirement), but this scanner is its own walker rather than reusing
|
|
13
|
+
// src/core/fileGlobs.ts's private walkFiles(), because inventory needs a
|
|
14
|
+
// different, broader exclusion set (notably "reports", which fileGlobs.ts's
|
|
15
|
+
// scanner does not exclude) and a wider default surface (docs, tests, CI,
|
|
16
|
+
// package, config -- not just src/scripts).
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
export const INVENTORY_FILE_CATEGORIES = [
|
|
19
|
+
"source",
|
|
20
|
+
"tests",
|
|
21
|
+
"docs",
|
|
22
|
+
"package",
|
|
23
|
+
"config",
|
|
24
|
+
"scripts",
|
|
25
|
+
"ci",
|
|
26
|
+
"generated",
|
|
27
|
+
"report",
|
|
28
|
+
"unknown",
|
|
29
|
+
];
|
|
30
|
+
// Directory names excluded from traversal entirely -- never descended into.
|
|
31
|
+
const EXCLUDED_DIR_NAMES = new Set([
|
|
32
|
+
"node_modules",
|
|
33
|
+
".git",
|
|
34
|
+
"dist",
|
|
35
|
+
"build",
|
|
36
|
+
"coverage",
|
|
37
|
+
"reports",
|
|
38
|
+
"lab-output",
|
|
39
|
+
".my-dev-kit",
|
|
40
|
+
".my-dev-kit-v1",
|
|
41
|
+
".my-dev-kit-lab",
|
|
42
|
+
"__pycache__",
|
|
43
|
+
".idea",
|
|
44
|
+
".vscode",
|
|
45
|
+
// Vitest's own local cache directory (gitignored) -- generated tool
|
|
46
|
+
// output, same category as coverage/.
|
|
47
|
+
".vitest",
|
|
48
|
+
]);
|
|
49
|
+
// OS/editor noise filenames, mirroring .gitignore's own conventions.
|
|
50
|
+
const EXCLUDED_FILE_NAMES = new Set([".DS_Store", "Thumbs.db", "desktop.ini"]);
|
|
51
|
+
function isNoiseFile(basename) {
|
|
52
|
+
return EXCLUDED_FILE_NAMES.has(basename) || basename.endsWith(".swp") || basename.endsWith("~");
|
|
53
|
+
}
|
|
54
|
+
// Known package/manifest filenames -> "package" category.
|
|
55
|
+
const PACKAGE_FILE_NAMES = new Set([
|
|
56
|
+
"package.json",
|
|
57
|
+
"package-lock.json",
|
|
58
|
+
"yarn.lock",
|
|
59
|
+
"pnpm-lock.yaml",
|
|
60
|
+
"npm-shrinkwrap.json",
|
|
61
|
+
"LICENSE",
|
|
62
|
+
".npmrc",
|
|
63
|
+
]);
|
|
64
|
+
// Known binary/media extensions -- never read for content (line count, etc).
|
|
65
|
+
const BINARY_EXTENSIONS = new Set([
|
|
66
|
+
".png",
|
|
67
|
+
".jpg",
|
|
68
|
+
".jpeg",
|
|
69
|
+
".gif",
|
|
70
|
+
".ico",
|
|
71
|
+
".pdf",
|
|
72
|
+
".zip",
|
|
73
|
+
".tgz",
|
|
74
|
+
".tar",
|
|
75
|
+
".gz",
|
|
76
|
+
".woff",
|
|
77
|
+
".woff2",
|
|
78
|
+
".ttf",
|
|
79
|
+
".eot",
|
|
80
|
+
".exe",
|
|
81
|
+
".dll",
|
|
82
|
+
".so",
|
|
83
|
+
".dylib",
|
|
84
|
+
".wasm",
|
|
85
|
+
".db",
|
|
86
|
+
".sqlite",
|
|
87
|
+
]);
|
|
88
|
+
const KNOWN_SOURCE_EXTENSIONS = new Set([
|
|
89
|
+
".ts",
|
|
90
|
+
".tsx",
|
|
91
|
+
".js",
|
|
92
|
+
".jsx",
|
|
93
|
+
".mjs",
|
|
94
|
+
".cjs",
|
|
95
|
+
".py",
|
|
96
|
+
".go",
|
|
97
|
+
".rs",
|
|
98
|
+
".java",
|
|
99
|
+
]);
|
|
100
|
+
// A file this large or larger is skipped entirely (not even stat'd into the
|
|
101
|
+
// inventory) -- protects against pathological huge-file scan cost.
|
|
102
|
+
const DEFAULT_HARD_SKIP_FILE_BYTES = 20_000_000;
|
|
103
|
+
// A file this large or larger is still inventoried (category, size, etc.)
|
|
104
|
+
// but never read for line-count -- keeps content reads bounded.
|
|
105
|
+
const DEFAULT_MAX_FILE_BYTES_FOR_LINE_COUNT = 500_000;
|
|
106
|
+
// Bytes read (at most) when sniffing for a NUL byte to detect binary content
|
|
107
|
+
// for files without a known binary extension.
|
|
108
|
+
const BINARY_SNIFF_BYTES = 512;
|
|
109
|
+
export function scanProjectInventory(targetRoot, options = {}) {
|
|
110
|
+
const resolvedRoot = path.resolve(targetRoot);
|
|
111
|
+
const hardSkipFileBytes = options.hardSkipFileBytes ?? DEFAULT_HARD_SKIP_FILE_BYTES;
|
|
112
|
+
const maxFileBytesForLineCount = options.maxFileBytesForLineCount ?? DEFAULT_MAX_FILE_BYTES_FOR_LINE_COUNT;
|
|
113
|
+
const warnings = [];
|
|
114
|
+
const excludedDirCounts = new Map();
|
|
115
|
+
const files = [];
|
|
116
|
+
let totalFileCount = 0;
|
|
117
|
+
let skippedFileCount = 0;
|
|
118
|
+
let oversizedSkipCount = 0;
|
|
119
|
+
walk(resolvedRoot);
|
|
120
|
+
files.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
|
121
|
+
if (oversizedSkipCount > 0) {
|
|
122
|
+
warnings.push(`Skipped ${oversizedSkipCount} oversized file(s) (>= ${hardSkipFileBytes} bytes): possible generated bundle or binary artifact.`);
|
|
123
|
+
}
|
|
124
|
+
const filesByCategory = countByCategory(files);
|
|
125
|
+
const filesByExtension = countByExtension(files);
|
|
126
|
+
const excludedDirectorySummary = [...excludedDirCounts.entries()]
|
|
127
|
+
.map(([name, occurrences]) => ({ name, occurrences }))
|
|
128
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
129
|
+
return {
|
|
130
|
+
targetRoot: resolvedRoot,
|
|
131
|
+
scanRoot: resolvedRoot,
|
|
132
|
+
totalFileCount,
|
|
133
|
+
totalScannedFileCount: files.length,
|
|
134
|
+
skippedFileCount,
|
|
135
|
+
filesByCategory,
|
|
136
|
+
filesByExtension,
|
|
137
|
+
packageFiles: files.filter((f) => f.category === "package"),
|
|
138
|
+
docsFiles: files.filter((f) => f.category === "docs"),
|
|
139
|
+
sourceFiles: files.filter((f) => f.category === "source"),
|
|
140
|
+
testFiles: files.filter((f) => f.category === "tests"),
|
|
141
|
+
scriptFiles: files.filter((f) => f.category === "scripts"),
|
|
142
|
+
ciFiles: files.filter((f) => f.category === "ci"),
|
|
143
|
+
excludedDirectorySummary,
|
|
144
|
+
warnings,
|
|
145
|
+
files,
|
|
146
|
+
};
|
|
147
|
+
function walk(dir) {
|
|
148
|
+
let entries;
|
|
149
|
+
try {
|
|
150
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
warnings.push(`Could not read directory: ${dir} (${err instanceof Error ? err.message : String(err)})`);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
// Sort entries so traversal order (and therefore file discovery order
|
|
157
|
+
// before the final sort-by-relativePath) is stable across platforms
|
|
158
|
+
// regardless of the OS's own readdir ordering.
|
|
159
|
+
const sorted = [...entries].sort((a, b) => a.name.localeCompare(b.name));
|
|
160
|
+
for (const entry of sorted) {
|
|
161
|
+
const fullPath = path.join(dir, entry.name);
|
|
162
|
+
if (entry.isDirectory()) {
|
|
163
|
+
if (EXCLUDED_DIR_NAMES.has(entry.name)) {
|
|
164
|
+
excludedDirCounts.set(entry.name, (excludedDirCounts.get(entry.name) ?? 0) + 1);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
walk(fullPath);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (!entry.isFile()) {
|
|
171
|
+
// Skip symlinks and other special entries -- not part of Batch 2 scope.
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (isNoiseFile(entry.name)) {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
totalFileCount += 1;
|
|
178
|
+
let stat;
|
|
179
|
+
try {
|
|
180
|
+
stat = fs.statSync(fullPath);
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
skippedFileCount += 1;
|
|
184
|
+
warnings.push(`Could not stat file: ${fullPath} (${err instanceof Error ? err.message : String(err)})`);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (stat.size >= hardSkipFileBytes) {
|
|
188
|
+
skippedFileCount += 1;
|
|
189
|
+
oversizedSkipCount += 1;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const relativePath = relativeWithinRoot(resolvedRoot, fullPath);
|
|
193
|
+
const extension = path.extname(entry.name).toLowerCase();
|
|
194
|
+
const category = classifyFile(relativePath, entry.name, extension);
|
|
195
|
+
const entryResult = {
|
|
196
|
+
relativePath,
|
|
197
|
+
normalizedPath: relativePath.toLowerCase(),
|
|
198
|
+
extension,
|
|
199
|
+
category,
|
|
200
|
+
sizeBytes: stat.size,
|
|
201
|
+
likelyGenerated: category === "generated" || category === "report",
|
|
202
|
+
likelyConfig: category === "config" || category === "package",
|
|
203
|
+
likelyDocs: category === "docs",
|
|
204
|
+
likelyTest: category === "tests",
|
|
205
|
+
likelySource: category === "source" || category === "scripts",
|
|
206
|
+
};
|
|
207
|
+
if (!BINARY_EXTENSIONS.has(extension) && stat.size <= maxFileBytesForLineCount) {
|
|
208
|
+
const lineCount = tryCountLines(fullPath, warnings);
|
|
209
|
+
if (lineCount !== undefined) {
|
|
210
|
+
entryResult.lineCount = lineCount;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
files.push(entryResult);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
function classifyFile(relativePath, basename, extension) {
|
|
218
|
+
if (PACKAGE_FILE_NAMES.has(basename))
|
|
219
|
+
return "package";
|
|
220
|
+
if (relativePath.startsWith(".github/workflows/") && (extension === ".yml" || extension === ".yaml")) {
|
|
221
|
+
return "ci";
|
|
222
|
+
}
|
|
223
|
+
if (isTestFile(relativePath, basename))
|
|
224
|
+
return "tests";
|
|
225
|
+
// Top-level scripts/ directory -- classified before generic "source" so
|
|
226
|
+
// scripts (which are also .ts/.js) land in their own category.
|
|
227
|
+
if (relativePath.startsWith("scripts/"))
|
|
228
|
+
return "scripts";
|
|
229
|
+
if (extension === ".md" || extension === ".mdx" || relativePath.startsWith("docs/"))
|
|
230
|
+
return "docs";
|
|
231
|
+
if (isConfigFile(relativePath, basename, extension))
|
|
232
|
+
return "config";
|
|
233
|
+
if (isStrayReportFile(basename))
|
|
234
|
+
return "report";
|
|
235
|
+
if (extension === ".map")
|
|
236
|
+
return "generated";
|
|
237
|
+
if (KNOWN_SOURCE_EXTENSIONS.has(extension))
|
|
238
|
+
return "source";
|
|
239
|
+
return "unknown";
|
|
240
|
+
}
|
|
241
|
+
function isTestFile(relativePath, basename) {
|
|
242
|
+
if (relativePath.startsWith("tests/") || relativePath.includes("/tests/"))
|
|
243
|
+
return true;
|
|
244
|
+
return /\.(test|spec)\.[jt]sx?$/.test(basename);
|
|
245
|
+
}
|
|
246
|
+
function isConfigFile(relativePath, basename, extension) {
|
|
247
|
+
if (/^tsconfig(\..+)?\.json$/.test(basename))
|
|
248
|
+
return true;
|
|
249
|
+
if (/^vitest\.config\.[jt]s$/.test(basename) || basename === "vitest.workspace.ts")
|
|
250
|
+
return true;
|
|
251
|
+
if (/^\.eslintrc(\..+)?$/.test(basename) || /^eslint\.config\.[jt]s$/.test(basename))
|
|
252
|
+
return true;
|
|
253
|
+
if (basename === ".editorconfig" || basename === ".gitignore" || basename === ".npmignore")
|
|
254
|
+
return true;
|
|
255
|
+
// Root-level dotfile configs (e.g. .prettierrc, .babelrc) not already
|
|
256
|
+
// covered above -- deliberately conservative (root-only, not recursive)
|
|
257
|
+
// to avoid misclassifying dotfiles inside fixture/benchmark projects.
|
|
258
|
+
if (!relativePath.includes("/") && basename.startsWith("."))
|
|
259
|
+
return true;
|
|
260
|
+
if (extension === ".json" && basename.toLowerCase().includes("config"))
|
|
261
|
+
return true;
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
function isStrayReportFile(basename) {
|
|
265
|
+
return /-security-validation\.(txt|json)$/.test(basename) || /-audit\.(txt|json)$/.test(basename);
|
|
266
|
+
}
|
|
267
|
+
function tryCountLines(fullPath, warnings) {
|
|
268
|
+
let buffer;
|
|
269
|
+
try {
|
|
270
|
+
buffer = fs.readFileSync(fullPath);
|
|
271
|
+
}
|
|
272
|
+
catch (err) {
|
|
273
|
+
warnings.push(`Could not read file for line count: ${fullPath} (${err instanceof Error ? err.message : String(err)})`);
|
|
274
|
+
return undefined;
|
|
275
|
+
}
|
|
276
|
+
const sniffLength = Math.min(buffer.length, BINARY_SNIFF_BYTES);
|
|
277
|
+
for (let i = 0; i < sniffLength; i++) {
|
|
278
|
+
if (buffer[i] === 0) {
|
|
279
|
+
// NUL byte present -- treat as binary, skip line counting.
|
|
280
|
+
return undefined;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (buffer.length === 0)
|
|
284
|
+
return 0;
|
|
285
|
+
const text = buffer.toString("utf8");
|
|
286
|
+
let lines = 1;
|
|
287
|
+
for (let i = 0; i < text.length; i++) {
|
|
288
|
+
if (text[i] === "\n")
|
|
289
|
+
lines += 1;
|
|
290
|
+
}
|
|
291
|
+
return lines;
|
|
292
|
+
}
|
|
293
|
+
function countByCategory(files) {
|
|
294
|
+
const counts = Object.fromEntries(INVENTORY_FILE_CATEGORIES.map((c) => [c, 0]));
|
|
295
|
+
for (const file of files) {
|
|
296
|
+
counts[file.category] += 1;
|
|
297
|
+
}
|
|
298
|
+
return counts;
|
|
299
|
+
}
|
|
300
|
+
function countByExtension(files) {
|
|
301
|
+
const counts = new Map();
|
|
302
|
+
for (const file of files) {
|
|
303
|
+
const key = file.extension || "(none)";
|
|
304
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
305
|
+
}
|
|
306
|
+
const sortedEntries = [...counts.entries()].sort(([a], [b]) => a.localeCompare(b));
|
|
307
|
+
return Object.fromEntries(sortedEntries);
|
|
308
|
+
}
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { splitLines } from "./textLines.js";
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// v0.3.0 Batch 2 — source-of-truth collector.
|
|
6
|
+
//
|
|
7
|
+
// Reads package.json/package-lock.json/docs/CI-workflow/config *facts* only.
|
|
8
|
+
// Never executes a command, never calls security:validate or an experiment
|
|
9
|
+
// command, and never produces an AuditIssue -- this is data collection for
|
|
10
|
+
// later detector batches to consume, same spirit as projectInventory.ts.
|
|
11
|
+
// Reuses the already-computed ProjectInventorySnapshot for file discovery
|
|
12
|
+
// (docs/ci/test file lists) rather than re-walking the filesystem.
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
export const SCRIPT_GROUPS = ["build", "test", "security", "experiment", "audit", "docs", "release", "other"];
|
|
15
|
+
const MAX_DOCS_READ_BYTES = 200_000;
|
|
16
|
+
const MAX_CI_READ_BYTES = 100_000;
|
|
17
|
+
export function collectSourceOfTruth(targetRoot, inventory) {
|
|
18
|
+
const resolvedRoot = path.resolve(targetRoot);
|
|
19
|
+
const warnings = [];
|
|
20
|
+
const pkg = readPackageTruth(resolvedRoot, warnings);
|
|
21
|
+
const lockfile = readLockfileTruth(resolvedRoot, pkg, warnings);
|
|
22
|
+
const commands = classifyCommands(pkg);
|
|
23
|
+
const docs = readDocsTruth(resolvedRoot, inventory, warnings);
|
|
24
|
+
const ci = readCiTruth(resolvedRoot, inventory, warnings);
|
|
25
|
+
const buildTooling = readBuildToolingTruth(inventory, pkg);
|
|
26
|
+
const tests = readTestTruth(inventory);
|
|
27
|
+
const security = readSecurityTruth(resolvedRoot, pkg);
|
|
28
|
+
const experiment = readExperimentTruth(resolvedRoot, pkg);
|
|
29
|
+
return {
|
|
30
|
+
targetRoot: resolvedRoot,
|
|
31
|
+
package: pkg,
|
|
32
|
+
lockfile,
|
|
33
|
+
commands,
|
|
34
|
+
docs,
|
|
35
|
+
ci,
|
|
36
|
+
buildTooling,
|
|
37
|
+
tests,
|
|
38
|
+
security,
|
|
39
|
+
experiment,
|
|
40
|
+
warnings,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function readPackageTruth(targetRoot, warnings) {
|
|
44
|
+
const pkgPath = path.join(targetRoot, "package.json");
|
|
45
|
+
let raw;
|
|
46
|
+
try {
|
|
47
|
+
raw = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
return {
|
|
54
|
+
name: typeof raw.name === "string" ? raw.name : null,
|
|
55
|
+
version: typeof raw.version === "string" ? raw.version : null,
|
|
56
|
+
description: typeof raw.description === "string" ? raw.description : null,
|
|
57
|
+
bin: isRecordOfStrings(raw.bin) || typeof raw.bin === "string" ? raw.bin : null,
|
|
58
|
+
main: typeof raw.main === "string" ? raw.main : null,
|
|
59
|
+
module: typeof raw.module === "string" ? raw.module : null,
|
|
60
|
+
types: typeof raw.types === "string" ? raw.types : typeof raw.typings === "string" ? raw.typings : null,
|
|
61
|
+
scripts: isRecordOfStrings(raw.scripts) ? raw.scripts : {},
|
|
62
|
+
dependencies: isRecordOfStrings(raw.dependencies) ? raw.dependencies : {},
|
|
63
|
+
devDependencies: isRecordOfStrings(raw.devDependencies) ? raw.devDependencies : {},
|
|
64
|
+
peerDependencies: isRecordOfStrings(raw.peerDependencies) ? raw.peerDependencies : null,
|
|
65
|
+
engines: isRecordOfStrings(raw.engines) ? raw.engines : null,
|
|
66
|
+
files: Array.isArray(raw.files) ? raw.files.filter((f) => typeof f === "string") : null,
|
|
67
|
+
packageManagerHint: typeof raw.packageManager === "string" ? raw.packageManager : null,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
warnings.push(`Could not fully parse package.json fields: ${err instanceof Error ? err.message : String(err)}`);
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function isRecordOfStrings(value) {
|
|
76
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
77
|
+
return false;
|
|
78
|
+
return Object.values(value).every((v) => typeof v === "string");
|
|
79
|
+
}
|
|
80
|
+
function readLockfileTruth(targetRoot, pkg, warnings) {
|
|
81
|
+
const lockPath = path.join(targetRoot, "package-lock.json");
|
|
82
|
+
let raw;
|
|
83
|
+
try {
|
|
84
|
+
raw = JSON.parse(fs.readFileSync(lockPath, "utf8"));
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return { present: false, rootName: null, rootVersion: null, lockfileVersion: null, packageManagerConsistent: null };
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
const rootPackageEntry = raw.packages?.[""];
|
|
91
|
+
const rootName = typeof raw.name === "string" ? raw.name : typeof rootPackageEntry?.name === "string" ? rootPackageEntry.name : null;
|
|
92
|
+
const rootVersion = typeof raw.version === "string" ? raw.version : typeof rootPackageEntry?.version === "string" ? rootPackageEntry.version : null;
|
|
93
|
+
const lockfileVersion = typeof raw.lockfileVersion === "number" ? raw.lockfileVersion : null;
|
|
94
|
+
const packageManagerConsistent = pkg?.name != null && rootName != null ? pkg.name === rootName && pkg.version === rootVersion : null;
|
|
95
|
+
return { present: true, rootName, rootVersion, lockfileVersion, packageManagerConsistent };
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
warnings.push(`Could not fully parse package-lock.json fields: ${err instanceof Error ? err.message : String(err)}`);
|
|
99
|
+
return { present: true, rootName: null, rootVersion: null, lockfileVersion: null, packageManagerConsistent: null };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// Command/script truth
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
function classifyScriptGroup(scriptName) {
|
|
106
|
+
const name = scriptName.toLowerCase();
|
|
107
|
+
if (name.startsWith("security") || name === "test:security" || name.startsWith("test:fuzz"))
|
|
108
|
+
return "security";
|
|
109
|
+
if (name.startsWith("experiment"))
|
|
110
|
+
return "experiment";
|
|
111
|
+
if (name === "audit" || name.startsWith("audit:"))
|
|
112
|
+
return "audit";
|
|
113
|
+
if (name === "test" || name.startsWith("test:"))
|
|
114
|
+
return "test";
|
|
115
|
+
if (name === "build" || name === "typecheck" || name === "clean" || name.includes("build"))
|
|
116
|
+
return "build";
|
|
117
|
+
if (name.includes("doc"))
|
|
118
|
+
return "docs";
|
|
119
|
+
if (name.includes("release") || name.includes("publish") || name.includes("pack") || name.includes("version")) {
|
|
120
|
+
return "release";
|
|
121
|
+
}
|
|
122
|
+
return "other";
|
|
123
|
+
}
|
|
124
|
+
function classifyCommands(pkg) {
|
|
125
|
+
const scriptsByGroup = Object.fromEntries(SCRIPT_GROUPS.map((g) => [g, []]));
|
|
126
|
+
if (!pkg) {
|
|
127
|
+
return { scriptsByGroup, allScriptNames: [] };
|
|
128
|
+
}
|
|
129
|
+
const allScriptNames = Object.keys(pkg.scripts).sort((a, b) => a.localeCompare(b));
|
|
130
|
+
for (const name of allScriptNames) {
|
|
131
|
+
scriptsByGroup[classifyScriptGroup(name)].push(name);
|
|
132
|
+
}
|
|
133
|
+
return { scriptsByGroup, allScriptNames };
|
|
134
|
+
}
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// Docs truth
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
function readDocsTruth(targetRoot, inventory, warnings) {
|
|
139
|
+
const files = [];
|
|
140
|
+
let hasReadme = false;
|
|
141
|
+
let hasChangelog = false;
|
|
142
|
+
for (const entry of inventory.docsFiles) {
|
|
143
|
+
const basename = path.basename(entry.relativePath).toLowerCase();
|
|
144
|
+
if (basename === "readme.md")
|
|
145
|
+
hasReadme = true;
|
|
146
|
+
if (basename === "changelog.md")
|
|
147
|
+
hasChangelog = true;
|
|
148
|
+
const fullPath = path.join(targetRoot, entry.relativePath);
|
|
149
|
+
let content = "";
|
|
150
|
+
try {
|
|
151
|
+
if (entry.sizeBytes <= MAX_DOCS_READ_BYTES) {
|
|
152
|
+
content = fs.readFileSync(fullPath, "utf8");
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
catch (err) {
|
|
156
|
+
warnings.push(`Could not read docs file: ${entry.relativePath} (${err instanceof Error ? err.message : String(err)})`);
|
|
157
|
+
}
|
|
158
|
+
const titleMatch = content.match(/^#\s+(.+)$/m);
|
|
159
|
+
files.push({
|
|
160
|
+
relativePath: entry.relativePath,
|
|
161
|
+
title: titleMatch ? titleMatch[1].trim() : null,
|
|
162
|
+
mentionsAudit: /\baudit\b/i.test(content),
|
|
163
|
+
mentionsSecurityValidation: /security[- ]?valid|security:validate/i.test(content),
|
|
164
|
+
mentionsExperiments: /\bexperiment/i.test(content),
|
|
165
|
+
mentionsReleasePublish: /\b(release|publish|npm publish)\b/i.test(content),
|
|
166
|
+
mentionsPlannedCommands: /\bplanned\b/i.test(content),
|
|
167
|
+
mentionsCurrentCommands: /\bcurrent\b/i.test(content),
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
files.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
|
171
|
+
return { files, hasReadme, hasChangelog };
|
|
172
|
+
}
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// CI/workflow truth
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
function readCiTruth(targetRoot, inventory, warnings) {
|
|
177
|
+
const workflows = [];
|
|
178
|
+
for (const entry of inventory.ciFiles) {
|
|
179
|
+
const fullPath = path.join(targetRoot, entry.relativePath);
|
|
180
|
+
let content = "";
|
|
181
|
+
try {
|
|
182
|
+
if (entry.sizeBytes <= MAX_CI_READ_BYTES) {
|
|
183
|
+
content = fs.readFileSync(fullPath, "utf8");
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
warnings.push(`Could not read workflow file: ${entry.relativePath} (${err instanceof Error ? err.message : String(err)})`);
|
|
188
|
+
}
|
|
189
|
+
workflows.push({
|
|
190
|
+
relativePath: entry.relativePath,
|
|
191
|
+
name: parseWorkflowName(content),
|
|
192
|
+
nodeVersionsReferenced: parseNodeVersions(content),
|
|
193
|
+
packageManagerCommandsReferenced: parsePackageManagerCommands(content),
|
|
194
|
+
npmScriptsReferenced: parseNpmScriptsReferenced(content),
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
workflows.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
|
198
|
+
return { workflows };
|
|
199
|
+
}
|
|
200
|
+
function parseWorkflowName(content) {
|
|
201
|
+
const match = content.match(/^name:\s*(.+)$/m);
|
|
202
|
+
return match ? match[1].trim() : null;
|
|
203
|
+
}
|
|
204
|
+
function parseNodeVersions(content) {
|
|
205
|
+
const versions = new Set();
|
|
206
|
+
// Inline form: node-version: 20
|
|
207
|
+
for (const match of content.matchAll(/node-version:\s*["']?([\w.]+)["']?\s*$/gm)) {
|
|
208
|
+
versions.add(match[1]);
|
|
209
|
+
}
|
|
210
|
+
// List form:
|
|
211
|
+
// node-version:
|
|
212
|
+
// - 20
|
|
213
|
+
// - 22
|
|
214
|
+
// v0.3.0 Batch 6 — use the canonical splitLines() helper instead of a raw
|
|
215
|
+
// string split on a newline character, so CRLF-authored workflow files
|
|
216
|
+
// (trailing carriage-return on every line) don't silently break the
|
|
217
|
+
// list-item regex below (see src/audits/core/textLines.ts's header
|
|
218
|
+
// comment). A bare split here is also exactly what
|
|
219
|
+
// crossPlatformRotDetector.ts's raw-split-detection check flags in any
|
|
220
|
+
// other src/audits/ file.
|
|
221
|
+
const lines = splitLines(content);
|
|
222
|
+
for (let i = 0; i < lines.length; i++) {
|
|
223
|
+
if (/node-version:\s*$/.test(lines[i])) {
|
|
224
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
225
|
+
const listItem = lines[j].match(/^\s*-\s*["']?([\w.]+)["']?\s*$/);
|
|
226
|
+
if (!listItem)
|
|
227
|
+
break;
|
|
228
|
+
versions.add(listItem[1]);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return [...versions].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
|
233
|
+
}
|
|
234
|
+
function parsePackageManagerCommands(content) {
|
|
235
|
+
const commands = new Set();
|
|
236
|
+
for (const match of content.matchAll(/\bnpm (ci|install|publish|pack|audit|outdated)\b/g)) {
|
|
237
|
+
commands.add(`npm ${match[1]}`);
|
|
238
|
+
}
|
|
239
|
+
for (const match of content.matchAll(/\bnpx [\w.:-]+/g)) {
|
|
240
|
+
commands.add(match[0].trim());
|
|
241
|
+
}
|
|
242
|
+
return [...commands].sort((a, b) => a.localeCompare(b));
|
|
243
|
+
}
|
|
244
|
+
function parseNpmScriptsReferenced(content) {
|
|
245
|
+
const scripts = new Set();
|
|
246
|
+
for (const match of content.matchAll(/\bnpm run ([\w:.-]+)/g)) {
|
|
247
|
+
scripts.add(match[1]);
|
|
248
|
+
}
|
|
249
|
+
return [...scripts].sort((a, b) => a.localeCompare(b));
|
|
250
|
+
}
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
// Build tooling truth
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
function readBuildToolingTruth(inventory, pkg) {
|
|
255
|
+
const tsconfigPaths = inventory.files
|
|
256
|
+
.filter((f) => /^tsconfig(\..+)?\.json$/.test(path.basename(f.relativePath)))
|
|
257
|
+
.map((f) => f.relativePath)
|
|
258
|
+
.sort((a, b) => a.localeCompare(b));
|
|
259
|
+
const vitestConfigPaths = inventory.files
|
|
260
|
+
.filter((f) => /^vitest\.config\.[jt]s$/.test(path.basename(f.relativePath)) || path.basename(f.relativePath) === "vitest.workspace.ts")
|
|
261
|
+
.map((f) => f.relativePath)
|
|
262
|
+
.sort((a, b) => a.localeCompare(b));
|
|
263
|
+
const eslintConfigPaths = inventory.files
|
|
264
|
+
.filter((f) => /^\.eslintrc(\..+)?$/.test(path.basename(f.relativePath)) || /^eslint\.config\.[jt]s$/.test(path.basename(f.relativePath)))
|
|
265
|
+
.map((f) => f.relativePath)
|
|
266
|
+
.sort((a, b) => a.localeCompare(b));
|
|
267
|
+
const scripts = pkg?.scripts ?? {};
|
|
268
|
+
return {
|
|
269
|
+
hasTsconfig: tsconfigPaths.length > 0,
|
|
270
|
+
tsconfigPaths,
|
|
271
|
+
hasVitestConfig: vitestConfigPaths.length > 0,
|
|
272
|
+
vitestConfigPaths,
|
|
273
|
+
hasEslintConfig: eslintConfigPaths.length > 0,
|
|
274
|
+
eslintConfigPaths,
|
|
275
|
+
hasBuildScript: "build" in scripts,
|
|
276
|
+
hasTypecheckScript: "typecheck" in scripts,
|
|
277
|
+
hasTestScript: "test" in scripts,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
// Test truth
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
function readTestTruth(inventory) {
|
|
284
|
+
const testFilesByTopLevelArea = {};
|
|
285
|
+
let auditTestFileCount = 0;
|
|
286
|
+
let securityTestFileCount = 0;
|
|
287
|
+
let experimentTestFileCount = 0;
|
|
288
|
+
for (const file of inventory.testFiles) {
|
|
289
|
+
const match = file.relativePath.match(/^tests\/([^/]+)\//);
|
|
290
|
+
const area = match ? match[1] : "other";
|
|
291
|
+
testFilesByTopLevelArea[area] = (testFilesByTopLevelArea[area] ?? 0) + 1;
|
|
292
|
+
if (area === "audits")
|
|
293
|
+
auditTestFileCount += 1;
|
|
294
|
+
if (area === "security")
|
|
295
|
+
securityTestFileCount += 1;
|
|
296
|
+
if (area === "experiments")
|
|
297
|
+
experimentTestFileCount += 1;
|
|
298
|
+
}
|
|
299
|
+
const orderedAreas = Object.fromEntries(Object.entries(testFilesByTopLevelArea).sort(([a], [b]) => a.localeCompare(b)));
|
|
300
|
+
return {
|
|
301
|
+
totalTestFileCount: inventory.testFiles.length,
|
|
302
|
+
auditTestFileCount,
|
|
303
|
+
securityTestFileCount,
|
|
304
|
+
experimentTestFileCount,
|
|
305
|
+
testFilesByTopLevelArea: orderedAreas,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
// Security / experiment truth (script + directory presence only)
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
function readSecurityTruth(targetRoot, pkg) {
|
|
312
|
+
const scripts = pkg?.scripts ?? {};
|
|
313
|
+
return {
|
|
314
|
+
hasSecurityValidateScript: "security:validate" in scripts,
|
|
315
|
+
hasSecurityDepsScript: "security:deps" in scripts,
|
|
316
|
+
hasSecurityPackageScript: "security:package" in scripts,
|
|
317
|
+
hasSecurityCodeqlScript: "security:codeql" in scripts,
|
|
318
|
+
hasSecuritySemgrepScript: "security:semgrep" in scripts,
|
|
319
|
+
hasTestSecurityScript: "test:security" in scripts,
|
|
320
|
+
hasSecurityValidationSourceDir: dirExists(path.join(targetRoot, "src", "securityValidation")),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
function readExperimentTruth(targetRoot, pkg) {
|
|
324
|
+
const scripts = pkg?.scripts ?? {};
|
|
325
|
+
return {
|
|
326
|
+
hasExperimentListScript: "experiment:list" in scripts,
|
|
327
|
+
hasExperimentDescribeScript: "experiment:describe" in scripts,
|
|
328
|
+
hasExperimentRunScript: "experiment:run" in scripts,
|
|
329
|
+
hasExperimentsSourceDir: dirExists(path.join(targetRoot, "src", "experiments")),
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
function dirExists(dirPath) {
|
|
333
|
+
try {
|
|
334
|
+
return fs.statSync(dirPath).isDirectory();
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// v0.3.0 Batch 6 — canonical, core-level line-splitting helper.
|
|
3
|
+
//
|
|
4
|
+
// Moved here from src/audits/codeRot/utils/textLines.ts (Batch 3), because
|
|
5
|
+
// src/audits/core/sourceOfTruth.ts (a core-level module) needed the same
|
|
6
|
+
// CRLF-safe splitting behavior for parseNodeVersions()'s list-form
|
|
7
|
+
// "node-version:\n - 20\n - 22" parsing, and core/ depending on
|
|
8
|
+
// codeRot/utils/ would be architecturally backwards (core is lower-level
|
|
9
|
+
// than codeRot). This is now the single canonical implementation; the
|
|
10
|
+
// codeRot-side module re-exports it so no existing detector import path
|
|
11
|
+
// changes (see src/audits/codeRot/utils/textLines.ts).
|
|
12
|
+
//
|
|
13
|
+
// Windows-authored files in this repo (e.g. docs/ROADMAP.md) commonly have
|
|
14
|
+
// CRLF line endings. A plain content.split("\n") leaves a trailing "\r" on
|
|
15
|
+
// every line, which silently breaks any "$"-anchored regex against that
|
|
16
|
+
// line (JS regex "." does not match "\r", a line terminator character) --
|
|
17
|
+
// discovered via a real false negative where a heading-tracking regex
|
|
18
|
+
// matched zero headings in a CRLF file. Every module that splits doc/text
|
|
19
|
+
// content into lines must go through this helper instead of a bare
|
|
20
|
+
// .split("\n").
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
export function splitLines(content) {
|
|
23
|
+
return content.replace(/\r\n/g, "\n").split("\n");
|
|
24
|
+
}
|