@dailephd/my-dev-kit-lab 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +17 -7
  2. package/dist/scripts/audits/runAudit.js +102 -0
  3. package/dist/src/audits/codeRot/codeRotDetectors.js +17 -0
  4. package/dist/src/audits/codeRot/detectors/architectureDriftDetector.js +271 -0
  5. package/dist/src/audits/codeRot/detectors/crossPlatformRotDetector.js +214 -0
  6. package/dist/src/audits/codeRot/detectors/deadCodeCandidateDetector.js +377 -0
  7. package/dist/src/audits/codeRot/detectors/dependencyEnvironmentRotDetector.js +244 -0
  8. package/dist/src/audits/codeRot/detectors/docsCodeMismatchDetector.js +219 -0
  9. package/dist/src/audits/codeRot/detectors/duplicateImplementationDetector.js +350 -0
  10. package/dist/src/audits/codeRot/detectors/packageReleaseRotDetector.js +325 -0
  11. package/dist/src/audits/codeRot/detectors/securityValidationAssumptionRotDetector.js +385 -0
  12. package/dist/src/audits/codeRot/detectors/staleCommandReferenceDetector.js +135 -0
  13. package/dist/src/audits/codeRot/detectors/testRotDetector.js +410 -0
  14. package/dist/src/audits/codeRot/utils/boundedRead.js +23 -0
  15. package/dist/src/audits/codeRot/utils/docClaimPatterns.js +62 -0
  16. package/dist/src/audits/codeRot/utils/docCommandReferences.js +64 -0
  17. package/dist/src/audits/codeRot/utils/filePatternUtils.js +79 -0
  18. package/dist/src/audits/codeRot/utils/issueFactories.js +38 -0
  19. package/dist/src/audits/codeRot/utils/sourceFactsLookup.js +53 -0
  20. package/dist/src/audits/codeRot/utils/textLines.js +12 -0
  21. package/dist/src/audits/core/auditConfig.js +125 -0
  22. package/dist/src/audits/core/auditExitCode.js +75 -0
  23. package/dist/src/audits/core/auditIssue.js +20 -0
  24. package/dist/src/audits/core/auditRegistry.js +40 -0
  25. package/dist/src/audits/core/auditRunner.js +74 -0
  26. package/dist/src/audits/core/auditTarget.js +19 -0
  27. package/dist/src/audits/core/auditTypes.js +30 -0
  28. package/dist/src/audits/core/collectSourceFacts.js +142 -0
  29. package/dist/src/audits/core/languageAnalyzerRegistry.js +33 -0
  30. package/dist/src/audits/core/projectInventory.js +458 -0
  31. package/dist/src/audits/core/sourceFacts.js +17 -0
  32. package/dist/src/audits/core/sourceOfTruth.js +339 -0
  33. package/dist/src/audits/core/textLines.js +24 -0
  34. package/dist/src/audits/core/typescriptJavaScriptAnalyzer.js +299 -0
  35. package/dist/src/audits/report/auditReportModel.js +253 -0
  36. package/dist/src/audits/report/auditReportPaths.js +13 -0
  37. package/dist/src/audits/report/renderAuditJsonReport.js +30 -0
  38. package/dist/src/audits/report/renderAuditTextReport.js +181 -0
  39. package/dist/src/audits/report/sanitizeAuditText.js +39 -0
  40. package/dist/src/audits/report/writeAuditReports.js +27 -0
  41. package/dist/src/core/resolveCommand.js +53 -2
  42. package/package.json +10 -6
