@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.
- package/README.md +19 -6
- package/dist/scripts/audits/runAudit.js +9 -2
- package/dist/scripts/security/validate.js +8 -29
- package/dist/src/audits/codeRot/detectors/deadCodeCandidateDetector.js +138 -11
- package/dist/src/audits/codeRot/detectors/duplicateImplementationDetector.js +93 -1
- package/dist/src/audits/codeRot/detectors/testRotDetector.js +176 -0
- package/dist/src/audits/codeRot/utils/filePatternUtils.js +50 -0
- package/dist/src/audits/codeRot/utils/sourceFactsLookup.js +53 -0
- package/dist/src/audits/core/auditConfig.js +2 -2
- package/dist/src/audits/core/auditRunner.js +23 -1
- package/dist/src/audits/core/auditTypes.js +14 -7
- package/dist/src/audits/core/collectSourceFacts.js +142 -0
- package/dist/src/audits/core/languageAnalyzerRegistry.js +36 -0
- package/dist/src/audits/core/projectInventory.js +185 -3
- package/dist/src/audits/core/pythonAnalyzer.js +328 -0
- package/dist/src/audits/core/pythonProjectMetadata.js +80 -0
- package/dist/src/audits/core/sourceFacts.js +17 -0
- package/dist/src/audits/core/typescriptJavaScriptAnalyzer.js +299 -0
- package/dist/src/audits/report/auditReportModel.js +15 -0
- package/dist/src/audits/report/renderAuditJsonReport.js +3 -0
- package/dist/src/audits/report/renderAuditTextReport.js +59 -3
- package/dist/src/audits/security/mapSecurityFindingToAuditIssue.js +70 -0
- package/dist/src/audits/security/securityAuditAdapter.js +67 -0
- package/dist/src/audits/security/securityAuditTypes.js +17 -0
- package/dist/src/securityValidation/report/buildSecurityReport.js +31 -0
- package/dist/src/securityValidation/validate/resolveTarget.js +9 -0
- 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(", ")}.
|
|
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.
|
|
11
|
-
// Batch
|
|
12
|
-
//
|
|
13
|
-
//
|
|
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", "
|
|
17
|
-
|
|
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
|
+
]);
|