@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,33 @@
1
+ import { TYPESCRIPT_JAVASCRIPT_ANALYZER } from "./typescriptJavaScriptAnalyzer.js";
2
+ // Validates a list has no duplicate analyzer ids. Later batches call this
3
+ // when assembling their own registries; Batch 2's empty default trivially
4
+ // passes.
5
+ export function createLanguageAnalyzerRegistry(analyzers) {
6
+ const seen = new Set();
7
+ for (const analyzer of analyzers) {
8
+ if (seen.has(analyzer.id)) {
9
+ throw new Error(`Duplicate language analyzer id: "${analyzer.id}".`);
10
+ }
11
+ seen.add(analyzer.id);
12
+ }
13
+ return analyzers;
14
+ }
15
+ // First matching analyzer in registry order (deterministic), or null when
16
+ // none supports the given language/extension. Order is preserved from the
17
+ // input registry -- not re-sorted.
18
+ export function selectLanguageAnalyzer(registry, language, extension) {
19
+ for (const analyzer of registry) {
20
+ if (!analyzer.supportedLanguages.includes(language))
21
+ continue;
22
+ if (analyzer.supportedExtensions && !analyzer.supportedExtensions.includes(extension))
23
+ continue;
24
+ return analyzer;
25
+ }
26
+ return null;
27
+ }
28
+ // v0.3.1 Batch 3 -- registers the TypeScript/JavaScript analyzer. Python,
29
+ // Java, and Kotlin remain fallback-only (no analyzer registered for them) --
30
+ // see collectSourceFacts.ts's fallback policy.
31
+ export const DEFAULT_LANGUAGE_ANALYZER_REGISTRY = createLanguageAnalyzerRegistry([
32
+ TYPESCRIPT_JAVASCRIPT_ANALYZER,
33
+ ]);
@@ -0,0 +1,458 @@
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
+ // ---------------------------------------------------------------------------
31
+ // v0.3.1 Batch 1 — normalized language and file-role metadata.
32
+ //
33
+ // Purely additive to the v0.3.0 category model above: `category` keeps its
34
+ // existing meaning and existing consumers (detectors, report model) are
35
+ // untouched. `language` and `role` are deterministic, extension/path-based
36
+ // classifications layered on top so later v0.3.1 batches (language analyzer
37
+ // registry, per-language code-rot detectors) can select files by language
38
+ // without re-deriving it from extensions themselves. Language detection is
39
+ // classification-only here -- it does not imply parsing/analysis support for
40
+ // python/java/kotlin exists yet.
41
+ // ---------------------------------------------------------------------------
42
+ export const NORMALIZED_LANGUAGES = [
43
+ "typescript",
44
+ "javascript",
45
+ "python",
46
+ "java",
47
+ "kotlin",
48
+ "json",
49
+ "markdown",
50
+ "yaml",
51
+ "xml",
52
+ "toml",
53
+ "unknown",
54
+ ];
55
+ const EXTENSION_TO_LANGUAGE = {
56
+ ".ts": "typescript",
57
+ ".tsx": "typescript",
58
+ ".mts": "typescript",
59
+ ".cts": "typescript",
60
+ ".js": "javascript",
61
+ ".jsx": "javascript",
62
+ ".mjs": "javascript",
63
+ ".cjs": "javascript",
64
+ ".py": "python",
65
+ ".java": "java",
66
+ ".kt": "kotlin",
67
+ ".kts": "kotlin",
68
+ ".json": "json",
69
+ ".md": "markdown",
70
+ ".markdown": "markdown",
71
+ ".yml": "yaml",
72
+ ".yaml": "yaml",
73
+ ".xml": "xml",
74
+ ".toml": "toml",
75
+ };
76
+ function classifyLanguage(extension) {
77
+ return EXTENSION_TO_LANGUAGE[extension] ?? "unknown";
78
+ }
79
+ export const FILE_ROLES = [
80
+ "source",
81
+ "test",
82
+ "docs",
83
+ "config",
84
+ "package",
85
+ "generated",
86
+ "build-output",
87
+ "vendor",
88
+ "report-output",
89
+ "unknown",
90
+ ];
91
+ // Path-segment markers used only when a file wasn't already excluded from
92
+ // traversal entirely (EXCLUDED_DIR_NAMES below) -- e.g. paths reachable from
93
+ // nested benchmark/fixture projects, or directories not yet in the hard
94
+ // exclusion set. Deliberately conservative: matched as whole path segments,
95
+ // not substrings.
96
+ const VENDOR_PATH_SEGMENTS = new Set(["node_modules", "vendor"]);
97
+ const BUILD_OUTPUT_PATH_SEGMENTS = new Set(["dist", "build", "out", ".next", ".turbo", "target", ".gradle"]);
98
+ const GENERATED_PATH_SEGMENTS = new Set([
99
+ "generated",
100
+ ".cache",
101
+ ".my-dev-kit",
102
+ ".my-dev-kit-v1",
103
+ ".my-dev-kit-lab",
104
+ ]);
105
+ const REPORT_OUTPUT_PATH_SEGMENTS = new Set(["reports", "lab-output"]);
106
+ function classifyFileRole(relativePath, basename, extension, category) {
107
+ const segments = relativePath.split("/");
108
+ if (category === "report" ||
109
+ isStrayReportFile(basename) ||
110
+ segments.some((s) => REPORT_OUTPUT_PATH_SEGMENTS.has(s))) {
111
+ return "report-output";
112
+ }
113
+ if (segments.some((s) => VENDOR_PATH_SEGMENTS.has(s)))
114
+ return "vendor";
115
+ if (extension === ".tsbuildinfo" || segments.some((s) => BUILD_OUTPUT_PATH_SEGMENTS.has(s)))
116
+ return "build-output";
117
+ if (extension === ".map" || segments.some((s) => GENERATED_PATH_SEGMENTS.has(s)))
118
+ return "generated";
119
+ switch (category) {
120
+ case "tests":
121
+ return "test";
122
+ case "docs":
123
+ return "docs";
124
+ case "package":
125
+ return "package";
126
+ case "config":
127
+ case "ci":
128
+ return "config";
129
+ case "source":
130
+ case "scripts":
131
+ return "source";
132
+ default:
133
+ return "unknown";
134
+ }
135
+ }
136
+ // Directory names excluded from traversal entirely -- never descended into.
137
+ const EXCLUDED_DIR_NAMES = new Set([
138
+ "node_modules",
139
+ ".git",
140
+ "dist",
141
+ "build",
142
+ "coverage",
143
+ "reports",
144
+ "lab-output",
145
+ ".my-dev-kit",
146
+ ".my-dev-kit-v1",
147
+ ".my-dev-kit-lab",
148
+ "__pycache__",
149
+ ".idea",
150
+ ".vscode",
151
+ // Vitest's own local cache directory (gitignored) -- generated tool
152
+ // output, same category as coverage/.
153
+ ".vitest",
154
+ ]);
155
+ // OS/editor noise filenames, mirroring .gitignore's own conventions.
156
+ const EXCLUDED_FILE_NAMES = new Set([".DS_Store", "Thumbs.db", "desktop.ini"]);
157
+ function isNoiseFile(basename) {
158
+ return EXCLUDED_FILE_NAMES.has(basename) || basename.endsWith(".swp") || basename.endsWith("~");
159
+ }
160
+ // Known package/manifest filenames -> "package" category.
161
+ const PACKAGE_FILE_NAMES = new Set([
162
+ "package.json",
163
+ "package-lock.json",
164
+ "yarn.lock",
165
+ "pnpm-lock.yaml",
166
+ "npm-shrinkwrap.json",
167
+ "LICENSE",
168
+ ".npmrc",
169
+ ]);
170
+ // Known binary/media extensions -- never read for content (line count, etc).
171
+ const BINARY_EXTENSIONS = new Set([
172
+ ".png",
173
+ ".jpg",
174
+ ".jpeg",
175
+ ".gif",
176
+ ".ico",
177
+ ".pdf",
178
+ ".zip",
179
+ ".tgz",
180
+ ".tar",
181
+ ".gz",
182
+ ".woff",
183
+ ".woff2",
184
+ ".ttf",
185
+ ".eot",
186
+ ".exe",
187
+ ".dll",
188
+ ".so",
189
+ ".dylib",
190
+ ".wasm",
191
+ ".db",
192
+ ".sqlite",
193
+ ]);
194
+ const KNOWN_SOURCE_EXTENSIONS = new Set([
195
+ ".ts",
196
+ ".tsx",
197
+ ".js",
198
+ ".jsx",
199
+ ".mjs",
200
+ ".cjs",
201
+ ".py",
202
+ ".go",
203
+ ".rs",
204
+ ".java",
205
+ // v0.3.1 Batch 2 fix: .kt/.kts were classified as a NormalizedLanguage
206
+ // ("kotlin") since Batch 1 but were missing here, so a Kotlin file under
207
+ // src/ got category "unknown" (role "unknown") instead of "source" --
208
+ // silently excluding it from source-facts collection. .go/.rs above are
209
+ // intentionally left as "source" despite having no NormalizedLanguage
210
+ // mapping yet (they normalize to language "unknown"), which is the
211
+ // legitimate "known role, unknown language" case collectSourceFacts.ts's
212
+ // fallback policy handles.
213
+ ".kt",
214
+ ".kts",
215
+ // v0.3.1 Batch 3 fix: same class of gap as the .kt/.kts fix above -- .mts
216
+ // and .cts were mapped to NormalizedLanguage "typescript" since Batch 1
217
+ // but were missing here, so a .mts/.cts file under src/ got category
218
+ // "unknown" (role "unknown") and would never reach the Batch 3
219
+ // TypeScript/JavaScript analyzer's eligibility filter (role source/test
220
+ // only, see collectSourceFacts.ts).
221
+ ".mts",
222
+ ".cts",
223
+ ]);
224
+ // A file this large or larger is skipped entirely (not even stat'd into the
225
+ // inventory) -- protects against pathological huge-file scan cost.
226
+ const DEFAULT_HARD_SKIP_FILE_BYTES = 20_000_000;
227
+ // A file this large or larger is still inventoried (category, size, etc.)
228
+ // but never read for line-count -- keeps content reads bounded.
229
+ const DEFAULT_MAX_FILE_BYTES_FOR_LINE_COUNT = 500_000;
230
+ // Bytes read (at most) when sniffing for a NUL byte to detect binary content
231
+ // for files without a known binary extension.
232
+ const BINARY_SNIFF_BYTES = 512;
233
+ export function scanProjectInventory(targetRoot, options = {}) {
234
+ const resolvedRoot = path.resolve(targetRoot);
235
+ const hardSkipFileBytes = options.hardSkipFileBytes ?? DEFAULT_HARD_SKIP_FILE_BYTES;
236
+ const maxFileBytesForLineCount = options.maxFileBytesForLineCount ?? DEFAULT_MAX_FILE_BYTES_FOR_LINE_COUNT;
237
+ const warnings = [];
238
+ const excludedDirCounts = new Map();
239
+ const files = [];
240
+ let totalFileCount = 0;
241
+ let skippedFileCount = 0;
242
+ let oversizedSkipCount = 0;
243
+ walk(resolvedRoot);
244
+ files.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
245
+ if (oversizedSkipCount > 0) {
246
+ warnings.push(`Skipped ${oversizedSkipCount} oversized file(s) (>= ${hardSkipFileBytes} bytes): possible generated bundle or binary artifact.`);
247
+ }
248
+ const filesByCategory = countByCategory(files);
249
+ const filesByExtension = countByExtension(files);
250
+ const filesByLanguage = countByLanguage(files);
251
+ const filesByRole = countByRole(files);
252
+ const excludedDirectorySummary = [...excludedDirCounts.entries()]
253
+ .map(([name, occurrences]) => ({ name, occurrences }))
254
+ .sort((a, b) => a.name.localeCompare(b.name));
255
+ return {
256
+ targetRoot: resolvedRoot,
257
+ scanRoot: resolvedRoot,
258
+ totalFileCount,
259
+ totalScannedFileCount: files.length,
260
+ skippedFileCount,
261
+ filesByCategory,
262
+ filesByExtension,
263
+ filesByLanguage,
264
+ filesByRole,
265
+ packageFiles: files.filter((f) => f.category === "package"),
266
+ docsFiles: files.filter((f) => f.category === "docs"),
267
+ sourceFiles: files.filter((f) => f.category === "source"),
268
+ testFiles: files.filter((f) => f.category === "tests"),
269
+ scriptFiles: files.filter((f) => f.category === "scripts"),
270
+ ciFiles: files.filter((f) => f.category === "ci"),
271
+ excludedDirectorySummary,
272
+ warnings,
273
+ files,
274
+ };
275
+ function walk(dir) {
276
+ let entries;
277
+ try {
278
+ entries = fs.readdirSync(dir, { withFileTypes: true });
279
+ }
280
+ catch (err) {
281
+ warnings.push(`Could not read directory: ${dir} (${err instanceof Error ? err.message : String(err)})`);
282
+ return;
283
+ }
284
+ // Sort entries so traversal order (and therefore file discovery order
285
+ // before the final sort-by-relativePath) is stable across platforms
286
+ // regardless of the OS's own readdir ordering.
287
+ const sorted = [...entries].sort((a, b) => a.name.localeCompare(b.name));
288
+ for (const entry of sorted) {
289
+ const fullPath = path.join(dir, entry.name);
290
+ if (entry.isDirectory()) {
291
+ if (EXCLUDED_DIR_NAMES.has(entry.name)) {
292
+ excludedDirCounts.set(entry.name, (excludedDirCounts.get(entry.name) ?? 0) + 1);
293
+ continue;
294
+ }
295
+ walk(fullPath);
296
+ continue;
297
+ }
298
+ if (!entry.isFile()) {
299
+ // Skip symlinks and other special entries -- not part of Batch 2 scope.
300
+ continue;
301
+ }
302
+ if (isNoiseFile(entry.name)) {
303
+ continue;
304
+ }
305
+ totalFileCount += 1;
306
+ let stat;
307
+ try {
308
+ stat = fs.statSync(fullPath);
309
+ }
310
+ catch (err) {
311
+ skippedFileCount += 1;
312
+ warnings.push(`Could not stat file: ${fullPath} (${err instanceof Error ? err.message : String(err)})`);
313
+ continue;
314
+ }
315
+ if (stat.size >= hardSkipFileBytes) {
316
+ skippedFileCount += 1;
317
+ oversizedSkipCount += 1;
318
+ continue;
319
+ }
320
+ const relativePath = relativeWithinRoot(resolvedRoot, fullPath);
321
+ const extension = path.extname(entry.name).toLowerCase();
322
+ const category = classifyFile(relativePath, entry.name, extension);
323
+ const language = classifyLanguage(extension);
324
+ const role = classifyFileRole(relativePath, entry.name, extension, category);
325
+ const entryResult = {
326
+ relativePath,
327
+ normalizedPath: relativePath.toLowerCase(),
328
+ extension,
329
+ category,
330
+ language,
331
+ role,
332
+ isGenerated: role === "generated",
333
+ isBuildOutput: role === "build-output",
334
+ isVendor: role === "vendor",
335
+ isReportOutput: role === "report-output",
336
+ sizeBytes: stat.size,
337
+ likelyGenerated: category === "generated" || category === "report",
338
+ likelyConfig: category === "config" || category === "package",
339
+ likelyDocs: category === "docs",
340
+ likelyTest: category === "tests",
341
+ likelySource: category === "source" || category === "scripts",
342
+ };
343
+ if (!BINARY_EXTENSIONS.has(extension) && stat.size <= maxFileBytesForLineCount) {
344
+ const lineCount = tryCountLines(fullPath, warnings);
345
+ if (lineCount !== undefined) {
346
+ entryResult.lineCount = lineCount;
347
+ }
348
+ }
349
+ files.push(entryResult);
350
+ }
351
+ }
352
+ }
353
+ function classifyFile(relativePath, basename, extension) {
354
+ if (PACKAGE_FILE_NAMES.has(basename))
355
+ return "package";
356
+ if (relativePath.startsWith(".github/workflows/") && (extension === ".yml" || extension === ".yaml")) {
357
+ return "ci";
358
+ }
359
+ if (isTestFile(relativePath, basename))
360
+ return "tests";
361
+ // Top-level scripts/ directory -- classified before generic "source" so
362
+ // scripts (which are also .ts/.js) land in their own category.
363
+ if (relativePath.startsWith("scripts/"))
364
+ return "scripts";
365
+ if (extension === ".md" || extension === ".mdx" || relativePath.startsWith("docs/"))
366
+ return "docs";
367
+ if (isConfigFile(relativePath, basename, extension))
368
+ return "config";
369
+ if (isStrayReportFile(basename))
370
+ return "report";
371
+ if (extension === ".map")
372
+ return "generated";
373
+ if (KNOWN_SOURCE_EXTENSIONS.has(extension))
374
+ return "source";
375
+ return "unknown";
376
+ }
377
+ function isTestFile(relativePath, basename) {
378
+ if (relativePath.startsWith("tests/") || relativePath.includes("/tests/"))
379
+ return true;
380
+ return /\.(test|spec)\.[jt]sx?$/.test(basename);
381
+ }
382
+ function isConfigFile(relativePath, basename, extension) {
383
+ if (/^tsconfig(\..+)?\.json$/.test(basename))
384
+ return true;
385
+ if (/^vitest\.config\.[jt]s$/.test(basename) || basename === "vitest.workspace.ts")
386
+ return true;
387
+ if (/^\.eslintrc(\..+)?$/.test(basename) || /^eslint\.config\.[jt]s$/.test(basename))
388
+ return true;
389
+ if (basename === ".editorconfig" || basename === ".gitignore" || basename === ".npmignore")
390
+ return true;
391
+ // Root-level dotfile configs (e.g. .prettierrc, .babelrc) not already
392
+ // covered above -- deliberately conservative (root-only, not recursive)
393
+ // to avoid misclassifying dotfiles inside fixture/benchmark projects.
394
+ if (!relativePath.includes("/") && basename.startsWith("."))
395
+ return true;
396
+ if (extension === ".json" && basename.toLowerCase().includes("config"))
397
+ return true;
398
+ return false;
399
+ }
400
+ function isStrayReportFile(basename) {
401
+ return /-security-validation\.(txt|json)$/.test(basename) || /-audit\.(txt|json)$/.test(basename);
402
+ }
403
+ function tryCountLines(fullPath, warnings) {
404
+ let buffer;
405
+ try {
406
+ buffer = fs.readFileSync(fullPath);
407
+ }
408
+ catch (err) {
409
+ warnings.push(`Could not read file for line count: ${fullPath} (${err instanceof Error ? err.message : String(err)})`);
410
+ return undefined;
411
+ }
412
+ const sniffLength = Math.min(buffer.length, BINARY_SNIFF_BYTES);
413
+ for (let i = 0; i < sniffLength; i++) {
414
+ if (buffer[i] === 0) {
415
+ // NUL byte present -- treat as binary, skip line counting.
416
+ return undefined;
417
+ }
418
+ }
419
+ if (buffer.length === 0)
420
+ return 0;
421
+ const text = buffer.toString("utf8");
422
+ let lines = 1;
423
+ for (let i = 0; i < text.length; i++) {
424
+ if (text[i] === "\n")
425
+ lines += 1;
426
+ }
427
+ return lines;
428
+ }
429
+ function countByCategory(files) {
430
+ const counts = Object.fromEntries(INVENTORY_FILE_CATEGORIES.map((c) => [c, 0]));
431
+ for (const file of files) {
432
+ counts[file.category] += 1;
433
+ }
434
+ return counts;
435
+ }
436
+ function countByLanguage(files) {
437
+ const counts = Object.fromEntries(NORMALIZED_LANGUAGES.map((l) => [l, 0]));
438
+ for (const file of files) {
439
+ counts[file.language] += 1;
440
+ }
441
+ return counts;
442
+ }
443
+ function countByRole(files) {
444
+ const counts = Object.fromEntries(FILE_ROLES.map((r) => [r, 0]));
445
+ for (const file of files) {
446
+ counts[file.role] += 1;
447
+ }
448
+ return counts;
449
+ }
450
+ function countByExtension(files) {
451
+ const counts = new Map();
452
+ for (const file of files) {
453
+ const key = file.extension || "(none)";
454
+ counts.set(key, (counts.get(key) ?? 0) + 1);
455
+ }
456
+ const sortedEntries = [...counts.entries()].sort(([a], [b]) => a.localeCompare(b));
457
+ return Object.fromEntries(sortedEntries);
458
+ }
@@ -0,0 +1,17 @@
1
+ // ---------------------------------------------------------------------------
2
+ // v0.3.1 Batch 2 -- normalized source facts model.
3
+ //
4
+ // Purely additive substrate: no concrete language analyzer exists yet (that
5
+ // is Batch 3's TypeScript/JavaScript analyzer), so every SourceFileFacts
6
+ // produced in this batch is either a deterministic fallback (see
7
+ // collectSourceFacts.ts) or a parse-error record. The fact arrays
8
+ // (imports/exports/declarations/references) are always empty in this batch
9
+ // -- the shape exists so Batch 3 can populate them without a type change.
10
+ // ---------------------------------------------------------------------------
11
+ export const SOURCE_FACT_PARSE_STATUSES = [
12
+ "parsed",
13
+ "file-level-only",
14
+ "unsupported",
15
+ "parse-error",
16
+ "skipped",
17
+ ];