@dailephd/my-dev-kit-lab 0.3.0 → 0.3.2

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 (27) hide show
  1. package/README.md +19 -6
  2. package/dist/scripts/audits/runAudit.js +9 -2
  3. package/dist/scripts/security/validate.js +8 -29
  4. package/dist/src/audits/codeRot/detectors/deadCodeCandidateDetector.js +138 -11
  5. package/dist/src/audits/codeRot/detectors/duplicateImplementationDetector.js +93 -1
  6. package/dist/src/audits/codeRot/detectors/testRotDetector.js +176 -0
  7. package/dist/src/audits/codeRot/utils/filePatternUtils.js +50 -0
  8. package/dist/src/audits/codeRot/utils/sourceFactsLookup.js +53 -0
  9. package/dist/src/audits/core/auditConfig.js +2 -2
  10. package/dist/src/audits/core/auditRunner.js +23 -1
  11. package/dist/src/audits/core/auditTypes.js +14 -7
  12. package/dist/src/audits/core/collectSourceFacts.js +142 -0
  13. package/dist/src/audits/core/languageAnalyzerRegistry.js +36 -0
  14. package/dist/src/audits/core/projectInventory.js +185 -3
  15. package/dist/src/audits/core/pythonAnalyzer.js +328 -0
  16. package/dist/src/audits/core/pythonProjectMetadata.js +80 -0
  17. package/dist/src/audits/core/sourceFacts.js +17 -0
  18. package/dist/src/audits/core/typescriptJavaScriptAnalyzer.js +299 -0
  19. package/dist/src/audits/report/auditReportModel.js +15 -0
  20. package/dist/src/audits/report/renderAuditJsonReport.js +3 -0
  21. package/dist/src/audits/report/renderAuditTextReport.js +59 -3
  22. package/dist/src/audits/security/mapSecurityFindingToAuditIssue.js +70 -0
  23. package/dist/src/audits/security/securityAuditAdapter.js +67 -0
  24. package/dist/src/audits/security/securityAuditTypes.js +17 -0
  25. package/dist/src/securityValidation/report/buildSecurityReport.js +31 -0
  26. package/dist/src/securityValidation/validate/resolveTarget.js +9 -0
  27. package/package.json +97 -96
@@ -1,7 +1,9 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { collectPythonProjectMetadata } from "../../core/pythonProjectMetadata.js";
3
4
  import { readBoundedFileText } from "../utils/boundedRead.js";
4
5
  import { deduplicateIssuesById, makeCodeRotIssue } from "../utils/issueFactories.js";
6
+ import { getParsedSourceFacts, indexSourceFactsByPath } from "../utils/sourceFactsLookup.js";
5
7
  import { splitLines } from "../utils/textLines.js";
6
8
  // ---------------------------------------------------------------------------
7
9
  // v0.3.0 Batch 4 — test-rot detector.
