@dailephd/my-dev-kit-lab 0.2.2 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -7
- package/dist/scripts/audits/runAudit.js +102 -0
- package/dist/src/audits/codeRot/codeRotDetectors.js +17 -0
- package/dist/src/audits/codeRot/detectors/architectureDriftDetector.js +271 -0
- package/dist/src/audits/codeRot/detectors/crossPlatformRotDetector.js +214 -0
- package/dist/src/audits/codeRot/detectors/deadCodeCandidateDetector.js +377 -0
- package/dist/src/audits/codeRot/detectors/dependencyEnvironmentRotDetector.js +244 -0
- package/dist/src/audits/codeRot/detectors/docsCodeMismatchDetector.js +219 -0
- package/dist/src/audits/codeRot/detectors/duplicateImplementationDetector.js +350 -0
- package/dist/src/audits/codeRot/detectors/packageReleaseRotDetector.js +325 -0
- package/dist/src/audits/codeRot/detectors/securityValidationAssumptionRotDetector.js +385 -0
- package/dist/src/audits/codeRot/detectors/staleCommandReferenceDetector.js +135 -0
- package/dist/src/audits/codeRot/detectors/testRotDetector.js +410 -0
- package/dist/src/audits/codeRot/utils/boundedRead.js +23 -0
- package/dist/src/audits/codeRot/utils/docClaimPatterns.js +62 -0
- package/dist/src/audits/codeRot/utils/docCommandReferences.js +64 -0
- package/dist/src/audits/codeRot/utils/filePatternUtils.js +79 -0
- package/dist/src/audits/codeRot/utils/issueFactories.js +38 -0
- package/dist/src/audits/codeRot/utils/sourceFactsLookup.js +53 -0
- package/dist/src/audits/codeRot/utils/textLines.js +12 -0
- package/dist/src/audits/core/auditConfig.js +125 -0
- package/dist/src/audits/core/auditExitCode.js +75 -0
- package/dist/src/audits/core/auditIssue.js +20 -0
- package/dist/src/audits/core/auditRegistry.js +40 -0
- package/dist/src/audits/core/auditRunner.js +74 -0
- package/dist/src/audits/core/auditTarget.js +19 -0
- package/dist/src/audits/core/auditTypes.js +30 -0
- package/dist/src/audits/core/collectSourceFacts.js +142 -0
- package/dist/src/audits/core/languageAnalyzerRegistry.js +33 -0
- package/dist/src/audits/core/projectInventory.js +458 -0
- package/dist/src/audits/core/sourceFacts.js +17 -0
- package/dist/src/audits/core/sourceOfTruth.js +339 -0
- package/dist/src/audits/core/textLines.js +24 -0
- package/dist/src/audits/core/typescriptJavaScriptAnalyzer.js +299 -0
- package/dist/src/audits/report/auditReportModel.js +253 -0
- package/dist/src/audits/report/auditReportPaths.js +13 -0
- package/dist/src/audits/report/renderAuditJsonReport.js +30 -0
- package/dist/src/audits/report/renderAuditTextReport.js +181 -0
- package/dist/src/audits/report/sanitizeAuditText.js +39 -0
- package/dist/src/audits/report/writeAuditReports.js +27 -0
- package/dist/src/core/resolveCommand.js +53 -2
- package/package.json +10 -6
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// v0.3.0 Batch 3 — shared code-rot issue construction helpers.
|
|
3
|
+
//
|
|
4
|
+
// Issue ids are content-addressed (detectorId + a stable, relative-path-only
|
|
5
|
+
// cue) rather than counter-based, so the same underlying problem always
|
|
6
|
+
// produces the same id across runs -- this is what makes de-duplication and
|
|
7
|
+
// "stable issue ids" (Batch 3 spec 3.8) trivial: two candidate issues with
|
|
8
|
+
// the same id are the same issue.
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Builds a deterministic id from a detector id and one or more relative-path
|
|
11
|
+
// or short-string cues. Never embeds an absolute path -- callers must pass
|
|
12
|
+
// already-relative paths (e.g. from InventoryFileEntry.relativePath).
|
|
13
|
+
export function makeIssueId(detectorId, ...cues) {
|
|
14
|
+
const sanitizedCues = cues.map((cue) => cue.replace(/\\/g, "/"));
|
|
15
|
+
return [detectorId, ...sanitizedCues].join(":");
|
|
16
|
+
}
|
|
17
|
+
export function makeCodeRotIssue(input) {
|
|
18
|
+
const { idCues, evidence, affectedFiles, ...rest } = input;
|
|
19
|
+
return {
|
|
20
|
+
...rest,
|
|
21
|
+
id: makeIssueId(rest.detectorId, ...idCues),
|
|
22
|
+
evidence: evidence ?? [],
|
|
23
|
+
affectedFiles: affectedFiles ?? [],
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
// Deduplicates a list of already-built issues by id, keeping the first
|
|
27
|
+
// occurrence and preserving overall array order (stable de-duplication).
|
|
28
|
+
export function deduplicateIssuesById(issues) {
|
|
29
|
+
const seen = new Set();
|
|
30
|
+
const result = [];
|
|
31
|
+
for (const issue of issues) {
|
|
32
|
+
if (seen.has(issue.id))
|
|
33
|
+
continue;
|
|
34
|
+
seen.add(issue.id);
|
|
35
|
+
result.push(issue);
|
|
36
|
+
}
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// v0.3.0 Batch 3 (moved in Batch 6) — shared line-splitting helper.
|
|
3
|
+
//
|
|
4
|
+
// The canonical implementation now lives in src/audits/core/textLines.ts
|
|
5
|
+
// (core is lower-level than codeRot, and src/audits/core/sourceOfTruth.ts
|
|
6
|
+
// needed the same helper -- see that file's header comment). This module is
|
|
7
|
+
// kept as a transparent re-export so every existing codeRot detector/util
|
|
8
|
+
// that imports `splitLines` from "../utils/textLines.js" (or
|
|
9
|
+
// "./textLines.js") keeps working unchanged -- named-export re-exports are
|
|
10
|
+
// transparent to importers, there is no behavior difference.
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
export { splitLines } from "../../core/textLines.js";
|
|
@@ -0,0 +1,125 @@
|
|
|
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";
|
|
3
|
+
const FLAGS_WITH_VALUE = {
|
|
4
|
+
"--target": "target",
|
|
5
|
+
"--types": "types",
|
|
6
|
+
"--include": "include",
|
|
7
|
+
"--format": "format",
|
|
8
|
+
"--fail-on": "failOn",
|
|
9
|
+
"--out": "out",
|
|
10
|
+
};
|
|
11
|
+
export function parseAuditArgs(argv) {
|
|
12
|
+
const result = {};
|
|
13
|
+
for (let i = 0; i < argv.length; i++) {
|
|
14
|
+
const arg = argv[i];
|
|
15
|
+
const key = FLAGS_WITH_VALUE[arg];
|
|
16
|
+
if (!key)
|
|
17
|
+
continue;
|
|
18
|
+
if (i + 1 >= argv.length) {
|
|
19
|
+
throw new Error(`Missing value for ${arg}. Expected a value after ${arg}.`);
|
|
20
|
+
}
|
|
21
|
+
result[key] = argv[++i];
|
|
22
|
+
}
|
|
23
|
+
return result;
|
|
24
|
+
}
|
|
25
|
+
export function normalizeAuditConfig(raw, toolRoot) {
|
|
26
|
+
const types = parseTypesOption(raw.types);
|
|
27
|
+
const include = parseIncludeOption(raw.include);
|
|
28
|
+
const formats = parseFormatOption(raw.format);
|
|
29
|
+
const failOn = parseFailOnOption(raw.failOn);
|
|
30
|
+
const out = raw.out !== undefined ? resolveOutDir(raw.out) : defaultOutDir(toolRoot, types.value);
|
|
31
|
+
return {
|
|
32
|
+
targetPathArg: raw.target,
|
|
33
|
+
targetMode: raw.target === undefined ? "self" : "external",
|
|
34
|
+
types: types.value,
|
|
35
|
+
typesWereDefault: types.wasDefault,
|
|
36
|
+
include: include.value,
|
|
37
|
+
includeWereDefault: include.wasDefault,
|
|
38
|
+
formats: formats.value,
|
|
39
|
+
formatsWereDefault: formats.wasDefault,
|
|
40
|
+
failOn: failOn.value,
|
|
41
|
+
failOnWasDefault: failOn.wasDefault,
|
|
42
|
+
out,
|
|
43
|
+
outWasDefault: raw.out === undefined,
|
|
44
|
+
isDefaultRun: raw.target === undefined &&
|
|
45
|
+
raw.types === undefined &&
|
|
46
|
+
raw.include === undefined &&
|
|
47
|
+
raw.format === undefined &&
|
|
48
|
+
raw.failOn === undefined &&
|
|
49
|
+
raw.out === undefined,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function splitAndNormalizeList(raw, flagName) {
|
|
53
|
+
const parts = raw.split(",").map((p) => p.trim());
|
|
54
|
+
if (parts.some((p) => p.length === 0)) {
|
|
55
|
+
throw new Error(`Invalid ${flagName} value: empty entries are not allowed. Got "${raw}".`);
|
|
56
|
+
}
|
|
57
|
+
return parts;
|
|
58
|
+
}
|
|
59
|
+
function parseTypesOption(raw) {
|
|
60
|
+
if (raw === undefined) {
|
|
61
|
+
return { value: [...DEFAULT_AUDIT_TYPES], wasDefault: true };
|
|
62
|
+
}
|
|
63
|
+
const parts = splitAndNormalizeList(raw, "--types");
|
|
64
|
+
const unknown = parts.filter((p) => !AUDIT_TYPES.includes(p));
|
|
65
|
+
if (unknown.length > 0) {
|
|
66
|
+
throw new Error(`Invalid --types value(s): ${unknown.join(", ")}. Valid values are: ${AUDIT_TYPES.join(", ")}.`);
|
|
67
|
+
}
|
|
68
|
+
const planned = parts.filter((p) => PLANNED_AUDIT_TYPES.includes(p));
|
|
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.`);
|
|
71
|
+
}
|
|
72
|
+
const unique = new Set(parts);
|
|
73
|
+
const ordered = AUDIT_TYPES.filter((id) => unique.has(id));
|
|
74
|
+
return { value: ordered, wasDefault: false };
|
|
75
|
+
}
|
|
76
|
+
function parseIncludeOption(raw) {
|
|
77
|
+
if (raw === undefined) {
|
|
78
|
+
return { value: [...DEFAULT_AUDIT_INCLUDE_AREAS], wasDefault: true };
|
|
79
|
+
}
|
|
80
|
+
const parts = splitAndNormalizeList(raw, "--include");
|
|
81
|
+
const invalid = parts.filter((p) => !AUDIT_INCLUDE_AREAS.includes(p));
|
|
82
|
+
if (invalid.length > 0) {
|
|
83
|
+
throw new Error(`Invalid --include value(s): ${invalid.join(", ")}. Valid values are: ${AUDIT_INCLUDE_AREAS.join(", ")}.`);
|
|
84
|
+
}
|
|
85
|
+
const unique = new Set(parts);
|
|
86
|
+
const ordered = AUDIT_INCLUDE_AREAS.filter((id) => unique.has(id));
|
|
87
|
+
return { value: ordered, wasDefault: false };
|
|
88
|
+
}
|
|
89
|
+
function parseFormatOption(raw) {
|
|
90
|
+
if (raw === undefined) {
|
|
91
|
+
return { value: [...DEFAULT_AUDIT_OUTPUT_FORMATS], wasDefault: true };
|
|
92
|
+
}
|
|
93
|
+
const parts = splitAndNormalizeList(raw, "--format");
|
|
94
|
+
const invalid = parts.filter((p) => !AUDIT_OUTPUT_FORMATS.includes(p));
|
|
95
|
+
if (invalid.length > 0) {
|
|
96
|
+
throw new Error(`Invalid --format value(s): ${invalid.join(", ")}. Valid values are: ${AUDIT_OUTPUT_FORMATS.join(", ")}.`);
|
|
97
|
+
}
|
|
98
|
+
const unique = new Set(parts);
|
|
99
|
+
const ordered = AUDIT_OUTPUT_FORMATS.filter((id) => unique.has(id));
|
|
100
|
+
return { value: ordered, wasDefault: false };
|
|
101
|
+
}
|
|
102
|
+
function parseFailOnOption(raw) {
|
|
103
|
+
if (raw === undefined) {
|
|
104
|
+
return { value: DEFAULT_AUDIT_FAIL_ON_THRESHOLD, wasDefault: true };
|
|
105
|
+
}
|
|
106
|
+
const value = raw.trim();
|
|
107
|
+
if (!AUDIT_FAIL_ON_THRESHOLDS.includes(value)) {
|
|
108
|
+
throw new Error(`Invalid --fail-on value: "${raw}". Valid values are: ${AUDIT_FAIL_ON_THRESHOLDS.join(", ")}.`);
|
|
109
|
+
}
|
|
110
|
+
return { value: value, wasDefault: false };
|
|
111
|
+
}
|
|
112
|
+
function resolveOutDir(raw) {
|
|
113
|
+
const trimmed = raw.trim();
|
|
114
|
+
if (trimmed.length === 0) {
|
|
115
|
+
throw new Error("Invalid --out value: path must not be empty.");
|
|
116
|
+
}
|
|
117
|
+
return path.resolve(trimmed);
|
|
118
|
+
}
|
|
119
|
+
function defaultOutDir(toolRoot, types) {
|
|
120
|
+
// Batch 1 only ever has "code-rot" as a valid resolved type, but this stays
|
|
121
|
+
// written generically so later batches (quality/security/project) don't
|
|
122
|
+
// need to touch this function, only extend the audit-type vocabulary.
|
|
123
|
+
const primaryType = types[0] ?? "code-rot";
|
|
124
|
+
return path.join(path.resolve(toolRoot), "reports", "audits", primaryType);
|
|
125
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// v0.3.0 Batch 1 — audit exit code policy.
|
|
3
|
+
//
|
|
4
|
+
// 0 = no issue met the --fail-on threshold (and no config/target/runtime
|
|
5
|
+
// error occurred).
|
|
6
|
+
// 1 = at least one issue met or exceeded the --fail-on threshold.
|
|
7
|
+
// 2 = invalid config, invalid target, or an audit runtime failure. This
|
|
8
|
+
// code is never returned by calculateAuditExitCode() below (a pure,
|
|
9
|
+
// issue-driven function) -- it is the caller's (scripts/audits/
|
|
10
|
+
// runAudit.ts) responsibility to catch parse/config/target errors and
|
|
11
|
+
// exit with AUDIT_EXIT_CODES.FATAL_ERROR directly, mirroring how
|
|
12
|
+
// scripts/security/validate.ts handles its own parse/target try/catch.
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
export const AUDIT_EXIT_CODES = {
|
|
15
|
+
SUCCESS: 0,
|
|
16
|
+
THRESHOLD_BREACHED: 1,
|
|
17
|
+
FATAL_ERROR: 2,
|
|
18
|
+
};
|
|
19
|
+
// "info" is a valid issue severity but is intentionally below every
|
|
20
|
+
// selectable --fail-on threshold (including "low") -- it can never breach.
|
|
21
|
+
const SEVERITY_RANK = {
|
|
22
|
+
info: 0,
|
|
23
|
+
low: 1,
|
|
24
|
+
medium: 2,
|
|
25
|
+
high: 3,
|
|
26
|
+
blocker: 4,
|
|
27
|
+
};
|
|
28
|
+
// "none" ranks above every severity so it can never be breached.
|
|
29
|
+
const THRESHOLD_RANK = {
|
|
30
|
+
low: 1,
|
|
31
|
+
medium: 2,
|
|
32
|
+
high: 3,
|
|
33
|
+
blocker: 4,
|
|
34
|
+
none: Number.POSITIVE_INFINITY,
|
|
35
|
+
};
|
|
36
|
+
export function issueBreachesFailOnThreshold(severity, failOn) {
|
|
37
|
+
if (failOn === "none")
|
|
38
|
+
return false;
|
|
39
|
+
return SEVERITY_RANK[severity] >= THRESHOLD_RANK[failOn];
|
|
40
|
+
}
|
|
41
|
+
// v0.3.0 Batch 5 — additive export. Report-building code (src/audits/report/
|
|
42
|
+
// auditReportModel.ts) needs the same severity ranking to compute a
|
|
43
|
+
// "highest severity present" summary field, and must not hardcode a second
|
|
44
|
+
// copy of this table (see Batch 5 spec 3.2). Signature/behavior of the two
|
|
45
|
+
// functions above is unchanged.
|
|
46
|
+
export function getHighestSeverity(issues) {
|
|
47
|
+
if (issues.length === 0)
|
|
48
|
+
return null;
|
|
49
|
+
let highest = issues[0].severity;
|
|
50
|
+
for (const issue of issues) {
|
|
51
|
+
if (SEVERITY_RANK[issue.severity] > SEVERITY_RANK[highest]) {
|
|
52
|
+
highest = issue.severity;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return highest;
|
|
56
|
+
}
|
|
57
|
+
// Pure function: given a final issue list and the configured --fail-on
|
|
58
|
+
// threshold, decides between SUCCESS and THRESHOLD_BREACHED. Never returns
|
|
59
|
+
// FATAL_ERROR -- see file header.
|
|
60
|
+
export function calculateAuditExitCode(issues, failOn) {
|
|
61
|
+
if (failOn === "none") {
|
|
62
|
+
return { exitCode: AUDIT_EXIT_CODES.SUCCESS, reason: "--fail-on none: findings never block the command." };
|
|
63
|
+
}
|
|
64
|
+
const breaching = issues.filter((issue) => issueBreachesFailOnThreshold(issue.severity, failOn));
|
|
65
|
+
if (breaching.length > 0) {
|
|
66
|
+
return {
|
|
67
|
+
exitCode: AUDIT_EXIT_CODES.THRESHOLD_BREACHED,
|
|
68
|
+
reason: `${breaching.length} issue(s) met or exceeded the --fail-on ${failOn} threshold.`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
exitCode: AUDIT_EXIT_CODES.SUCCESS,
|
|
73
|
+
reason: `No issues met the --fail-on ${failOn} threshold.`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export const AUDIT_EVIDENCE_KINDS = [
|
|
2
|
+
"file",
|
|
3
|
+
"command",
|
|
4
|
+
"diff",
|
|
5
|
+
"reference",
|
|
6
|
+
"observation",
|
|
7
|
+
];
|
|
8
|
+
const MAX_EXCERPT_LENGTH = 400;
|
|
9
|
+
// Bounds an excerpt to a safe preview length so evidence never embeds huge
|
|
10
|
+
// file contents. Does not attempt secret redaction here — detectors that
|
|
11
|
+
// scan for secret-shaped content are responsible for redacting before this
|
|
12
|
+
// point, same division of responsibility documented in exploitEvidence.ts.
|
|
13
|
+
export function boundExcerpt(raw, maxLength = MAX_EXCERPT_LENGTH) {
|
|
14
|
+
if (raw.length <= maxLength)
|
|
15
|
+
return raw;
|
|
16
|
+
return `${raw.slice(0, maxLength)}... [truncated, ${raw.length} chars total]`;
|
|
17
|
+
}
|
|
18
|
+
export function makeAuditEvidence(evidence) {
|
|
19
|
+
return evidence.excerpt !== undefined ? { ...evidence, excerpt: boundExcerpt(evidence.excerpt) } : evidence;
|
|
20
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { STALE_COMMAND_REFERENCE_DETECTOR, DOCS_CODE_MISMATCH_DETECTOR, PACKAGE_RELEASE_ROT_DETECTOR, DUPLICATE_IMPLEMENTATION_DETECTOR, DEAD_CODE_CANDIDATE_DETECTOR, TEST_ROT_DETECTOR, ARCHITECTURE_DRIFT_DETECTOR, DEPENDENCY_ENVIRONMENT_ROT_DETECTOR, CROSS_PLATFORM_ROT_DETECTOR, SECURITY_VALIDATION_ASSUMPTION_ROT_DETECTOR, } from "../codeRot/codeRotDetectors.js";
|
|
2
|
+
// v0.3.0 Batch 3/4 — code-rot detectors. Batch 4 completes the full
|
|
3
|
+
// code-rot detector family planned in docs/ROADMAP.md's v0.3.0 section:
|
|
4
|
+
// stale command/workflow reference, docs/code mismatch, package/release
|
|
5
|
+
// rot (Batch 3), plus duplicate-implementation, dead-code, test-rot,
|
|
6
|
+
// architecture-drift, dependency/environment-rot, cross-platform-rot, and
|
|
7
|
+
// security/validation-assumption-rot (Batch 4).
|
|
8
|
+
export const DEFAULT_AUDIT_REGISTRY = createAuditRegistry([
|
|
9
|
+
STALE_COMMAND_REFERENCE_DETECTOR,
|
|
10
|
+
DOCS_CODE_MISMATCH_DETECTOR,
|
|
11
|
+
PACKAGE_RELEASE_ROT_DETECTOR,
|
|
12
|
+
DUPLICATE_IMPLEMENTATION_DETECTOR,
|
|
13
|
+
DEAD_CODE_CANDIDATE_DETECTOR,
|
|
14
|
+
TEST_ROT_DETECTOR,
|
|
15
|
+
ARCHITECTURE_DRIFT_DETECTOR,
|
|
16
|
+
DEPENDENCY_ENVIRONMENT_ROT_DETECTOR,
|
|
17
|
+
CROSS_PLATFORM_ROT_DETECTOR,
|
|
18
|
+
SECURITY_VALIDATION_ASSUMPTION_ROT_DETECTOR,
|
|
19
|
+
]);
|
|
20
|
+
// Validates a detector list has no duplicate ids. Later batches call this
|
|
21
|
+
// when assembling their own registries; Batch 1's empty default trivially
|
|
22
|
+
// passes.
|
|
23
|
+
export function createAuditRegistry(detectors) {
|
|
24
|
+
const seen = new Set();
|
|
25
|
+
for (const detector of detectors) {
|
|
26
|
+
if (seen.has(detector.id)) {
|
|
27
|
+
throw new Error(`Duplicate audit detector id: "${detector.id}".`);
|
|
28
|
+
}
|
|
29
|
+
seen.add(detector.id);
|
|
30
|
+
}
|
|
31
|
+
return detectors;
|
|
32
|
+
}
|
|
33
|
+
// Filters a registry down to detectors matching the selected audit types and
|
|
34
|
+
// at least one selected include area. Order is preserved from the input
|
|
35
|
+
// registry (deterministic, not re-sorted).
|
|
36
|
+
export function selectDetectors(registry, types, include) {
|
|
37
|
+
const typeSet = new Set(types);
|
|
38
|
+
const includeSet = new Set(include);
|
|
39
|
+
return registry.filter((detector) => typeSet.has(detector.auditType) && detector.supportedIncludeAreas.some((area) => includeSet.has(area)));
|
|
40
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { calculateAuditExitCode } from "./auditExitCode.js";
|
|
2
|
+
import { DEFAULT_AUDIT_REGISTRY, selectDetectors } from "./auditRegistry.js";
|
|
3
|
+
import { resolveAuditTarget } from "./auditTarget.js";
|
|
4
|
+
import { scanProjectInventory } from "./projectInventory.js";
|
|
5
|
+
import { collectSourceOfTruth } from "./sourceOfTruth.js";
|
|
6
|
+
import { collectSourceFacts } from "./collectSourceFacts.js";
|
|
7
|
+
export async function runAudit(options) {
|
|
8
|
+
const { config, toolRoot } = options;
|
|
9
|
+
const registry = options.registry ?? DEFAULT_AUDIT_REGISTRY;
|
|
10
|
+
const target = options.target ?? resolveAuditTarget(config.targetPathArg, toolRoot);
|
|
11
|
+
const inventory = scanProjectInventory(target.rootPath);
|
|
12
|
+
const sourceOfTruth = collectSourceOfTruth(target.rootPath, inventory);
|
|
13
|
+
const sourceFacts = await collectSourceFacts(target.rootPath, inventory);
|
|
14
|
+
const selected = selectDetectors(registry, config.types, config.include);
|
|
15
|
+
const ctx = { target, config, inventory, sourceOfTruth, sourceFacts };
|
|
16
|
+
const issues = [];
|
|
17
|
+
const skippedDetectors = [];
|
|
18
|
+
const detectorErrors = [];
|
|
19
|
+
// Sequential, in registry order -- keeps issue ordering deterministic
|
|
20
|
+
// without needing a secondary sort step.
|
|
21
|
+
for (const detector of selected) {
|
|
22
|
+
try {
|
|
23
|
+
if (detector.shouldSkip) {
|
|
24
|
+
const decision = detector.shouldSkip(ctx);
|
|
25
|
+
if (decision.skip) {
|
|
26
|
+
skippedDetectors.push({ id: detector.id, reason: decision.reason });
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const detectorIssues = await detector.run(ctx);
|
|
31
|
+
issues.push(...detectorIssues);
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
detectorErrors.push({
|
|
35
|
+
id: detector.id,
|
|
36
|
+
message: err instanceof Error ? err.message : String(err),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const issueCounts = countIssuesBySeverity(issues);
|
|
41
|
+
const { exitCode, reason } = calculateAuditExitCode(issues, config.failOn);
|
|
42
|
+
return {
|
|
43
|
+
configSummary: {
|
|
44
|
+
types: config.types,
|
|
45
|
+
include: config.include,
|
|
46
|
+
formats: config.formats,
|
|
47
|
+
failOn: config.failOn,
|
|
48
|
+
out: config.out,
|
|
49
|
+
isDefaultRun: config.isDefaultRun,
|
|
50
|
+
},
|
|
51
|
+
targetSummary: {
|
|
52
|
+
rootPath: target.rootPath,
|
|
53
|
+
displayName: target.displayName,
|
|
54
|
+
isSelf: target.isSelf,
|
|
55
|
+
},
|
|
56
|
+
issues,
|
|
57
|
+
skippedDetectors,
|
|
58
|
+
detectorErrors,
|
|
59
|
+
issueCounts,
|
|
60
|
+
exitCode,
|
|
61
|
+
exitReason: reason,
|
|
62
|
+
inventory,
|
|
63
|
+
sourceOfTruth,
|
|
64
|
+
sourceFacts,
|
|
65
|
+
noDetectorsRegistered: selected.length === 0,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
function countIssuesBySeverity(issues) {
|
|
69
|
+
const counts = { blocker: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
70
|
+
for (const issue of issues) {
|
|
71
|
+
counts[issue.severity] += 1;
|
|
72
|
+
}
|
|
73
|
+
return counts;
|
|
74
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { resolveLocalProjectTarget } from "../../core/localProjectTarget.js";
|
|
3
|
+
// Resolves and validates an audit target. Throws a descriptive Error (no
|
|
4
|
+
// stack trace wrapper) for a missing path or a non-directory path. Does not
|
|
5
|
+
// modify the target in any way.
|
|
6
|
+
export function resolveAuditTarget(targetPathArg, toolRoot) {
|
|
7
|
+
const metadata = resolveLocalProjectTarget(targetPathArg, toolRoot);
|
|
8
|
+
const displayName = metadata.packageName ?? path.basename(metadata.targetRoot);
|
|
9
|
+
return {
|
|
10
|
+
rootPath: metadata.targetRoot,
|
|
11
|
+
displayName,
|
|
12
|
+
exists: true,
|
|
13
|
+
isDirectory: true,
|
|
14
|
+
packageJsonPath: metadata.hasPackageJson ? path.join(metadata.targetRoot, "package.json") : null,
|
|
15
|
+
gitRoot: metadata.hasGit ? metadata.targetRoot : null,
|
|
16
|
+
isSelf: metadata.isSelf,
|
|
17
|
+
safeReportOutputRoot: path.join(path.resolve(toolRoot), "reports", "audits"),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// v0.3.0 Batch 1 — generic audit framework core vocabulary.
|
|
3
|
+
//
|
|
4
|
+
// Deliberately kept separate from src/securityValidation/types.ts. Audit and
|
|
5
|
+
// security-validation are two distinct frameworks (see docs/ROADMAP.md); a
|
|
6
|
+
// later v0.3.2 batch may integrate their results into a unified report, but
|
|
7
|
+
// this batch keeps the audit issue schema independent even where a
|
|
8
|
+
// vocabulary word (e.g. "blocker") happens to be shared.
|
|
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.
|
|
14
|
+
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;
|
|
18
|
+
export const AUDIT_INCLUDE_AREAS = ["docs", "tests", "package", "architecture", "cli"];
|
|
19
|
+
export const DEFAULT_AUDIT_INCLUDE_AREAS = AUDIT_INCLUDE_AREAS;
|
|
20
|
+
export const AUDIT_OUTPUT_FORMATS = ["text", "json"];
|
|
21
|
+
export const DEFAULT_AUDIT_OUTPUT_FORMATS = ["text", "json"];
|
|
22
|
+
// "none" is a valid, explicit opt-out — findings never block the command.
|
|
23
|
+
export const AUDIT_FAIL_ON_THRESHOLDS = ["blocker", "high", "medium", "low", "none"];
|
|
24
|
+
export const DEFAULT_AUDIT_FAIL_ON_THRESHOLD = "blocker";
|
|
25
|
+
// Issue severity. Distinct from AuditFailOnThreshold: "info" is a valid
|
|
26
|
+
// issue severity but not a selectable --fail-on threshold (an info-severity
|
|
27
|
+
// issue can never breach any threshold, including "low").
|
|
28
|
+
export const AUDIT_SEVERITIES = ["blocker", "high", "medium", "low", "info"];
|
|
29
|
+
export const AUDIT_CONFIDENCE_LEVELS = ["high", "medium", "low"];
|
|
30
|
+
export const AUDIT_FALSE_POSITIVE_RISK_LEVELS = ["high", "medium", "low"];
|
|
@@ -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
|
+
}
|