@dailephd/my-dev-kit-lab 0.2.2 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -7
- package/dist/scripts/audits/runAudit.js +102 -0
- package/dist/src/audits/codeRot/codeRotDetectors.js +17 -0
- package/dist/src/audits/codeRot/detectors/architectureDriftDetector.js +271 -0
- package/dist/src/audits/codeRot/detectors/crossPlatformRotDetector.js +214 -0
- package/dist/src/audits/codeRot/detectors/deadCodeCandidateDetector.js +377 -0
- package/dist/src/audits/codeRot/detectors/dependencyEnvironmentRotDetector.js +244 -0
- package/dist/src/audits/codeRot/detectors/docsCodeMismatchDetector.js +219 -0
- package/dist/src/audits/codeRot/detectors/duplicateImplementationDetector.js +350 -0
- package/dist/src/audits/codeRot/detectors/packageReleaseRotDetector.js +325 -0
- package/dist/src/audits/codeRot/detectors/securityValidationAssumptionRotDetector.js +385 -0
- package/dist/src/audits/codeRot/detectors/staleCommandReferenceDetector.js +135 -0
- package/dist/src/audits/codeRot/detectors/testRotDetector.js +410 -0
- package/dist/src/audits/codeRot/utils/boundedRead.js +23 -0
- package/dist/src/audits/codeRot/utils/docClaimPatterns.js +62 -0
- package/dist/src/audits/codeRot/utils/docCommandReferences.js +64 -0
- package/dist/src/audits/codeRot/utils/filePatternUtils.js +79 -0
- package/dist/src/audits/codeRot/utils/issueFactories.js +38 -0
- package/dist/src/audits/codeRot/utils/sourceFactsLookup.js +53 -0
- package/dist/src/audits/codeRot/utils/textLines.js +12 -0
- package/dist/src/audits/core/auditConfig.js +125 -0
- package/dist/src/audits/core/auditExitCode.js +75 -0
- package/dist/src/audits/core/auditIssue.js +20 -0
- package/dist/src/audits/core/auditRegistry.js +40 -0
- package/dist/src/audits/core/auditRunner.js +74 -0
- package/dist/src/audits/core/auditTarget.js +19 -0
- package/dist/src/audits/core/auditTypes.js +30 -0
- package/dist/src/audits/core/collectSourceFacts.js +142 -0
- package/dist/src/audits/core/languageAnalyzerRegistry.js +33 -0
- package/dist/src/audits/core/projectInventory.js +458 -0
- package/dist/src/audits/core/sourceFacts.js +17 -0
- package/dist/src/audits/core/sourceOfTruth.js +339 -0
- package/dist/src/audits/core/textLines.js +24 -0
- package/dist/src/audits/core/typescriptJavaScriptAnalyzer.js +299 -0
- package/dist/src/audits/report/auditReportModel.js +253 -0
- package/dist/src/audits/report/auditReportPaths.js +13 -0
- package/dist/src/audits/report/renderAuditJsonReport.js +30 -0
- package/dist/src/audits/report/renderAuditTextReport.js +181 -0
- package/dist/src/audits/report/sanitizeAuditText.js +39 -0
- package/dist/src/audits/report/writeAuditReports.js +27 -0
- package/dist/src/core/resolveCommand.js +53 -2
- package/package.json +10 -6
|
@@ -0,0 +1,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
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// v0.3.1 Batch 3 -- TypeScript/JavaScript source analyzer.
|
|
4
|
+
//
|
|
5
|
+
// Single-file, syntax-only analysis via the TypeScript compiler API:
|
|
6
|
+
// - ts.createSourceFile() builds a per-file AST (no Program, no project
|
|
7
|
+
// resolution, no type checking) that is walked once to extract facts.
|
|
8
|
+
// - ts.transpileModule({ reportDiagnostics: true }) is used purely to
|
|
9
|
+
// obtain syntactic diagnostics -- it is itself a single-file operation
|
|
10
|
+
// (no cross-file resolution), matching the "single-file parsing only"
|
|
11
|
+
// requirement for this batch.
|
|
12
|
+
//
|
|
13
|
+
// Parse-status policy (deliberately conservative, see docs on
|
|
14
|
+
// SourceFactParseStatus): zero syntax diagnostics -> "parsed". Any syntax
|
|
15
|
+
// diagnostic -> "parse-error", even though the AST walk below still runs
|
|
16
|
+
// against TypeScript's best-effort (error-tolerant) parse and whatever facts
|
|
17
|
+
// it manages to extract are still attached -- the parseStatus signals
|
|
18
|
+
// "don't treat these facts as fully reliable", not "no facts exist". This
|
|
19
|
+
// mirrors how collectSourceFacts.ts already reports a caught analyzer
|
|
20
|
+
// exception as parse-error with an empty-fact fallback -- this analyzer just
|
|
21
|
+
// reaches parse-error through diagnostics instead of a thrown exception, and
|
|
22
|
+
// still gets to keep best-effort facts (which a thrown-exception parse-error
|
|
23
|
+
// never has, since collectSourceFacts.ts only has the exception itself).
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
export const TYPESCRIPT_JAVASCRIPT_ANALYZER_ID = "typescript-javascript-analyzer";
|
|
26
|
+
const SUPPORTED_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"];
|
|
27
|
+
// AST construction is meaningfully more expensive than a line count -- large
|
|
28
|
+
// files (generated bundles, vendored single-file libraries that slipped past
|
|
29
|
+
// role/path exclusions, etc.) are not parsed. Mirrors the size-bounding
|
|
30
|
+
// philosophy of projectInventory.ts's own read limits, just at a tighter
|
|
31
|
+
// threshold appropriate for actually building an AST.
|
|
32
|
+
const MAX_ANALYZABLE_BYTES = 1_000_000;
|
|
33
|
+
export const TYPESCRIPT_JAVASCRIPT_ANALYZER = {
|
|
34
|
+
id: TYPESCRIPT_JAVASCRIPT_ANALYZER_ID,
|
|
35
|
+
supportedLanguages: ["typescript", "javascript"],
|
|
36
|
+
supportedExtensions: SUPPORTED_EXTENSIONS,
|
|
37
|
+
analyzeFile,
|
|
38
|
+
};
|
|
39
|
+
function analyzeFile(input) {
|
|
40
|
+
const { relativePath, language, role, content, inventoryEntry } = input;
|
|
41
|
+
if (Buffer.byteLength(content, "utf8") > MAX_ANALYZABLE_BYTES) {
|
|
42
|
+
return {
|
|
43
|
+
relativePath,
|
|
44
|
+
language,
|
|
45
|
+
role,
|
|
46
|
+
parseStatus: "file-level-only",
|
|
47
|
+
analyzerId: TYPESCRIPT_JAVASCRIPT_ANALYZER_ID,
|
|
48
|
+
lineCount: inventoryEntry.lineCount,
|
|
49
|
+
imports: [],
|
|
50
|
+
exports: [],
|
|
51
|
+
declarations: [],
|
|
52
|
+
references: [],
|
|
53
|
+
diagnostics: [
|
|
54
|
+
{
|
|
55
|
+
severity: "info",
|
|
56
|
+
code: "file-too-large-for-analysis",
|
|
57
|
+
message: `File exceeds the ${MAX_ANALYZABLE_BYTES}-byte analyzer bound; only inventory-level facts are available.`,
|
|
58
|
+
path: relativePath,
|
|
59
|
+
analyzerId: TYPESCRIPT_JAVASCRIPT_ANALYZER_ID,
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
const scriptKind = resolveScriptKind(inventoryEntry.extension);
|
|
65
|
+
const sourceFile = ts.createSourceFile(relativePath, content, ts.ScriptTarget.Latest, true, scriptKind);
|
|
66
|
+
const diagnostics = getSyntaxDiagnostics(relativePath, sourceFile);
|
|
67
|
+
const imports = [];
|
|
68
|
+
const exports = [];
|
|
69
|
+
const declarations = [];
|
|
70
|
+
const references = [];
|
|
71
|
+
const lineOf = (node) => sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
|
|
72
|
+
function pushDeclaration(node, name, kind) {
|
|
73
|
+
const isExported = hasModifier(node, ts.SyntaxKind.ExportKeyword);
|
|
74
|
+
const isDefault = hasModifier(node, ts.SyntaxKind.DefaultKeyword);
|
|
75
|
+
if (name) {
|
|
76
|
+
declarations.push({ name, kind, exported: isExported, line: lineOf(node) });
|
|
77
|
+
}
|
|
78
|
+
if (isDefault) {
|
|
79
|
+
exports.push({ name: name ?? "default", kind: "default", line: lineOf(node) });
|
|
80
|
+
}
|
|
81
|
+
else if (isExported && name) {
|
|
82
|
+
exports.push({ name, kind: "named", line: lineOf(node) });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function handleImportDeclaration(node) {
|
|
86
|
+
if (!ts.isStringLiteralLike(node.moduleSpecifier))
|
|
87
|
+
return;
|
|
88
|
+
const source = node.moduleSpecifier.text;
|
|
89
|
+
const importedNames = [];
|
|
90
|
+
const clause = node.importClause;
|
|
91
|
+
if (clause) {
|
|
92
|
+
if (clause.name)
|
|
93
|
+
importedNames.push(clause.name.text);
|
|
94
|
+
const bindings = clause.namedBindings;
|
|
95
|
+
if (bindings && ts.isNamespaceImport(bindings)) {
|
|
96
|
+
importedNames.push(bindings.name.text);
|
|
97
|
+
}
|
|
98
|
+
else if (bindings && ts.isNamedImports(bindings)) {
|
|
99
|
+
for (const element of bindings.elements)
|
|
100
|
+
importedNames.push(element.name.text);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
imports.push({
|
|
104
|
+
source,
|
|
105
|
+
kind: "static",
|
|
106
|
+
line: lineOf(node),
|
|
107
|
+
...(importedNames.length > 0 ? { importedNames } : {}),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
function handleExportDeclaration(node) {
|
|
111
|
+
const source = node.moduleSpecifier && ts.isStringLiteralLike(node.moduleSpecifier) ? node.moduleSpecifier.text : undefined;
|
|
112
|
+
if (source) {
|
|
113
|
+
imports.push({ source, kind: "re-export", line: lineOf(node) });
|
|
114
|
+
}
|
|
115
|
+
if (!node.exportClause) {
|
|
116
|
+
if (source)
|
|
117
|
+
exports.push({ name: "*", kind: "namespace", line: lineOf(node) });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (ts.isNamespaceExport(node.exportClause)) {
|
|
121
|
+
exports.push({ name: node.exportClause.name.text, kind: "namespace", line: lineOf(node) });
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
for (const element of node.exportClause.elements) {
|
|
125
|
+
exports.push({ name: element.name.text, kind: "named", line: lineOf(node) });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function handleExportAssignment(node) {
|
|
129
|
+
if (node.isExportEquals) {
|
|
130
|
+
exports.push({ name: "module.exports", kind: "commonjs", line: lineOf(node) });
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const expr = node.expression;
|
|
134
|
+
let name = "default";
|
|
135
|
+
if (ts.isIdentifier(expr)) {
|
|
136
|
+
name = expr.text;
|
|
137
|
+
}
|
|
138
|
+
else if ((ts.isFunctionExpression(expr) || ts.isClassExpression(expr)) && expr.name) {
|
|
139
|
+
name = expr.name.text;
|
|
140
|
+
}
|
|
141
|
+
exports.push({ name, kind: "default", line: lineOf(node) });
|
|
142
|
+
}
|
|
143
|
+
function handleVariableStatement(node) {
|
|
144
|
+
const isExported = hasModifier(node, ts.SyntaxKind.ExportKeyword);
|
|
145
|
+
const isConst = (node.declarationList.flags & ts.NodeFlags.Const) !== 0;
|
|
146
|
+
for (const decl of node.declarationList.declarations) {
|
|
147
|
+
// Destructuring patterns (`const { a, b } = x`) are conservatively
|
|
148
|
+
// skipped -- only simple identifier bindings are recorded.
|
|
149
|
+
if (!ts.isIdentifier(decl.name))
|
|
150
|
+
continue;
|
|
151
|
+
declarations.push({
|
|
152
|
+
name: decl.name.text,
|
|
153
|
+
kind: isConst ? "constant" : "variable",
|
|
154
|
+
exported: isExported,
|
|
155
|
+
line: lineOf(decl),
|
|
156
|
+
});
|
|
157
|
+
if (isExported) {
|
|
158
|
+
exports.push({ name: decl.name.text, kind: "named", line: lineOf(decl) });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
function handleCommonJsAssignment(node) {
|
|
163
|
+
const left = node.left;
|
|
164
|
+
if (!ts.isPropertyAccessExpression(left) || !ts.isIdentifier(left.expression))
|
|
165
|
+
return;
|
|
166
|
+
if (left.expression.text === "module" && left.name.text === "exports") {
|
|
167
|
+
exports.push({ name: "module.exports", kind: "commonjs", line: lineOf(node) });
|
|
168
|
+
}
|
|
169
|
+
else if (left.expression.text === "exports") {
|
|
170
|
+
exports.push({ name: left.name.text, kind: "commonjs", line: lineOf(node) });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
function handleCallExpression(node) {
|
|
174
|
+
if (ts.isIdentifier(node.expression) && node.expression.text === "require") {
|
|
175
|
+
const arg = node.arguments[0];
|
|
176
|
+
if (arg && ts.isStringLiteralLike(arg)) {
|
|
177
|
+
imports.push({ source: arg.text, kind: "commonjs", line: lineOf(node) });
|
|
178
|
+
}
|
|
179
|
+
return; // already represented as an import fact -- do not also reference "require".
|
|
180
|
+
}
|
|
181
|
+
if (node.expression.kind === ts.SyntaxKind.ImportKeyword) {
|
|
182
|
+
const arg = node.arguments[0];
|
|
183
|
+
if (arg && ts.isStringLiteralLike(arg)) {
|
|
184
|
+
imports.push({ source: arg.text, kind: "dynamic", line: lineOf(node) });
|
|
185
|
+
}
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (ts.isIdentifier(node.expression)) {
|
|
189
|
+
// Conservative reference extraction: only bare-identifier call
|
|
190
|
+
// expressions (`foo()`), not property-access calls (`a.b()`) or any
|
|
191
|
+
// other identifier usage -- no scope analysis, no unused-detection.
|
|
192
|
+
references.push({ name: node.expression.text, kind: "call", line: lineOf(node) });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
function visit(node) {
|
|
196
|
+
if (ts.isImportDeclaration(node)) {
|
|
197
|
+
handleImportDeclaration(node);
|
|
198
|
+
}
|
|
199
|
+
else if (ts.isExportDeclaration(node)) {
|
|
200
|
+
handleExportDeclaration(node);
|
|
201
|
+
}
|
|
202
|
+
else if (ts.isExportAssignment(node)) {
|
|
203
|
+
handleExportAssignment(node);
|
|
204
|
+
}
|
|
205
|
+
else if (ts.isFunctionDeclaration(node)) {
|
|
206
|
+
pushDeclaration(node, node.name?.text, "function");
|
|
207
|
+
}
|
|
208
|
+
else if (ts.isClassDeclaration(node)) {
|
|
209
|
+
pushDeclaration(node, node.name?.text, "class");
|
|
210
|
+
}
|
|
211
|
+
else if (ts.isInterfaceDeclaration(node)) {
|
|
212
|
+
pushDeclaration(node, node.name.text, "interface");
|
|
213
|
+
}
|
|
214
|
+
else if (ts.isTypeAliasDeclaration(node)) {
|
|
215
|
+
pushDeclaration(node, node.name.text, "type");
|
|
216
|
+
}
|
|
217
|
+
else if (ts.isEnumDeclaration(node)) {
|
|
218
|
+
pushDeclaration(node, node.name.text, "enum");
|
|
219
|
+
}
|
|
220
|
+
else if (ts.isVariableStatement(node)) {
|
|
221
|
+
handleVariableStatement(node);
|
|
222
|
+
}
|
|
223
|
+
else if (ts.isMethodDeclaration(node) && ts.isIdentifier(node.name)) {
|
|
224
|
+
declarations.push({ name: node.name.text, kind: "method", exported: false, line: lineOf(node) });
|
|
225
|
+
}
|
|
226
|
+
else if (ts.isCallExpression(node)) {
|
|
227
|
+
handleCallExpression(node);
|
|
228
|
+
}
|
|
229
|
+
else if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken) {
|
|
230
|
+
handleCommonJsAssignment(node);
|
|
231
|
+
}
|
|
232
|
+
ts.forEachChild(node, visit);
|
|
233
|
+
}
|
|
234
|
+
visit(sourceFile);
|
|
235
|
+
const parseStatus = diagnostics.length > 0 ? "parse-error" : "parsed";
|
|
236
|
+
return {
|
|
237
|
+
relativePath,
|
|
238
|
+
language,
|
|
239
|
+
role,
|
|
240
|
+
parseStatus,
|
|
241
|
+
analyzerId: TYPESCRIPT_JAVASCRIPT_ANALYZER_ID,
|
|
242
|
+
lineCount: inventoryEntry.lineCount,
|
|
243
|
+
imports,
|
|
244
|
+
exports,
|
|
245
|
+
declarations,
|
|
246
|
+
references,
|
|
247
|
+
diagnostics,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
function resolveScriptKind(extension) {
|
|
251
|
+
switch (extension) {
|
|
252
|
+
case ".tsx":
|
|
253
|
+
return ts.ScriptKind.TSX;
|
|
254
|
+
case ".jsx":
|
|
255
|
+
return ts.ScriptKind.JSX;
|
|
256
|
+
case ".js":
|
|
257
|
+
case ".mjs":
|
|
258
|
+
case ".cjs":
|
|
259
|
+
return ts.ScriptKind.JS;
|
|
260
|
+
case ".ts":
|
|
261
|
+
case ".mts":
|
|
262
|
+
case ".cts":
|
|
263
|
+
default:
|
|
264
|
+
return ts.ScriptKind.TS;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
function hasModifier(node, kind) {
|
|
268
|
+
if (!ts.canHaveModifiers(node))
|
|
269
|
+
return false;
|
|
270
|
+
const modifiers = ts.getModifiers(node);
|
|
271
|
+
return modifiers?.some((m) => m.kind === kind) ?? false;
|
|
272
|
+
}
|
|
273
|
+
// Reads syntax diagnostics directly off the already-built SourceFile rather
|
|
274
|
+
// than running a second full parse via ts.transpileModule(). `parseDiagnostics`
|
|
275
|
+
// is not part of the public TypeScript.d.ts surface, but it is populated by
|
|
276
|
+
// createSourceFile() itself (every syntax error the parser recovers from is
|
|
277
|
+
// recorded there) and has been stable across TypeScript versions for years --
|
|
278
|
+
// this is a widely-used pattern for exactly this "syntax diagnostics without
|
|
279
|
+
// a second parse/transpile pass" use case. Benchmarked at ~5x faster than
|
|
280
|
+
// transpileModule() over this repo's own ~375 TS/JS files, which matters
|
|
281
|
+
// because collectSourceFacts.ts calls this analyzer once per eligible file
|
|
282
|
+
// on every audit run (including this repo's own self-audit).
|
|
283
|
+
function getSyntaxDiagnostics(relativePath, sourceFile) {
|
|
284
|
+
const parseDiagnostics = sourceFile.parseDiagnostics ?? [];
|
|
285
|
+
return parseDiagnostics.map((diagnostic) => toSourceFactDiagnostic(relativePath, diagnostic));
|
|
286
|
+
}
|
|
287
|
+
function toSourceFactDiagnostic(relativePath, diagnostic) {
|
|
288
|
+
const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n");
|
|
289
|
+
const line = diagnostic.file && diagnostic.start !== undefined
|
|
290
|
+
? diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start).line + 1
|
|
291
|
+
: undefined;
|
|
292
|
+
return {
|
|
293
|
+
severity: diagnostic.category === ts.DiagnosticCategory.Warning ? "warning" : "error",
|
|
294
|
+
code: `TS${diagnostic.code}`,
|
|
295
|
+
message: line !== undefined ? `Line ${line}: ${message}` : message,
|
|
296
|
+
path: relativePath,
|
|
297
|
+
analyzerId: TYPESCRIPT_JAVASCRIPT_ANALYZER_ID,
|
|
298
|
+
};
|
|
299
|
+
}
|