@@ -0,0 +1,253 @@
1
+ import { getHighestSeverity, AUDIT_EXIT_CODES } from "../core/auditExitCode.js";
2
+ import { DEFAULT_AUDIT_REGISTRY, selectDetectors } from "../core/auditRegistry.js";
3
+ // ---------------------------------------------------------------------------
4
+ // v0.3.0 Batch 5 — stable, versioned audit report model.
5
+ //
6
+ // Pure transform: AuditResult (+ the richer AuditTarget the CLI script
7
+ // already has in scope) -> AuditReportModel. No I/O here (that lives in
8
+ // writeAuditReports.ts). This is what replaces the inline
9
+ // buildJsonReport()/renderMinimalTextSummary()/summarizeInventory()/
10
+ // summarizeSourceOfTruth() functions that previously lived directly in
11
+ // scripts/audits/runAudit.ts (Batch 1/2) -- the summarize* condensing logic
12
+ // is preserved verbatim here (inventory/sourceOfTruth shapes are unchanged,
13
+ // see spec 3.2 "inventory"/"sourceOfTruth" sections), just relocated so the
14
+ // CLI script can stay thin.
15
+ // ---------------------------------------------------------------------------
16
+ export const AUDIT_REPORT_SCHEMA_VERSION = "1.0";
17
+ export function summarizeInventory(inventory) {
18
+ return {
19
+ targetRoot: inventory.targetRoot,
20
+ totalFileCount: inventory.totalFileCount,
21
+ totalScannedFileCount: inventory.totalScannedFileCount,
22
+ skippedFileCount: inventory.skippedFileCount,
23
+ filesByCategory: inventory.filesByCategory,
24
+ filesByExtension: inventory.filesByExtension,
25
+ filesByLanguage: inventory.filesByLanguage,
26
+ filesByRole: inventory.filesByRole,
27
+ excludedDirectorySummary: inventory.excludedDirectorySummary,
28
+ warnings: inventory.warnings,
29
+ };
30
+ }
31
+ export function summarizeSourceOfTruth(sourceOfTruth) {
32
+ const scriptCountByGroup = Object.fromEntries(Object.entries(sourceOfTruth.commands.scriptsByGroup).map(([group, names]) => [group, names.length]));
33
+ const nodeVersionsReferenced = [
34
+ ...new Set(sourceOfTruth.ci.workflows.flatMap((w) => w.nodeVersionsReferenced)),
35
+ ].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
36
+ return {
37
+ packageName: sourceOfTruth.package?.name ?? null,
38
+ packageVersion: sourceOfTruth.package?.version ?? null,
39
+ scriptCountByGroup,
40
+ hasReadme: sourceOfTruth.docs.hasReadme,
41
+ hasChangelog: sourceOfTruth.docs.hasChangelog,
42
+ docsFileCount: sourceOfTruth.docs.files.length,
43
+ ciWorkflowCount: sourceOfTruth.ci.workflows.length,
44
+ nodeVersionsReferenced,
45
+ buildTooling: sourceOfTruth.buildTooling,
46
+ tests: sourceOfTruth.tests,
47
+ security: sourceOfTruth.security,
48
+ experiment: sourceOfTruth.experiment,
49
+ warnings: sourceOfTruth.warnings,
50
+ };
51
+ }
52
+ export function summarizeSourceFacts(sourceFacts) {
53
+ return {
54
+ totalFilesAnalyzed: sourceFacts.files.length,
55
+ filesByLanguage: sourceFacts.filesByLanguage,
56
+ filesByParseStatus: sourceFacts.filesByParseStatus,
57
+ analyzerDiagnosticCount: sourceFacts.analyzerDiagnostics.length,
58
+ warnings: sourceFacts.warnings,
59
+ };
60
+ }
61
+ const MAX_JSON_RECOMMENDATIONS = 200;
62
+ const MAX_TEXT_RECOMMENDATIONS = 10;
63
+ const SEVERITY_ORDER = ["blocker", "high", "medium", "low", "info"];
64
+ function severityRankForSort(severity) {
65
+ return SEVERITY_ORDER.indexOf(severity);
66
+ }
67
+ // Aggregates AuditIssue.recommendedAction strings: dedupes identical text,
68
+ // tracks which issues/detectors map to each, sorts blocker/high-severity
69
+ // recommendations first (stable by highest severity among contributing
70
+ // issues, then by first-occurrence order). Only derives text from actual
71
+ // issue.recommendedAction fields -- never invents new recommendation text.
72
+ export function buildRecommendations(issues) {
73
+ const byText = new Map();
74
+ issues.forEach((issue, index) => {
75
+ const text = issue.recommendedAction;
76
+ const existing = byText.get(text);
77
+ if (existing) {
78
+ if (!existing.detectorIds.includes(issue.detectorId))
79
+ existing.detectorIds.push(issue.detectorId);
80
+ existing.issueIds.push(issue.id);
81
+ if (severityRankForSort(issue.severity) < severityRankForSort(existing.highestSeverity)) {
82
+ existing.highestSeverity = issue.severity;
83
+ }
84
+ return;
85
+ }
86
+ byText.set(text, {
87
+ text,
88
+ detectorIds: [issue.detectorId],
89
+ issueIds: [issue.id],
90
+ highestSeverity: issue.severity,
91
+ firstIndex: index,
92
+ });
93
+ });
94
+ const all = [...byText.values()].sort((a, b) => {
95
+ const rankDiff = severityRankForSort(a.highestSeverity) - severityRankForSort(b.highestSeverity);
96
+ if (rankDiff !== 0)
97
+ return rankDiff;
98
+ return a.firstIndex - b.firstIndex;
99
+ });
100
+ return all.slice(0, MAX_JSON_RECOMMENDATIONS).map(({ firstIndex: _firstIndex, ...rec }) => rec);
101
+ }
102
+ export { MAX_TEXT_RECOMMENDATIONS };
103
+ // Precedence (documented per spec 3.7): a fail-on breach is always the
104
+ // primary label when both a breach AND detector errors are present, because
105
+ // the process exit code (what actually blocks automation) is driven by the
106
+ // breach, not by detector errors. "detector errors" is only ever the primary
107
+ // label when there is no breach -- callers that want to know about detector
108
+ // errors independently of the primary label should check
109
+ // summary.detectorErrorCount directly (always present regardless of which
110
+ // verdict label won).
111
+ export function computeVerdictLabel(result, breached) {
112
+ if (breached)
113
+ return "fail-on threshold breached";
114
+ if (result.detectorErrors.length > 0)
115
+ return "detector errors";
116
+ if (result.issues.length === 0)
117
+ return "no issues";
118
+ return "issues found below fail-on threshold";
119
+ }
120
+ export function categorizeDetectorError(message) {
121
+ if (/ENOENT|no such file|ENOTDIR|EACCES|EPERM/i.test(message))
122
+ return "filesystem";
123
+ if (/JSON|unexpected token|parse/i.test(message))
124
+ return "parse";
125
+ return "unknown";
126
+ }
127
+ export function buildAuditReportModel(result, opts) {
128
+ const registry = opts.registry ?? DEFAULT_AUDIT_REGISTRY;
129
+ const generatedAt = opts.generatedAt ?? new Date().toISOString();
130
+ const command = opts.command ?? reconstructCommand(result);
131
+ const issues = result.issues;
132
+ const breached = result.exitCode === AUDIT_EXIT_CODES.THRESHOLD_BREACHED;
133
+ const verdictLabel = computeVerdictLabel(result, breached);
134
+ const issuesByConfidence = countBy(issues, (i) => i.confidence, ["high", "medium", "low"]);
135
+ const issuesByFalsePositiveRisk = countBy(issues, (i) => i.falsePositiveRisk, ["high", "medium", "low"]);
136
+ const issuesByDetector = {};
137
+ for (const issue of issues) {
138
+ issuesByDetector[issue.detectorId] = (issuesByDetector[issue.detectorId] ?? 0) + 1;
139
+ }
140
+ const detectors = buildDetectorReportEntries(registry, result);
141
+ const selectedDetectorCount = detectors.filter((d) => d.status === "selected" || d.status === "error").length;
142
+ const recommendations = buildRecommendations(issues);
143
+ return {
144
+ schemaVersion: AUDIT_REPORT_SCHEMA_VERSION,
145
+ metadata: {
146
+ packageName: result.sourceOfTruth.package?.name ?? null,
147
+ packageVersion: result.sourceOfTruth.package?.version ?? null,
148
+ generatedAt,
149
+ command,
150
+ reportType: "code-rot-audit",
151
+ auditType: result.configSummary.types.join(","),
152
+ auditTypes: result.configSummary.types,
153
+ },
154
+ target: {
155
+ displayName: opts.target.displayName,
156
+ rootPath: opts.target.rootPath,
157
+ targetKind: opts.target.isSelf ? "self" : "external",
158
+ hasPackageJson: opts.target.packageJsonPath !== null,
159
+ hasGitRoot: opts.target.gitRoot !== null,
160
+ },
161
+ config: {
162
+ types: result.configSummary.types,
163
+ include: result.configSummary.include,
164
+ formats: result.configSummary.formats,
165
+ failOn: result.configSummary.failOn,
166
+ out: result.configSummary.out,
167
+ isDefaultRun: result.configSummary.isDefaultRun,
168
+ },
169
+ summary: {
170
+ totalIssues: issues.length,
171
+ issuesBySeverity: result.issueCounts,
172
+ issuesByConfidence,
173
+ issuesByFalsePositiveRisk,
174
+ issuesByDetector,
175
+ releaseBlockingCount: issues.filter((i) => i.releaseBlocking).length,
176
+ implementationBlockingCount: issues.filter((i) => i.implementationBlocking).length,
177
+ autoFixEligibleCount: issues.filter((i) => i.autoFixEligible).length,
178
+ detectorCount: registry.length,
179
+ selectedDetectorCount,
180
+ skippedDetectorCount: result.skippedDetectors.length,
181
+ detectorErrorCount: result.detectorErrors.length,
182
+ noDetectorsRegistered: selectedDetectorCount === 0,
183
+ highestSeverity: getHighestSeverity(issues),
184
+ failOnBreached: breached,
185
+ finalExitCode: result.exitCode,
186
+ finalVerdictLabel: verdictLabel,
187
+ },
188
+ inventory: summarizeInventory(result.inventory),
189
+ sourceOfTruth: summarizeSourceOfTruth(result.sourceOfTruth),
190
+ sourceFacts: summarizeSourceFacts(result.sourceFacts),
191
+ detectors,
192
+ issues,
193
+ skippedDetectors: result.skippedDetectors,
194
+ detectorErrors: result.detectorErrors.map((e) => ({ ...e, category: categorizeDetectorError(e.message) })),
195
+ recommendations,
196
+ exit: {
197
+ code: result.exitCode,
198
+ reason: result.exitReason,
199
+ failOnThreshold: result.configSummary.failOn,
200
+ breached,
201
+ },
202
+ };
203
+ }
204
+ function countBy(issues, selector, levels) {
205
+ const counts = Object.fromEntries(levels.map((l) => [l, 0]));
206
+ for (const issue of issues) {
207
+ counts[selector(issue)] += 1;
208
+ }
209
+ return counts;
210
+ }
211
+ function buildDetectorReportEntries(registry, result) {
212
+ const types = result.configSummary.types;
213
+ const include = result.configSummary.include;
214
+ const selected = new Set(selectDetectors(registry, types, include).map((d) => d.id));
215
+ const skippedIds = new Set(result.skippedDetectors.map((d) => d.id));
216
+ const erroredIds = new Set(result.detectorErrors.map((d) => d.id));
217
+ return registry.map((detector) => {
218
+ let status;
219
+ if (!selected.has(detector.id)) {
220
+ status = "excluded";
221
+ }
222
+ else if (erroredIds.has(detector.id)) {
223
+ status = "error";
224
+ }
225
+ else if (skippedIds.has(detector.id)) {
226
+ status = "skipped";
227
+ }
228
+ else {
229
+ status = "selected";
230
+ }
231
+ return {
232
+ id: detector.id,
233
+ auditType: detector.auditType,
234
+ title: detector.title,
235
+ supportedIncludeAreas: detector.supportedIncludeAreas,
236
+ status,
237
+ issueCount: result.issues.filter((i) => i.detectorId === detector.id).length,
238
+ };
239
+ });
240
+ }
241
+ function reconstructCommand(result) {
242
+ const parts = ["npm", "run", "audit", "--"];
243
+ const cfg = result.configSummary;
244
+ if (cfg.types.length > 0)
245
+ parts.push("--types", cfg.types.join(","));
246
+ if (cfg.include.length > 0)
247
+ parts.push("--include", cfg.include.join(","));
248
+ if (cfg.formats.length > 0)
249
+ parts.push("--format", cfg.formats.join(","));
250
+ parts.push("--fail-on", cfg.failOn);
251
+ parts.push("--out", cfg.out);
252
+ return parts.join(" ");
253
+ }
@@ -0,0 +1,13 @@
1
+ // ---------------------------------------------------------------------------
2
+ // v0.3.0 Batch 5 — audit report filename constants.
3
+ //
4
+ // Keeps the exact filenames already committed to in Batch 1/2/3/4
5
+ // (tests/audits/auditReportInventoryOutput.test.ts,
6
+ // tests/audits/auditCommandSmoke.test.ts,
7
+ // tests/audits/auditCommandCodeRotSmoke.test.ts all hardcode
8
+ // "code-rot-audit.json"/"code-rot-audit.txt") -- the Batch 5 spec's
9
+ // "suggested" names are only suggestions; the filename convention itself is
10
+ // unchanged, only the JSON content schema changes in this batch.
11
+ // ---------------------------------------------------------------------------
12
+ export const AUDIT_JSON_REPORT_FILENAME = "code-rot-audit.json";
13
+ export const AUDIT_TEXT_REPORT_FILENAME = "code-rot-audit.txt";
@@ -0,0 +1,30 @@
1
+ // ---------------------------------------------------------------------------
2
+ // v0.3.0 Batch 5 — stable JSON report renderer.
3
+ //
4
+ // Pure: JSON.stringify with a fixed, explicit key order (not
5
+ // insertion-order-dependent on how auditReportModel.ts happens to construct
6
+ // the object) and 2-space indent. No sanitization needed here -- JSON.stringify
7
+ // already escapes control characters into safe \u00XX sequences by spec, so
8
+ // hostile evidence content cannot corrupt the JSON structure. See
9
+ // sanitizeAuditText.ts's header comment for the same reasoning from the text
10
+ // renderer's side.
11
+ // ---------------------------------------------------------------------------
12
+ export function renderAuditJsonReport(model) {
13
+ const ordered = {
14
+ schemaVersion: model.schemaVersion,
15
+ metadata: model.metadata,
16
+ target: model.target,
17
+ config: model.config,
18
+ summary: model.summary,
19
+ inventory: model.inventory,
20
+ sourceOfTruth: model.sourceOfTruth,
21
+ sourceFacts: model.sourceFacts,
22
+ detectors: model.detectors,
23
+ issues: model.issues,
24
+ skippedDetectors: model.skippedDetectors,
25
+ detectorErrors: model.detectorErrors,
26
+ recommendations: model.recommendations,
27
+ exit: model.exit,
28
+ };
29
+ return JSON.stringify(ordered, null, 2);
30
+ }
@@ -0,0 +1,181 @@
1
+ import { MAX_TEXT_RECOMMENDATIONS } from "./auditReportModel.js";
2
+ import { sanitizeAuditText } from "./sanitizeAuditText.js";
3
+ // ---------------------------------------------------------------------------
4
+ // v0.3.0 Batch 5 — human-readable text report renderer.
5
+ //
6
+ // Pure: takes an already-built AuditReportModel and renders terminal-
7
+ // friendly text. Sanitizes any text sourced from scanned project content
8
+ // (issue titles/descriptions/evidence excerpts, recommendedAction strings)
9
+ // via sanitizeAuditText() so a hostile source file cannot poison the report
10
+ // with ANSI escapes or a fake section header. Evidence excerpts are quoted
11
+ // with a leading "> " marker precisely so a fake "===" divider or a fake
12
+ // "[BLOCKER]"-looking line embedded in scanned content reads as quoted
13
+ // evidence, not as a real report section (see spec 3.9/tests/audits/
14
+ // auditReportSanitization.test.ts).
15
+ // ---------------------------------------------------------------------------
16
+ const SEVERITY_HEADING_ORDER = ["blocker", "high", "medium", "low", "info"];
17
+ const MAX_EVIDENCE_PER_ISSUE = 2;
18
+ const MAX_EXCERPT_LENGTH = 300;
19
+ function divider(char = "=", width = 72) {
20
+ return char.repeat(width);
21
+ }
22
+ function sanitizeLine(raw) {
23
+ // Defensive bound even for non-excerpt text (titles/descriptions) --
24
+ // sanitizeAuditText() strips control chars and only truncates past
25
+ // maxLength, so ordinary short strings pass through unchanged. Also
26
+ // collapses any embedded real newlines to a single space: these fields
27
+ // (title/description/category/reason/message) are rendered as a single
28
+ // "label: value" line, so a hostile source containing a literal newline
29
+ // must not be allowed to inject what looks like a second, unindented
30
+ // report line.
31
+ return sanitizeAuditText(raw, 500).replace(/\r\n|\r|\n/g, " ");
32
+ }
33
+ // Renders a (possibly multi-line) evidence excerpt as one or more clearly
34
+ // quoted lines, each individually prefixed with "> " -- this is what stops a
35
+ // hostile excerpt containing an embedded newline plus a fake "===" divider
36
+ // or a fake "[BLOCKER] ..." header line from ever appearing as a bare,
37
+ // unindented line that could be mistaken for real report structure. Every
38
+ // physical line of the excerpt is quoted, not just the first.
39
+ function renderQuotedExcerpt(raw, indent) {
40
+ const sanitized = sanitizeAuditText(raw, MAX_EXCERPT_LENGTH);
41
+ return sanitized.split(/\r\n|\r|\n/).map((line) => `${indent}> ${line}`);
42
+ }
43
+ export function renderAuditTextReport(model) {
44
+ const lines = [];
45
+ lines.push("my-dev-kit-lab audit report");
46
+ lines.push(divider());
47
+ lines.push(`Schema version: ${model.schemaVersion}`);
48
+ lines.push(`Generated: ${model.metadata.generatedAt}`);
49
+ lines.push(`Report type: ${model.metadata.reportType} (audit type: ${model.metadata.auditType})`);
50
+ lines.push(`Package: ${model.metadata.packageName ?? "(none)"}@${model.metadata.packageVersion ?? "?"}`);
51
+ lines.push("");
52
+ lines.push("Target");
53
+ lines.push(divider("-"));
54
+ lines.push(` ${sanitizeLine(model.target.displayName)} (${model.target.rootPath})`);
55
+ lines.push(` kind=${model.target.targetKind} hasPackageJson=${model.target.hasPackageJson} hasGitRoot=${model.target.hasGitRoot}`);
56
+ lines.push("");
57
+ lines.push("Config");
58
+ lines.push(divider("-"));
59
+ lines.push(` types=${model.config.types.join(",")} include=${model.config.include.join(",")}`);
60
+ lines.push(` formats=${model.config.formats.join(",")} failOn=${model.config.failOn} isDefaultRun=${model.config.isDefaultRun}`);
61
+ lines.push(` out=${model.config.out}`);
62
+ lines.push("");
63
+ lines.push("Detectors");
64
+ lines.push(divider("-"));
65
+ for (const d of model.detectors) {
66
+ lines.push(` [${d.status.padEnd(8)}] ${d.id} (${d.auditType}) issues=${d.issueCount}`);
67
+ }
68
+ lines.push("");
69
+ lines.push("Inventory summary");
70
+ lines.push(divider("-"));
71
+ lines.push(` total=${model.inventory.totalFileCount} scanned=${model.inventory.totalScannedFileCount} skipped=${model.inventory.skippedFileCount}`);
72
+ lines.push(` source=${model.inventory.filesByCategory.source} tests=${model.inventory.filesByCategory.tests} docs=${model.inventory.filesByCategory.docs} package=${model.inventory.filesByCategory.package} config=${model.inventory.filesByCategory.config} scripts=${model.inventory.filesByCategory.scripts} ci=${model.inventory.filesByCategory.ci} generated=${model.inventory.filesByCategory.generated} report=${model.inventory.filesByCategory.report} unknown=${model.inventory.filesByCategory.unknown}`);
73
+ lines.push(` languages: typescript=${model.inventory.filesByLanguage.typescript} javascript=${model.inventory.filesByLanguage.javascript} python=${model.inventory.filesByLanguage.python} java=${model.inventory.filesByLanguage.java} kotlin=${model.inventory.filesByLanguage.kotlin} json=${model.inventory.filesByLanguage.json} markdown=${model.inventory.filesByLanguage.markdown} yaml=${model.inventory.filesByLanguage.yaml} xml=${model.inventory.filesByLanguage.xml} toml=${model.inventory.filesByLanguage.toml} unknown=${model.inventory.filesByLanguage.unknown}`);
74
+ lines.push(` roles: source=${model.inventory.filesByRole.source} test=${model.inventory.filesByRole.test} docs=${model.inventory.filesByRole.docs} config=${model.inventory.filesByRole.config} package=${model.inventory.filesByRole.package} generated=${model.inventory.filesByRole.generated} build-output=${model.inventory.filesByRole["build-output"]} vendor=${model.inventory.filesByRole.vendor} report-output=${model.inventory.filesByRole["report-output"]} unknown=${model.inventory.filesByRole.unknown}`);
75
+ if (model.inventory.warnings.length > 0) {
76
+ lines.push(` warnings: ${model.inventory.warnings.length}`);
77
+ }
78
+ lines.push("");
79
+ lines.push("Source facts summary");
80
+ lines.push(divider("-"));
81
+ lines.push(` analyzed=${model.sourceFacts.totalFilesAnalyzed}`);
82
+ lines.push(` parsed=${model.sourceFacts.filesByParseStatus.parsed} file-level-only=${model.sourceFacts.filesByParseStatus["file-level-only"]} unsupported=${model.sourceFacts.filesByParseStatus.unsupported} parse-error=${model.sourceFacts.filesByParseStatus["parse-error"]} skipped=${model.sourceFacts.filesByParseStatus.skipped}`);
83
+ if (model.sourceFacts.analyzerDiagnosticCount > 0) {
84
+ lines.push(` analyzer diagnostics: ${model.sourceFacts.analyzerDiagnosticCount}`);
85
+ }
86
+ lines.push("");
87
+ lines.push("Source-of-truth summary");
88
+ lines.push(divider("-"));
89
+ lines.push(` package: ${model.sourceOfTruth.packageName ?? "(none)"}@${model.sourceOfTruth.packageVersion ?? "?"}`);
90
+ lines.push(` README present=${model.sourceOfTruth.hasReadme} CHANGELOG present=${model.sourceOfTruth.hasChangelog} docs files=${model.sourceOfTruth.docsFileCount}`);
91
+ lines.push(` CI workflows=${model.sourceOfTruth.ciWorkflowCount} node versions referenced=${model.sourceOfTruth.nodeVersionsReferenced.join(",") || "(none)"}`);
92
+ if (model.sourceOfTruth.warnings.length > 0) {
93
+ lines.push(` warnings: ${model.sourceOfTruth.warnings.length}`);
94
+ }
95
+ lines.push("");
96
+ lines.push("Issue summary");
97
+ lines.push(divider("-"));
98
+ lines.push(` Total issues: ${model.summary.totalIssues}`);
99
+ lines.push(` By severity: blocker=${model.summary.issuesBySeverity.blocker} high=${model.summary.issuesBySeverity.high} medium=${model.summary.issuesBySeverity.medium} low=${model.summary.issuesBySeverity.low} info=${model.summary.issuesBySeverity.info}`);
100
+ lines.push(` By confidence: high=${model.summary.issuesByConfidence.high} medium=${model.summary.issuesByConfidence.medium} low=${model.summary.issuesByConfidence.low}`);
101
+ lines.push(` By false-positive risk: high=${model.summary.issuesByFalsePositiveRisk.high} medium=${model.summary.issuesByFalsePositiveRisk.medium} low=${model.summary.issuesByFalsePositiveRisk.low}`);
102
+ lines.push(` Release-blocking=${model.summary.releaseBlockingCount} Implementation-blocking=${model.summary.implementationBlockingCount} Auto-fix-eligible=${model.summary.autoFixEligibleCount}`);
103
+ lines.push(` Detectors: total=${model.summary.detectorCount} selected=${model.summary.selectedDetectorCount} skipped=${model.summary.skippedDetectorCount} errors=${model.summary.detectorErrorCount}`);
104
+ lines.push(` Highest severity: ${model.summary.highestSeverity ?? "(none)"}`);
105
+ lines.push(` Verdict: ${model.summary.finalVerdictLabel}`);
106
+ lines.push("");
107
+ if (model.recommendations.length > 0) {
108
+ lines.push(`Top recommendations (showing up to ${MAX_TEXT_RECOMMENDATIONS} of ${model.recommendations.length})`);
109
+ lines.push(divider("-"));
110
+ for (const rec of model.recommendations.slice(0, MAX_TEXT_RECOMMENDATIONS)) {
111
+ lines.push(` - [${rec.highestSeverity}] ${sanitizeLine(rec.text)} (${rec.issueIds.length} issue(s), detector(s): ${rec.detectorIds.join(", ")})`);
112
+ }
113
+ lines.push("");
114
+ }
115
+ lines.push("Issues (grouped by severity)");
116
+ lines.push(divider("-"));
117
+ if (model.issues.length === 0) {
118
+ lines.push(" (no issues in this run)");
119
+ }
120
+ else {
121
+ for (const severity of SEVERITY_HEADING_ORDER) {
122
+ const group = model.issues.filter((i) => i.severity === severity);
123
+ if (group.length === 0)
124
+ continue;
125
+ lines.push(` -- ${severity.toUpperCase()} (${group.length}) --`);
126
+ for (const issue of group) {
127
+ lines.push(...renderIssueBlock(issue));
128
+ }
129
+ }
130
+ }
131
+ lines.push("");
132
+ lines.push("Skipped detectors");
133
+ lines.push(divider("-"));
134
+ if (model.skippedDetectors.length === 0) {
135
+ lines.push(" (none)");
136
+ }
137
+ else {
138
+ for (const s of model.skippedDetectors) {
139
+ lines.push(` [${s.id}] ${sanitizeLine(s.reason)}`);
140
+ }
141
+ }
142
+ lines.push("");
143
+ lines.push("Detector errors");
144
+ lines.push(divider("-"));
145
+ if (model.detectorErrors.length === 0) {
146
+ lines.push(" (none)");
147
+ }
148
+ else {
149
+ for (const e of model.detectorErrors) {
150
+ lines.push(` [${e.id}] (${e.category}) ${sanitizeLine(e.message)}`);
151
+ }
152
+ }
153
+ lines.push("");
154
+ lines.push("Exit result");
155
+ lines.push(divider("-"));
156
+ lines.push(` code=${model.exit.code} failOnThreshold=${model.exit.failOnThreshold} breached=${model.exit.breached}`);
157
+ lines.push(` reason: ${sanitizeLine(model.exit.reason)}`);
158
+ return lines.join("\n");
159
+ }
160
+ function renderIssueBlock(issue) {
161
+ const lines = [];
162
+ lines.push(` * ${sanitizeLine(issue.title)}`);
163
+ lines.push(` severity=${issue.severity} confidence=${issue.confidence} category=${sanitizeLine(issue.category)}`);
164
+ lines.push(` detector=${issue.detectorId} id=${issue.id}`);
165
+ for (const evidence of issue.evidence.slice(0, MAX_EVIDENCE_PER_ISSUE)) {
166
+ // v0.3.1 Batch 5 -- an evidence entry's `message` (the human-readable
167
+ // "why") and `excerpt` (a quoted snippet/reference list) are
168
+ // independent fields on the same entry -- render both when both are
169
+ // present. Previously an `excerpt` silently suppressed `message`
170
+ // entirely, which dropped the only readable explanation for
171
+ // source-facts-derived findings (e.g. duplicateImplementationDetector's
172
+ // "Source facts: an exported ... was parsed in N distinct files.") and
173
+ // left only a bare file-path excerpt in text output.
174
+ lines.push(` > ${sanitizeLine(evidence.message)}`);
175
+ if (evidence.excerpt) {
176
+ lines.push(...renderQuotedExcerpt(evidence.excerpt, " "));
177
+ }
178
+ }
179
+ lines.push(` recommendedAction: ${sanitizeLine(issue.recommendedAction)}`);
180
+ return lines;
181
+ }
@@ -0,0 +1,39 @@
1
+ // ---------------------------------------------------------------------------
2
+ // v0.3.0 Batch 5 — audit text-report sanitization.
3
+ //
4
+ // Same technique as src/securityValidation/attackScenarios/exploitEvidence.ts's
5
+ // stripUnsafeControlChars()/sanitizeForTextReport() (strip ANSI CSI escapes
6
+ // and other unsafe control bytes before printing hostile-sourced text into a
7
+ // terminal-readable report). Deliberately NOT imported from
8
+ // src/securityValidation/ -- audit and security-validation are kept as
9
+ // independent frameworks throughout this codebase (see the header comment in
10
+ // src/audits/core/auditIssue.ts), and this is a small, self-contained,
11
+ // dependency-free function, so a 10-line local copy is cheaper than adding a
12
+ // cross-framework import for one helper.
13
+ //
14
+ // JSON output does not need this: JSON.stringify already escapes control
15
+ // characters (including ESC, 0x1b) into safe \u00XX sequences by
16
+ // specification, so hostile control bytes can never corrupt JSON structure.
17
+ // This sanitizer is used only by renderAuditTextReport.ts.
18
+ // ---------------------------------------------------------------------------
19
+ // ANSI CSI (Control Sequence Introducer) escape sequences, e.g. "\x1b[31m".
20
+ const ANSI_CSI_PATTERN = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
21
+ // Other C0 control characters except common whitespace (tab \t, newline \n,
22
+ // carriage return \r), plus the DEL character (0x7f).
23
+ // eslint-disable-next-line no-control-regex
24
+ const OTHER_CONTROL_CHARS_PATTERN = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g;
25
+ export function stripUnsafeControlChars(raw) {
26
+ return raw.replace(ANSI_CSI_PATTERN, "").replace(OTHER_CONTROL_CHARS_PATTERN, "");
27
+ }
28
+ const DEFAULT_MAX_TEXT_EXCERPT_LENGTH = 300;
29
+ // Bounds+sanitizes a string for inclusion in the text report: strips unsafe
30
+ // control bytes first (so a payload cannot hide behind control characters),
31
+ // then re-bounds length defensively even though callers (e.g. AuditEvidence
32
+ // excerpts) are usually already bounded upstream by boundExcerpt() in
33
+ // auditIssue.ts.
34
+ export function sanitizeAuditText(raw, maxLength = DEFAULT_MAX_TEXT_EXCERPT_LENGTH) {
35
+ const stripped = stripUnsafeControlChars(raw);
36
+ if (stripped.length <= maxLength)
37
+ return stripped;
38
+ return `${stripped.slice(0, maxLength)}... [truncated, ${stripped.length} chars total]`;
39
+ }
@@ -0,0 +1,27 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { AUDIT_JSON_REPORT_FILENAME, AUDIT_TEXT_REPORT_FILENAME } from "./auditReportPaths.js";
4
+ import { renderAuditJsonReport } from "./renderAuditJsonReport.js";
5
+ import { renderAuditTextReport } from "./renderAuditTextReport.js";
6
+ export function writeAuditReports(options) {
7
+ const { model, config, outDir } = options;
8
+ const resolvedOutDir = path.resolve(outDir);
9
+ const writtenPaths = [];
10
+ if (config.formats.length === 0) {
11
+ return { writtenPaths };
12
+ }
13
+ if (!fs.existsSync(resolvedOutDir)) {
14
+ fs.mkdirSync(resolvedOutDir, { recursive: true });
15
+ }
16
+ if (config.formats.includes("json")) {
17
+ const jsonPath = path.join(resolvedOutDir, AUDIT_JSON_REPORT_FILENAME);
18
+ fs.writeFileSync(jsonPath, renderAuditJsonReport(model), "utf8");
19
+ writtenPaths.push(jsonPath);
20
+ }
21
+ if (config.formats.includes("text")) {
22
+ const textPath = path.join(resolvedOutDir, AUDIT_TEXT_REPORT_FILENAME);
23
+ fs.writeFileSync(textPath, renderAuditTextReport(model), "utf8");
24
+ writtenPaths.push(textPath);
25
+ }
26
+ return { writtenPaths };
27
+ }
@@ -4,17 +4,27 @@ const windowsExtensionPreference = [".cmd", ".exe", ".bat", ".ps1", ""];
4
4
  export function resolveCommand(command, options = {}) {
5
5
  const platform = options.platform ?? process.platform;
6
6
  const env = options.env ?? process.env;
7
+ const cwd = options.cwd ?? process.cwd();
7
8
  if (platform !== "win32") {
9
+ const posixCandidate = findPosixCommandCandidate(command, env, cwd);
10
+ if (!posixCandidate) {
11
+ return {
12
+ originalCommand: command,
13
+ command,
14
+ argsPrefix: [],
15
+ resolutionKind: "unavailable",
16
+ warnings: [`Command was not found on PATH: ${command}`]
17
+ };
18
+ }
8
19
  return {
9
20
  originalCommand: command,
10
21
  command,
11
22
  argsPrefix: [],
12
23
  resolutionKind: "direct",
13
- resolvedPath: command,
24
+ resolvedPath: posixCandidate,
14
25
  warnings: []
15
26
  };
16
27
  }
17
- const cwd = options.cwd ?? process.cwd();
18
28
  const candidate = findWindowsCommandCandidate(command, env, cwd);
19
29
  if (!candidate) {
20
30
  return {
@@ -68,6 +78,47 @@ function commandForWindowsCandidate(originalCommand, resolvedPath, env, allowPow
68
78
  warnings: []
69
79
  };
70
80
  }
81
+ // POSIX (Linux/macOS) command resolution. Unlike the Windows branch above,
82
+ // there is no extension-guessing -- a bare command name must match exactly
83
+ // one PATH entry, and the match must actually be executable (checked via
84
+ // X_OK, not just file existence, so a non-executable regular file with a
85
+ // matching name is correctly treated as absent). This mirrors the Windows
86
+ // branch's "search PATH, report unavailable if nothing matches" contract:
87
+ // previously this platform branch unconditionally reported "direct" without
88
+ // ever checking PATH, so a genuinely-missing command (e.g. codeql, semgrep)
89
+ // was only discovered as absent when the downstream spawn failed with
90
+ // ENOENT -- which staticScans/codeql.ts and staticScans/semgrep.ts
91
+ // misclassify as a MAJOR/failed finding rather than a graceful skip,
92
+ // causing security:validate to falsely report a release blocker on
93
+ // Linux/macOS whenever an optional static-analysis tool is not installed.
94
+ function findPosixCommandCandidate(command, env, cwd) {
95
+ if (hasPathSeparator(command) || path.isAbsolute(command)) {
96
+ const resolved = path.resolve(cwd, command);
97
+ return isExecutablePosixFile(resolved) ? resolved : undefined;
98
+ }
99
+ for (const searchDir of getPosixPathEntries(env)) {
100
+ const candidate = path.join(searchDir, command);
101
+ if (isExecutablePosixFile(candidate)) {
102
+ return candidate;
103
+ }
104
+ }
105
+ return undefined;
106
+ }
107
+ function getPosixPathEntries(env) {
108
+ const pathValue = env.PATH ?? env.Path ?? "";
109
+ return pathValue.split(path.delimiter).filter(Boolean);
110
+ }
111
+ function isExecutablePosixFile(candidatePath) {
112
+ try {
113
+ if (!fs.statSync(candidatePath).isFile())
114
+ return false;
115
+ fs.accessSync(candidatePath, fs.constants.X_OK);
116
+ return true;
117
+ }
118
+ catch {
119
+ return false;
120
+ }
121
+ }
71
122
  function findWindowsCommandCandidate(command, env, cwd) {
72
123
  if (hasPathSeparator(command) || path.isAbsolute(command)) {
73
124
  return findCandidateInDirectory(path.resolve(cwd, command));