@@ -42,6 +44,13 @@ export const TEST_ROT_DETECTOR = {
42
44
  const issues = [];
43
45
  const scriptNames = new Set(ctx.sourceOfTruth.commands.allScriptNames);
44
46
  const currentVersion = ctx.sourceOfTruth.package?.version ?? null;
47
+ const sourceFactsIndex = indexSourceFactsByPath(ctx.sourceFacts);
48
+ // v0.3.2 Batch 2 -- computed once per run, not per test file. Presence-
49
+ // only/simple-text-extraction (see pythonProjectMetadata.ts); used below
50
+ // purely as weak supporting context on Python findings, never as a
51
+ // required condition -- its absence must not change whether a finding
52
+ // is emitted, only add or omit one extra evidence line.
53
+ const pythonMetadata = collectPythonProjectMetadata(ctx.target.rootPath, ctx.inventory);
45
54
  for (const testFile of ctx.inventory.testFiles) {
46
55
  const content = readBoundedFileText(ctx.target.rootPath, testFile.relativePath, testFile.sizeBytes, MAX_READ_BYTES);
47
56
  if (content === null)
@@ -61,6 +70,26 @@ export const TEST_ROT_DETECTOR = {
61
70
  if (testFile.relativePath.startsWith("tests/audits/codeRot/"))
62
71
  continue;
63
72
  issues.push(...findMissingSourceImports(ctx, testFile.relativePath, content));
73
+ // v0.3.1 Batch 4 -- when the TypeScript/JavaScript analyzer actually
74
+ // parsed this test file, also check its structured import facts.
75
+ // This catches import forms the regex above misses (e.g. multi-line
76
+ // import statements) without duplicate-reporting a specifier both
77
+ // checks already found: idCues use the same shape
78
+ // (testRelativePath, "missing-source-import", specifier), so an
79
+ // identical id is deduplicated by deduplicateIssuesById() below.
80
+ const parsedFacts = getParsedSourceFacts(sourceFactsIndex, testFile.relativePath);
81
+ if (parsedFacts?.language === "python") {
82
+ // v0.3.2 Batch 2 -- Python relative imports use dotted-module
83
+ // notation (".module", "..pkg.module"), not TS/JS's path-like
84
+ // "./module" syntax. Routing Python facts through the TS/JS
85
+ // resolver below would misresolve every relative Python import and
86
+ // flag it as missing regardless of whether it actually exists --
87
+ // this guard is the fix for that latent cross-language bug.
88
+ issues.push(...findMissingPythonSourceImports(ctx, testFile.relativePath, parsedFacts, pythonMetadata));
89
+ }
90
+ else if (parsedFacts) {
91
+ issues.push(...findMissingSourceImportsFromSourceFacts(ctx, testFile.relativePath, parsedFacts));
92
+ }
64
93
  issues.push(...findStaleNpmRunReferences(testFile.relativePath, content, scriptNames));
65
94
  issues.push(...findSkipUsage(testFile.relativePath, content));
66
95
  issues.push(...findOnlyUsage(testFile.relativePath, content));
@@ -115,6 +144,153 @@ function findMissingSourceImports(ctx, testRelativePath, content) {
115
144
  }
116
145
  return issues;
117
146
  }
147
+ // v0.3.1 Batch 4 -- source-facts-derived counterpart to
148
+ // findMissingSourceImports() above. Uses the TypeScript/JavaScript
149
+ // analyzer's already-parsed ImportFact list instead of a regex scan of raw
150
+ // content, so it also catches relative import specifiers the regex above
151
+ // misses. Only called for facts with parseStatus "parsed" (see caller) --
152
+ // file-level-only/parse-error facts carry an empty imports array that
153
+ // would otherwise silently look like "no imports" rather than "unknown".
154
+ function findMissingSourceImportsFromSourceFacts(ctx, testRelativePath, facts) {
155
+ const issues = [];
156
+ const testDir = path.dirname(testRelativePath);
157
+ for (const imp of facts.imports) {
158
+ if (!imp.source.startsWith("."))
159
+ continue;
160
+ const resolvedBase = path.join(testDir, imp.source).replace(/\\/g, "/");
161
+ const strippedBase = resolvedBase.replace(/\.[cm]?[jt]sx?$/, "");
162
+ const resolved = RESOLVE_EXTENSIONS.some((ext) => fs.existsSync(path.join(ctx.target.rootPath, `${strippedBase}${ext}`)));
163
+ if (resolved)
164
+ continue;
165
+ issues.push(makeCodeRotIssue({
166
+ auditType: "code-rot",
167
+ detectorId: DETECTOR_ID,
168
+ idCues: [testRelativePath, "missing-source-import", imp.source],
169
+ title: `Test "${testRelativePath}" imports a missing source file: "${imp.source}"`,
170
+ description: `${testRelativePath} imports "${imp.source}" (source-facts-derived via the TypeScript/JavaScript analyzer), which does not resolve to an existing file on disk (checked common TS/JS extensions relative to the test file).`,
171
+ severity: "medium",
172
+ confidence: "high",
173
+ falsePositiveRisk: "low",
174
+ category: "test-rot",
175
+ recommendedAction: "Fix or remove the stale import.",
176
+ suggestedFixStrategy: `Update the import specifier "${imp.source}" in ${testRelativePath} to point to an existing file.`,
177
+ validationCommands: [`npx vitest run ${testRelativePath}`],
178
+ releaseBlocking: false,
179
+ implementationBlocking: false,
180
+ autoFixEligible: false,
181
+ evidence: [
182
+ {
183
+ kind: "file",
184
+ message: "Analyzer-recorded relative import specifier does not resolve to an existing file.",
185
+ filePath: testRelativePath,
186
+ excerpt: imp.source,
187
+ source: DETECTOR_ID,
188
+ confidence: "high",
189
+ },
190
+ ],
191
+ affectedFiles: [testRelativePath],
192
+ }));
193
+ }
194
+ return issues;
195
+ }
196
+ // v0.3.2 Batch 2 -- resolves a Python relative-import source (".module",
197
+ // "..pkg.sub", or bare "."/".." paired with `from . import name`-style
198
+ // imported names) to candidate local file paths, using Python's own
199
+ // relative-import level semantics (one leading dot = the importing file's
200
+ // own directory, each additional dot = one directory further up). This is a
201
+ // conservative, path-based approximation -- it does not resolve namespace
202
+ // packages, `sys.path` manipulation, editable installs, or anything beyond
203
+ // simple on-disk file/package presence, so returning zero matches is a
204
+ // candidate "may indicate a missing module" signal, not proof.
205
+ function resolvePythonRelativeImportCandidatePaths(testDir, importSource, importedNames) {
206
+ if (!importSource.startsWith("."))
207
+ return [];
208
+ let dotCount = 0;
209
+ while (dotCount < importSource.length && importSource[dotCount] === ".")
210
+ dotCount += 1;
211
+ const remainder = importSource.slice(dotCount);
212
+ const upLevels = dotCount - 1;
213
+ let baseDir = testDir;
214
+ for (let i = 0; i < upLevels; i++)
215
+ baseDir = path.dirname(baseDir);
216
+ const candidates = [];
217
+ const pushCandidatesFor = (moduleSegment) => {
218
+ candidates.push(path.join(baseDir, `${moduleSegment}.py`).replace(/\\/g, "/"));
219
+ candidates.push(path.join(baseDir, moduleSegment, "__init__.py").replace(/\\/g, "/"));
220
+ };
221
+ if (remainder) {
222
+ pushCandidatesFor(remainder.split(".").join("/"));
223
+ }
224
+ else {
225
+ // Bare "." / ".." (e.g. `from . import sibling`) -- the module lives
226
+ // in `importedNames`, not in `importSource` itself.
227
+ for (const name of importedNames ?? [])
228
+ pushCandidatesFor(name);
229
+ }
230
+ return candidates;
231
+ }
232
+ // v0.3.2 Batch 2 -- Python-specific counterpart to
233
+ // findMissingSourceImportsFromSourceFacts() above. Only ever called for
234
+ // facts with `language === "python"` (see caller) -- the two functions are
235
+ // kept fully separate rather than parameterized by language, because their
236
+ // resolution algorithms are genuinely different (dotted-module relative-
237
+ // import-level semantics vs. path-like specifier semantics), and conflating
238
+ // them risked silently misresolving one language or the other. Deliberately
239
+ // lower severity/confidence than the TS/JS counterpart: this scan has no
240
+ // real package/sys.path awareness, so it is weaker evidence than the
241
+ // TS/JS analyzer's near-exact relative-path resolution.
242
+ function findMissingPythonSourceImports(ctx, testRelativePath, facts, pythonMetadata) {
243
+ const issues = [];
244
+ const testDir = path.dirname(testRelativePath);
245
+ for (const imp of facts.imports) {
246
+ if (!imp.source.startsWith("."))
247
+ continue; // conservative: only relative Python imports are checked
248
+ const candidates = resolvePythonRelativeImportCandidatePaths(testDir, imp.source, imp.importedNames);
249
+ if (candidates.length === 0)
250
+ continue; // nothing checkable (e.g. bare "." with no imported names)
251
+ const resolved = candidates.some((candidate) => fs.existsSync(path.join(ctx.target.rootPath, candidate)));
252
+ if (resolved)
253
+ continue;
254
+ const evidence = [
255
+ {
256
+ kind: "file",
257
+ message: "Analyzer-recorded relative Python import does not resolve to a candidate local module/package file. This is a best-effort, path-based check -- it does not resolve Python's real package/sys.path rules, so confirm manually before treating it as stale.",
258
+ filePath: testRelativePath,
259
+ excerpt: imp.source,
260
+ source: DETECTOR_ID,
261
+ confidence: "low",
262
+ },
263
+ ];
264
+ if (pythonMetadata.hasPytestConfiguration) {
265
+ evidence.push({
266
+ kind: "observation",
267
+ message: "Weak supporting context: this project has a detected pytest configuration (pytest.ini, or a pyproject.toml/setup.cfg pytest section), consistent with pytest-style test conventions -- not a requirement for this finding.",
268
+ source: DETECTOR_ID,
269
+ confidence: "low",
270
+ });
271
+ }
272
+ issues.push(makeCodeRotIssue({
273
+ auditType: "code-rot",
274
+ detectorId: DETECTOR_ID,
275
+ idCues: [testRelativePath, "missing-python-source-import", imp.source],
276
+ title: `Test "${testRelativePath}" imports a Python module that may not exist: "${imp.source}"`,
277
+ description: `${testRelativePath} imports "${imp.source}" (source-facts-derived via the Python analyzer). No candidate local module or package file was found relative to the test file. This is a deterministic source-facts signal from a conservative, non-semantic scan -- it does not resolve Python's real package/sys.path rules, namespace packages, or editable installs, so this may indicate a stale import but is not proof.`,
278
+ severity: "low",
279
+ confidence: "low",
280
+ falsePositiveRisk: "medium",
281
+ category: "test-rot",
282
+ recommendedAction: "Confirm the imported Python module exists (it may resolve via a path this scan cannot see) before treating this as stale.",
283
+ suggestedFixStrategy: `Update or remove the import "${imp.source}" in ${testRelativePath} if confirmed stale.`,
284
+ validationCommands: [`npx vitest run ${testRelativePath}`],
285
+ releaseBlocking: false,
286
+ implementationBlocking: false,
287
+ autoFixEligible: false,
288
+ evidence,
289
+ affectedFiles: [testRelativePath],
290
+ }));
291
+ }
292
+ return issues;
293
+ }
118
294
  function findStaleNpmRunReferences(testRelativePath, content, scriptNames) {
119
295
  const issues = [];
120
296
  const seen = new Set();
@@ -45,3 +45,53 @@ export const GENERIC_INFRA_BASENAMES = new Set([
45
45
  "errors",
46
46
  "error",
47
47
  ]);
48
+ // v0.3.1 Batch 4 -- common, generic exported-declaration names that are
49
+ // expected to repeat across unrelated files legitimately (CLI entrypoints,
50
+ // handler/lifecycle conventions, etc.) -- excluded from the source-facts
51
+ // duplicate-declaration-candidate heuristic in duplicateImplementationDetector.ts
52
+ // to keep false positives near zero. v0.3.2 Batch 2 adds Python lifecycle/
53
+ // dunder names ("teardown", "__init__", "__call__") for the same reason,
54
+ // reused by both duplicateImplementationDetector.ts and
55
+ // deadCodeCandidateDetector.ts's new Python-aware checks.
56
+ export const GENERIC_DECLARATION_NAMES = new Set([
57
+ "run",
58
+ "main",
59
+ "init",
60
+ "initialize",
61
+ "setup",
62
+ "teardown",
63
+ "handler",
64
+ "handle",
65
+ "execute",
66
+ "process",
67
+ "validate",
68
+ "parse",
69
+ "format",
70
+ "render",
71
+ "build",
72
+ "create",
73
+ "start",
74
+ "stop",
75
+ "load",
76
+ "save",
77
+ "update",
78
+ "remove",
79
+ "apply",
80
+ "get",
81
+ "set",
82
+ "default",
83
+ "__init__",
84
+ "__call__",
85
+ ]);
86
+ // v0.3.2 Batch 2 -- true for a generic/lifecycle declaration name (see
87
+ // GENERIC_DECLARATION_NAMES above) or a pytest-style `test_*` name. Shared by
88
+ // the Python-aware dead-code and duplicate-implementation checks so a
89
+ // `test_*` helper/fixture function is never flagged as a candidate dead-code
90
+ // symbol or a duplicate-implementation candidate merely for repeating across
91
+ // unrelated test modules, a legitimate and extremely common pytest
92
+ // convention. Comparison is case-insensitive; callers pass the already-
93
+ // lowercased name where convenient.
94
+ export function isGenericOrTestPrefixedDeclarationName(name) {
95
+ const lower = name.toLowerCase();
96
+ return GENERIC_DECLARATION_NAMES.has(lower) || lower.startsWith("test_");
97
+ }
@@ -0,0 +1,53 @@
1
+ import { baseNameNoExt } from "./filePatternUtils.js";
2
+ // ---------------------------------------------------------------------------
3
+ // v0.3.1 Batch 4 -- shared source-facts lookup helpers for code-rot
4
+ // detectors.
5
+ //
6
+ // ctx.sourceFacts is optional (see auditRegistry.ts) precisely so existing
7
+ // detector tests that build a literal AuditDetectorContext without it keep
8
+ // working unchanged -- every helper here accepts `undefined` and degrades to
9
+ // an empty result rather than throwing, so a caller never needs its own
10
+ // null-check before using them.
11
+ // ---------------------------------------------------------------------------
12
+ // Indexes a SourceFactsSnapshot's files by relativePath for O(1) per-file
13
+ // lookups. Returns an empty map when sourceFacts is undefined.
14
+ export function indexSourceFactsByPath(sourceFacts) {
15
+ const index = new Map();
16
+ if (!sourceFacts)
17
+ return index;
18
+ for (const file of sourceFacts.files)
19
+ index.set(file.relativePath, file);
20
+ return index;
21
+ }
22
+ // Returns the facts for relativePath only when parseStatus is "parsed" --
23
+ // callers that want analyzer-derived evidence (imports/exports/declarations)
24
+ // should use this rather than reading the index directly, since
25
+ // file-level-only/parse-error/skipped/unsupported entries carry empty fact
26
+ // arrays that would silently look like "no imports" rather than "unknown".
27
+ export function getParsedSourceFacts(index, relativePath) {
28
+ const facts = index.get(relativePath);
29
+ return facts && facts.parseStatus === "parsed" ? facts : null;
30
+ }
31
+ // Basenames referenced by any parsed file's relative import/re-export
32
+ // specifier, lowercased. Mirrors deadCodeCandidateDetector.ts's regex-based
33
+ // buildReverseReferenceIndex() basename matching, but sourced from
34
+ // structured analyzer facts -- so it also catches import forms the regex
35
+ // misses (e.g. multi-line import statements) for files a registered
36
+ // analyzer actually parsed. Returns an empty set when sourceFacts is
37
+ // undefined, so merging it into an existing Set is always safe.
38
+ export function collectRelativeImportBasenames(sourceFacts) {
39
+ const basenames = new Set();
40
+ if (!sourceFacts)
41
+ return basenames;
42
+ for (const file of sourceFacts.files) {
43
+ if (file.parseStatus !== "parsed")
44
+ continue;
45
+ for (const imp of file.imports) {
46
+ if (!imp.source.startsWith("."))
47
+ continue;
48
+ const base = baseNameNoExt(imp.source.split("?")[0]);
49
+ basenames.add(base.toLowerCase());
50
+ }
51
+ }
52
+ return basenames;
53
+ }
@@ -1,5 +1,5 @@
1
1
  import path from "node:path";
2
- import { AUDIT_FAIL_ON_THRESHOLDS, AUDIT_INCLUDE_AREAS, AUDIT_OUTPUT_FORMATS, AUDIT_TYPES, DEFAULT_AUDIT_FAIL_ON_THRESHOLD, DEFAULT_AUDIT_INCLUDE_AREAS, DEFAULT_AUDIT_OUTPUT_FORMATS, DEFAULT_AUDIT_TYPES, PLANNED_AUDIT_TYPES, } from "./auditTypes.js";
2
+ import { AUDIT_FAIL_ON_THRESHOLDS, AUDIT_INCLUDE_AREAS, AUDIT_OUTPUT_FORMATS, AUDIT_TYPES, DEFAULT_AUDIT_FAIL_ON_THRESHOLD, DEFAULT_AUDIT_INCLUDE_AREAS, DEFAULT_AUDIT_OUTPUT_FORMATS, DEFAULT_AUDIT_TYPES, IMPLEMENTED_AUDIT_TYPES, PLANNED_AUDIT_TYPES, } from "./auditTypes.js";
3
3
  const FLAGS_WITH_VALUE = {
4
4
  "--target": "target",
5
5
  "--types": "types",
@@ -67,7 +67,7 @@ function parseTypesOption(raw) {
67
67
  }
68
68
  const planned = parts.filter((p) => PLANNED_AUDIT_TYPES.includes(p));
69
69
  if (planned.length > 0) {
70
- throw new Error(`--types value(s) planned but not implemented: ${planned.join(", ")}. Only "code-rot" is implemented in this version.`);
70
+ throw new Error(`--types value(s) planned but not implemented: ${planned.join(", ")}. Implemented audit types: ${IMPLEMENTED_AUDIT_TYPES.join(", ")}.`);
71
71
  }
72
72
  const unique = new Set(parts);
73
73
  const ordered = AUDIT_TYPES.filter((id) => unique.has(id));
@@ -3,14 +3,20 @@ import { DEFAULT_AUDIT_REGISTRY, selectDetectors } from "./auditRegistry.js";
3
3
  import { resolveAuditTarget } from "./auditTarget.js";
4
4
  import { scanProjectInventory } from "./projectInventory.js";
5
5
  import { collectSourceOfTruth } from "./sourceOfTruth.js";
6
+ import { collectSourceFacts } from "./collectSourceFacts.js";
7
+ import { collectPythonProjectMetadata } from "./pythonProjectMetadata.js";
8
+ import { runSecurityAuditAdapter } from "../security/securityAuditAdapter.js";
9
+ import { SECURITY_AUDIT_NOT_RUN_SUMMARY } from "../security/securityAuditTypes.js";
6
10
  export async function runAudit(options) {
7
11
  const { config, toolRoot } = options;
8
12
  const registry = options.registry ?? DEFAULT_AUDIT_REGISTRY;
9
13
  const target = options.target ?? resolveAuditTarget(config.targetPathArg, toolRoot);
10
14
  const inventory = scanProjectInventory(target.rootPath);
11
15
  const sourceOfTruth = collectSourceOfTruth(target.rootPath, inventory);
16
+ const sourceFacts = await collectSourceFacts(target.rootPath, inventory);
17
+ const pythonProjectMetadata = collectPythonProjectMetadata(target.rootPath, inventory);
12
18
  const selected = selectDetectors(registry, config.types, config.include);
13
- const ctx = { target, config, inventory, sourceOfTruth };
19
+ const ctx = { target, config, inventory, sourceOfTruth, sourceFacts };
14
20
  const issues = [];
15
21
  const skippedDetectors = [];
16
22
  const detectorErrors = [];
@@ -35,6 +41,19 @@ export async function runAudit(options) {
35
41
  });
36
42
  }
37
43
  }
44
+ // v0.3.2 Batch 4 -- security audit adapter. Conditional (see the
45
+ // AuditResult.securitySummary field comment above): only runs when --types
46
+ // explicitly includes "security", never as part of a default/code-rot-only
47
+ // run. Appended after the code-rot detector loop above, so combined
48
+ // `--types code-rot,security` runs keep a deterministic issue order:
49
+ // code-rot detector issues (registry order) first, then security findings
50
+ // (security-validation check-execution order).
51
+ let securitySummary = SECURITY_AUDIT_NOT_RUN_SUMMARY;
52
+ if (config.types.includes("security")) {
53
+ const securityAudit = await runSecurityAuditAdapter({ toolRoot, config });
54
+ issues.push(...securityAudit.issues);
55
+ securitySummary = securityAudit.summary;
56
+ }
38
57
  const issueCounts = countIssuesBySeverity(issues);
39
58
  const { exitCode, reason } = calculateAuditExitCode(issues, config.failOn);
40
59
  return {
@@ -59,6 +78,9 @@ export async function runAudit(options) {
59
78
  exitReason: reason,
60
79
  inventory,
61
80
  sourceOfTruth,
81
+ sourceFacts,
82
+ pythonProjectMetadata,
83
+ securitySummary,
62
84
  noDetectorsRegistered: selected.length === 0,
63
85
  };
64
86
  }
@@ -7,14 +7,21 @@
7
7
  // this batch keeps the audit issue schema independent even where a
8
8
  // vocabulary word (e.g. "blocker") happens to be shared.
9
9
  // ---------------------------------------------------------------------------
10
- // Recognized --types values. Only "code-rot" has real detector coverage in
11
- // Batch 1 — the rest are known, named future audit types that must fail
12
- // cleanly with a "planned but not implemented" message rather than being
13
- // silently accepted or treated as an unknown/garbage value.
10
+ // Recognized --types values. "code-rot" (Batch 1) and "security" (v0.3.2
11
+ // Batch 4, via the security-validation audit adapter) have real coverage —
12
+ // the rest are known, named future audit types that must fail cleanly with a
13
+ // "planned but not implemented" message rather than being silently accepted
14
+ // or treated as an unknown/garbage value.
14
15
  export const AUDIT_TYPES = ["code-rot", "quality", "security", "project", "all"];
15
- export const IMPLEMENTED_AUDIT_TYPES = ["code-rot"];
16
- export const PLANNED_AUDIT_TYPES = ["quality", "security", "project", "all"];
17
- export const DEFAULT_AUDIT_TYPES = IMPLEMENTED_AUDIT_TYPES;
16
+ export const IMPLEMENTED_AUDIT_TYPES = ["code-rot", "security"];
17
+ export const PLANNED_AUDIT_TYPES = ["quality", "project", "all"];
18
+ // v0.3.2 Batch 4 — deliberately NOT derived from IMPLEMENTED_AUDIT_TYPES.
19
+ // "security" becomes selectable via --types security (and --types
20
+ // code-rot,security) but must never join the *default* (no --types flag)
21
+ // audit run, since it shells out to npm audit/CodeQL/Semgrep/fuzz and is far
22
+ // more expensive than the always-on code-rot detectors — a default,
23
+ // unqualified `npm run audit` must keep its existing code-rot-only behavior.
24
+ export const DEFAULT_AUDIT_TYPES = ["code-rot"];
18
25
  export const AUDIT_INCLUDE_AREAS = ["docs", "tests", "package", "architecture", "cli"];
19
26
  export const DEFAULT_AUDIT_INCLUDE_AREAS = AUDIT_INCLUDE_AREAS;
20
27
  export const AUDIT_OUTPUT_FORMATS = ["text", "json"];
@@ -0,0 +1,142 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { NORMALIZED_LANGUAGES } from "./projectInventory.js";
4
+ import { DEFAULT_LANGUAGE_ANALYZER_REGISTRY, selectLanguageAnalyzer, } from "./languageAnalyzerRegistry.js";
5
+ import { SOURCE_FACT_PARSE_STATUSES, } from "./sourceFacts.js";
6
+ // ---------------------------------------------------------------------------
7
+ // v0.3.1 Batch 2 -- source facts collector.
8
+ //
9
+ // Consumes the Batch 1 ProjectInventorySnapshot (never re-walks the
10
+ // filesystem) and produces a SourceFactsSnapshot: analyzer facts where a
11
+ // registered analyzer exists, deterministic fallback facts otherwise. Same
12
+ // spirit as sourceOfTruth.ts -- pure data collection, never an AuditIssue,
13
+ // never a write to a target file.
14
+ //
15
+ // Eligibility / omission policy (documented here since it's the single
16
+ // source of truth for the decision):
17
+ // - Only inventory entries with role "source" or "test" are considered.
18
+ // docs/config/package/generated/vendor/build-output/report-output/
19
+ // unknown-role files are omitted entirely from `files` -- source facts
20
+ // are meaningless for them, and omitting (rather than emitting a
21
+ // "skipped" placeholder for every doc/config file in the repo) keeps
22
+ // the snapshot proportional to actual candidate source files.
23
+ // - For an eligible file in a *known* language (typescript, javascript,
24
+ // python, java, kotlin) with no registered analyzer: parseStatus
25
+ // "file-level-only" -- we know the language and already have
26
+ // inventory-level evidence (lineCount), just no analyzer parsed it yet.
27
+ // - For an eligible file in an *unknown* language: parseStatus
28
+ // "unsupported" -- no file-level evidence attempted either.
29
+ // - File content is only read when a registered analyzer actually claims
30
+ // the file -- with an empty default registry (Batch 2), no file is ever
31
+ // read by this collector.
32
+ // ---------------------------------------------------------------------------
33
+ const ANALYZABLE_ROLES = new Set(["source", "test"]);
34
+ const KNOWN_LANGUAGES = new Set(["typescript", "javascript", "python", "java", "kotlin"]);
35
+ export async function collectSourceFacts(targetRoot, inventory, registry = DEFAULT_LANGUAGE_ANALYZER_REGISTRY) {
36
+ const resolvedRoot = path.resolve(targetRoot);
37
+ const files = [];
38
+ const analyzerDiagnostics = [];
39
+ const warnings = [];
40
+ // inventory.files is already sorted by relativePath (Batch 1 guarantee) --
41
+ // filtering preserves that order, so no re-sort is needed here.
42
+ const eligible = inventory.files.filter((entry) => ANALYZABLE_ROLES.has(entry.role));
43
+ for (const entry of eligible) {
44
+ const analyzer = selectLanguageAnalyzer(registry, entry.language, entry.extension);
45
+ if (!analyzer) {
46
+ files.push(buildFallbackFacts(entry));
47
+ continue;
48
+ }
49
+ const absolutePath = path.join(resolvedRoot, entry.relativePath);
50
+ try {
51
+ const content = fs.readFileSync(absolutePath, "utf8");
52
+ const facts = await analyzer.analyzeFile({
53
+ targetRoot: resolvedRoot,
54
+ relativePath: entry.relativePath,
55
+ absolutePath,
56
+ content,
57
+ inventoryEntry: entry,
58
+ language: entry.language,
59
+ role: entry.role,
60
+ });
61
+ files.push(facts);
62
+ }
63
+ catch (err) {
64
+ const message = err instanceof Error ? err.message : String(err);
65
+ const diagnostic = {
66
+ severity: "error",
67
+ code: "analyzer-error",
68
+ message: `Analyzer "${analyzer.id}" failed on "${entry.relativePath}": ${message}`,
69
+ path: entry.relativePath,
70
+ analyzerId: analyzer.id,
71
+ };
72
+ analyzerDiagnostics.push(diagnostic);
73
+ files.push({
74
+ relativePath: entry.relativePath,
75
+ language: entry.language,
76
+ role: entry.role,
77
+ parseStatus: "parse-error",
78
+ analyzerId: analyzer.id,
79
+ lineCount: entry.lineCount,
80
+ imports: [],
81
+ exports: [],
82
+ declarations: [],
83
+ references: [],
84
+ diagnostics: [diagnostic],
85
+ });
86
+ }
87
+ }
88
+ return {
89
+ targetRoot: resolvedRoot,
90
+ files,
91
+ filesByLanguage: countByLanguage(files),
92
+ filesByParseStatus: countByParseStatus(files),
93
+ analyzerDiagnostics,
94
+ warnings,
95
+ };
96
+ }
97
+ function buildFallbackFacts(entry) {
98
+ const known = KNOWN_LANGUAGES.has(entry.language);
99
+ const parseStatus = known ? "file-level-only" : "unsupported";
100
+ const diagnostic = known
101
+ ? {
102
+ severity: "info",
103
+ code: "no-analyzer-registered",
104
+ message: `No language analyzer is registered for "${entry.language}" yet; only inventory-level facts are available.`,
105
+ path: entry.relativePath,
106
+ analyzerId: null,
107
+ }
108
+ : {
109
+ severity: "info",
110
+ code: "unsupported-language",
111
+ message: `Language "${entry.language}" is not supported for source-fact extraction.`,
112
+ path: entry.relativePath,
113
+ analyzerId: null,
114
+ };
115
+ return {
116
+ relativePath: entry.relativePath,
117
+ language: entry.language,
118
+ role: entry.role,
119
+ parseStatus,
120
+ analyzerId: null,
121
+ lineCount: entry.lineCount,
122
+ imports: [],
123
+ exports: [],
124
+ declarations: [],
125
+ references: [],
126
+ diagnostics: [diagnostic],
127
+ };
128
+ }
129
+ function countByLanguage(files) {
130
+ const counts = Object.fromEntries(NORMALIZED_LANGUAGES.map((l) => [l, 0]));
131
+ for (const file of files) {
132
+ counts[file.language] += 1;
133
+ }
134
+ return counts;
135
+ }
136
+ function countByParseStatus(files) {
137
+ const counts = Object.fromEntries(SOURCE_FACT_PARSE_STATUSES.map((s) => [s, 0]));
138
+ for (const file of files) {
139
+ counts[file.parseStatus] += 1;
140
+ }
141
+ return counts;
142
+ }
@@ -0,0 +1,36 @@
1
+ import { TYPESCRIPT_JAVASCRIPT_ANALYZER } from "./typescriptJavaScriptAnalyzer.js";
2
+ import { PYTHON_ANALYZER } from "./pythonAnalyzer.js";
3
+ // Validates a list has no duplicate analyzer ids. Later batches call this
4
+ // when assembling their own registries; Batch 2's empty default trivially
5
+ // passes.
6
+ export function createLanguageAnalyzerRegistry(analyzers) {
7
+ const seen = new Set();
8
+ for (const analyzer of analyzers) {
9
+ if (seen.has(analyzer.id)) {
10
+ throw new Error(`Duplicate language analyzer id: "${analyzer.id}".`);
11
+ }
12
+ seen.add(analyzer.id);
13
+ }
14
+ return analyzers;
15
+ }
16
+ // First matching analyzer in registry order (deterministic), or null when
17
+ // none supports the given language/extension. Order is preserved from the
18
+ // input registry -- not re-sorted.
19
+ export function selectLanguageAnalyzer(registry, language, extension) {
20
+ for (const analyzer of registry) {
21
+ if (!analyzer.supportedLanguages.includes(language))
22
+ continue;
23
+ if (analyzer.supportedExtensions && !analyzer.supportedExtensions.includes(extension))
24
+ continue;
25
+ return analyzer;
26
+ }
27
+ return null;
28
+ }
29
+ // v0.3.1 Batch 3 -- registers the TypeScript/JavaScript analyzer. v0.3.2
30
+ // Batch 1 adds the Python analyzer alongside it. Java and Kotlin remain
31
+ // fallback-only (no analyzer registered for them) -- see
32
+ // collectSourceFacts.ts's fallback policy.
33
+ export const DEFAULT_LANGUAGE_ANALYZER_REGISTRY = createLanguageAnalyzerRegistry([
34
+ TYPESCRIPT_JAVASCRIPT_ANALYZER,
35
+ PYTHON_ANALYZER,
36
+ ]);