@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.
Files changed (42) hide show
  1. package/README.md +17 -7
  2. package/dist/scripts/audits/runAudit.js +102 -0
  3. package/dist/src/audits/codeRot/codeRotDetectors.js +17 -0
  4. package/dist/src/audits/codeRot/detectors/architectureDriftDetector.js +271 -0
  5. package/dist/src/audits/codeRot/detectors/crossPlatformRotDetector.js +214 -0
  6. package/dist/src/audits/codeRot/detectors/deadCodeCandidateDetector.js +377 -0
  7. package/dist/src/audits/codeRot/detectors/dependencyEnvironmentRotDetector.js +244 -0
  8. package/dist/src/audits/codeRot/detectors/docsCodeMismatchDetector.js +219 -0
  9. package/dist/src/audits/codeRot/detectors/duplicateImplementationDetector.js +350 -0
  10. package/dist/src/audits/codeRot/detectors/packageReleaseRotDetector.js +325 -0
  11. package/dist/src/audits/codeRot/detectors/securityValidationAssumptionRotDetector.js +385 -0
  12. package/dist/src/audits/codeRot/detectors/staleCommandReferenceDetector.js +135 -0
  13. package/dist/src/audits/codeRot/detectors/testRotDetector.js +410 -0
  14. package/dist/src/audits/codeRot/utils/boundedRead.js +23 -0
  15. package/dist/src/audits/codeRot/utils/docClaimPatterns.js +62 -0
  16. package/dist/src/audits/codeRot/utils/docCommandReferences.js +64 -0
  17. package/dist/src/audits/codeRot/utils/filePatternUtils.js +79 -0
  18. package/dist/src/audits/codeRot/utils/issueFactories.js +38 -0
  19. package/dist/src/audits/codeRot/utils/sourceFactsLookup.js +53 -0
  20. package/dist/src/audits/codeRot/utils/textLines.js +12 -0
  21. package/dist/src/audits/core/auditConfig.js +125 -0
  22. package/dist/src/audits/core/auditExitCode.js +75 -0
  23. package/dist/src/audits/core/auditIssue.js +20 -0
  24. package/dist/src/audits/core/auditRegistry.js +40 -0
  25. package/dist/src/audits/core/auditRunner.js +74 -0
  26. package/dist/src/audits/core/auditTarget.js +19 -0
  27. package/dist/src/audits/core/auditTypes.js +30 -0
  28. package/dist/src/audits/core/collectSourceFacts.js +142 -0
  29. package/dist/src/audits/core/languageAnalyzerRegistry.js +33 -0
  30. package/dist/src/audits/core/projectInventory.js +458 -0
  31. package/dist/src/audits/core/sourceFacts.js +17 -0
  32. package/dist/src/audits/core/sourceOfTruth.js +339 -0
  33. package/dist/src/audits/core/textLines.js +24 -0
  34. package/dist/src/audits/core/typescriptJavaScriptAnalyzer.js +299 -0
  35. package/dist/src/audits/report/auditReportModel.js +253 -0
  36. package/dist/src/audits/report/auditReportPaths.js +13 -0
  37. package/dist/src/audits/report/renderAuditJsonReport.js +30 -0
  38. package/dist/src/audits/report/renderAuditTextReport.js +181 -0
  39. package/dist/src/audits/report/sanitizeAuditText.js +39 -0
  40. package/dist/src/audits/report/writeAuditReports.js +27 -0
  41. package/dist/src/core/resolveCommand.js +53 -2
  42. 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
+ }