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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. package/README.md +19 -6
  2. package/dist/scripts/audits/runAudit.js +9 -2
  3. package/dist/scripts/security/validate.js +8 -29
  4. package/dist/src/audits/codeRot/detectors/deadCodeCandidateDetector.js +138 -11
  5. package/dist/src/audits/codeRot/detectors/duplicateImplementationDetector.js +93 -1
  6. package/dist/src/audits/codeRot/detectors/testRotDetector.js +176 -0
  7. package/dist/src/audits/codeRot/utils/filePatternUtils.js +50 -0
  8. package/dist/src/audits/codeRot/utils/sourceFactsLookup.js +53 -0
  9. package/dist/src/audits/core/auditConfig.js +2 -2
  10. package/dist/src/audits/core/auditRunner.js +23 -1
  11. package/dist/src/audits/core/auditTypes.js +14 -7
  12. package/dist/src/audits/core/collectSourceFacts.js +142 -0
  13. package/dist/src/audits/core/languageAnalyzerRegistry.js +36 -0
  14. package/dist/src/audits/core/projectInventory.js +185 -3
  15. package/dist/src/audits/core/pythonAnalyzer.js +328 -0
  16. package/dist/src/audits/core/pythonProjectMetadata.js +80 -0
  17. package/dist/src/audits/core/sourceFacts.js +17 -0
  18. package/dist/src/audits/core/typescriptJavaScriptAnalyzer.js +299 -0
  19. package/dist/src/audits/report/auditReportModel.js +15 -0
  20. package/dist/src/audits/report/renderAuditJsonReport.js +3 -0
  21. package/dist/src/audits/report/renderAuditTextReport.js +59 -3
  22. package/dist/src/audits/security/mapSecurityFindingToAuditIssue.js +70 -0
  23. package/dist/src/audits/security/securityAuditAdapter.js +67 -0
  24. package/dist/src/audits/security/securityAuditTypes.js +17 -0
  25. package/dist/src/securityValidation/report/buildSecurityReport.js +31 -0
  26. package/dist/src/securityValidation/validate/resolveTarget.js +9 -0
  27. package/package.json +97 -96
@@ -0,0 +1,299 @@
1
+ import ts from "typescript";
2
+ // ---------------------------------------------------------------------------
3
+ // v0.3.1 Batch 3 -- TypeScript/JavaScript source analyzer.
4
+ //
5
+ // Single-file, syntax-only analysis via the TypeScript compiler API:
6
+ // - ts.createSourceFile() builds a per-file AST (no Program, no project
7
+ // resolution, no type checking) that is walked once to extract facts.
8
+ // - ts.transpileModule({ reportDiagnostics: true }) is used purely to
9
+ // obtain syntactic diagnostics -- it is itself a single-file operation
10
+ // (no cross-file resolution), matching the "single-file parsing only"
11
+ // requirement for this batch.
12
+ //
13
+ // Parse-status policy (deliberately conservative, see docs on
14
+ // SourceFactParseStatus): zero syntax diagnostics -> "parsed". Any syntax
15
+ // diagnostic -> "parse-error", even though the AST walk below still runs
16
+ // against TypeScript's best-effort (error-tolerant) parse and whatever facts
17
+ // it manages to extract are still attached -- the parseStatus signals
18
+ // "don't treat these facts as fully reliable", not "no facts exist". This
19
+ // mirrors how collectSourceFacts.ts already reports a caught analyzer
20
+ // exception as parse-error with an empty-fact fallback -- this analyzer just
21
+ // reaches parse-error through diagnostics instead of a thrown exception, and
22
+ // still gets to keep best-effort facts (which a thrown-exception parse-error
23
+ // never has, since collectSourceFacts.ts only has the exception itself).
24
+ // ---------------------------------------------------------------------------
25
+ export const TYPESCRIPT_JAVASCRIPT_ANALYZER_ID = "typescript-javascript-analyzer";
26
+ const SUPPORTED_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"];
27
+ // AST construction is meaningfully more expensive than a line count -- large
28
+ // files (generated bundles, vendored single-file libraries that slipped past
29
+ // role/path exclusions, etc.) are not parsed. Mirrors the size-bounding
30
+ // philosophy of projectInventory.ts's own read limits, just at a tighter
31
+ // threshold appropriate for actually building an AST.
32
+ const MAX_ANALYZABLE_BYTES = 1_000_000;
33
+ export const TYPESCRIPT_JAVASCRIPT_ANALYZER = {
34
+ id: TYPESCRIPT_JAVASCRIPT_ANALYZER_ID,
35
+ supportedLanguages: ["typescript", "javascript"],
36
+ supportedExtensions: SUPPORTED_EXTENSIONS,
37
+ analyzeFile,
38
+ };
39
+ function analyzeFile(input) {
40
+ const { relativePath, language, role, content, inventoryEntry } = input;
41
+ if (Buffer.byteLength(content, "utf8") > MAX_ANALYZABLE_BYTES) {
42
+ return {
43
+ relativePath,
44
+ language,
45
+ role,
46
+ parseStatus: "file-level-only",
47
+ analyzerId: TYPESCRIPT_JAVASCRIPT_ANALYZER_ID,
48
+ lineCount: inventoryEntry.lineCount,
49
+ imports: [],
50
+ exports: [],
51
+ declarations: [],
52
+ references: [],
53
+ diagnostics: [
54
+ {
55
+ severity: "info",
56
+ code: "file-too-large-for-analysis",
57
+ message: `File exceeds the ${MAX_ANALYZABLE_BYTES}-byte analyzer bound; only inventory-level facts are available.`,
58
+ path: relativePath,
59
+ analyzerId: TYPESCRIPT_JAVASCRIPT_ANALYZER_ID,
60
+ },
61
+ ],
62
+ };
63
+ }
64
+ const scriptKind = resolveScriptKind(inventoryEntry.extension);
65
+ const sourceFile = ts.createSourceFile(relativePath, content, ts.ScriptTarget.Latest, true, scriptKind);
66
+ const diagnostics = getSyntaxDiagnostics(relativePath, sourceFile);
67
+ const imports = [];
68
+ const exports = [];
69
+ const declarations = [];
70
+ const references = [];
71
+ const lineOf = (node) => sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
72
+ function pushDeclaration(node, name, kind) {
73
+ const isExported = hasModifier(node, ts.SyntaxKind.ExportKeyword);
74
+ const isDefault = hasModifier(node, ts.SyntaxKind.DefaultKeyword);
75
+ if (name) {
76
+ declarations.push({ name, kind, exported: isExported, line: lineOf(node) });
77
+ }
78
+ if (isDefault) {
79
+ exports.push({ name: name ?? "default", kind: "default", line: lineOf(node) });
80
+ }
81
+ else if (isExported && name) {
82
+ exports.push({ name, kind: "named", line: lineOf(node) });
83
+ }
84
+ }
85
+ function handleImportDeclaration(node) {
86
+ if (!ts.isStringLiteralLike(node.moduleSpecifier))
87
+ return;
88
+ const source = node.moduleSpecifier.text;
89
+ const importedNames = [];
90
+ const clause = node.importClause;
91
+ if (clause) {
92
+ if (clause.name)
93
+ importedNames.push(clause.name.text);
94
+ const bindings = clause.namedBindings;
95
+ if (bindings && ts.isNamespaceImport(bindings)) {
96
+ importedNames.push(bindings.name.text);
97
+ }
98
+ else if (bindings && ts.isNamedImports(bindings)) {
99
+ for (const element of bindings.elements)
100
+ importedNames.push(element.name.text);
101
+ }
102
+ }
103
+ imports.push({
104
+ source,
105
+ kind: "static",
106
+ line: lineOf(node),
107
+ ...(importedNames.length > 0 ? { importedNames } : {}),
108
+ });
109
+ }
110
+ function handleExportDeclaration(node) {
111
+ const source = node.moduleSpecifier && ts.isStringLiteralLike(node.moduleSpecifier) ? node.moduleSpecifier.text : undefined;
112
+ if (source) {
113
+ imports.push({ source, kind: "re-export", line: lineOf(node) });
114
+ }
115
+ if (!node.exportClause) {
116
+ if (source)
117
+ exports.push({ name: "*", kind: "namespace", line: lineOf(node) });
118
+ return;
119
+ }
120
+ if (ts.isNamespaceExport(node.exportClause)) {
121
+ exports.push({ name: node.exportClause.name.text, kind: "namespace", line: lineOf(node) });
122
+ return;
123
+ }
124
+ for (const element of node.exportClause.elements) {
125
+ exports.push({ name: element.name.text, kind: "named", line: lineOf(node) });
126
+ }
127
+ }
128
+ function handleExportAssignment(node) {
129
+ if (node.isExportEquals) {
130
+ exports.push({ name: "module.exports", kind: "commonjs", line: lineOf(node) });
131
+ return;
132
+ }
133
+ const expr = node.expression;
134
+ let name = "default";
135
+ if (ts.isIdentifier(expr)) {
136
+ name = expr.text;
137
+ }
138
+ else if ((ts.isFunctionExpression(expr) || ts.isClassExpression(expr)) && expr.name) {
139
+ name = expr.name.text;
140
+ }
141
+ exports.push({ name, kind: "default", line: lineOf(node) });
142
+ }
143
+ function handleVariableStatement(node) {
144
+ const isExported = hasModifier(node, ts.SyntaxKind.ExportKeyword);
145
+ const isConst = (node.declarationList.flags & ts.NodeFlags.Const) !== 0;
146
+ for (const decl of node.declarationList.declarations) {
147
+ // Destructuring patterns (`const { a, b } = x`) are conservatively
148
+ // skipped -- only simple identifier bindings are recorded.
149
+ if (!ts.isIdentifier(decl.name))
150
+ continue;
151
+ declarations.push({
152
+ name: decl.name.text,
153
+ kind: isConst ? "constant" : "variable",
154
+ exported: isExported,
155
+ line: lineOf(decl),
156
+ });
157
+ if (isExported) {
158
+ exports.push({ name: decl.name.text, kind: "named", line: lineOf(decl) });
159
+ }
160
+ }
161
+ }
162
+ function handleCommonJsAssignment(node) {
163
+ const left = node.left;
164
+ if (!ts.isPropertyAccessExpression(left) || !ts.isIdentifier(left.expression))
165
+ return;
166
+ if (left.expression.text === "module" && left.name.text === "exports") {
167
+ exports.push({ name: "module.exports", kind: "commonjs", line: lineOf(node) });
168
+ }
169
+ else if (left.expression.text === "exports") {
170
+ exports.push({ name: left.name.text, kind: "commonjs", line: lineOf(node) });
171
+ }
172
+ }
173
+ function handleCallExpression(node) {
174
+ if (ts.isIdentifier(node.expression) && node.expression.text === "require") {
175
+ const arg = node.arguments[0];
176
+ if (arg && ts.isStringLiteralLike(arg)) {
177
+ imports.push({ source: arg.text, kind: "commonjs", line: lineOf(node) });
178
+ }
179
+ return; // already represented as an import fact -- do not also reference "require".
180
+ }
181
+ if (node.expression.kind === ts.SyntaxKind.ImportKeyword) {
182
+ const arg = node.arguments[0];
183
+ if (arg && ts.isStringLiteralLike(arg)) {
184
+ imports.push({ source: arg.text, kind: "dynamic", line: lineOf(node) });
185
+ }
186
+ return;
187
+ }
188
+ if (ts.isIdentifier(node.expression)) {
189
+ // Conservative reference extraction: only bare-identifier call
190
+ // expressions (`foo()`), not property-access calls (`a.b()`) or any
191
+ // other identifier usage -- no scope analysis, no unused-detection.
192
+ references.push({ name: node.expression.text, kind: "call", line: lineOf(node) });
193
+ }
194
+ }
195
+ function visit(node) {
196
+ if (ts.isImportDeclaration(node)) {
197
+ handleImportDeclaration(node);
198
+ }
199
+ else if (ts.isExportDeclaration(node)) {
200
+ handleExportDeclaration(node);
201
+ }
202
+ else if (ts.isExportAssignment(node)) {
203
+ handleExportAssignment(node);
204
+ }
205
+ else if (ts.isFunctionDeclaration(node)) {
206
+ pushDeclaration(node, node.name?.text, "function");
207
+ }
208
+ else if (ts.isClassDeclaration(node)) {
209
+ pushDeclaration(node, node.name?.text, "class");
210
+ }
211
+ else if (ts.isInterfaceDeclaration(node)) {
212
+ pushDeclaration(node, node.name.text, "interface");
213
+ }
214
+ else if (ts.isTypeAliasDeclaration(node)) {
215
+ pushDeclaration(node, node.name.text, "type");
216
+ }
217
+ else if (ts.isEnumDeclaration(node)) {
218
+ pushDeclaration(node, node.name.text, "enum");
219
+ }
220
+ else if (ts.isVariableStatement(node)) {
221
+ handleVariableStatement(node);
222
+ }
223
+ else if (ts.isMethodDeclaration(node) && ts.isIdentifier(node.name)) {
224
+ declarations.push({ name: node.name.text, kind: "method", exported: false, line: lineOf(node) });
225
+ }
226
+ else if (ts.isCallExpression(node)) {
227
+ handleCallExpression(node);
228
+ }
229
+ else if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken) {
230
+ handleCommonJsAssignment(node);
231
+ }
232
+ ts.forEachChild(node, visit);
233
+ }
234
+ visit(sourceFile);
235
+ const parseStatus = diagnostics.length > 0 ? "parse-error" : "parsed";
236
+ return {
237
+ relativePath,
238
+ language,
239
+ role,
240
+ parseStatus,
241
+ analyzerId: TYPESCRIPT_JAVASCRIPT_ANALYZER_ID,
242
+ lineCount: inventoryEntry.lineCount,
243
+ imports,
244
+ exports,
245
+ declarations,
246
+ references,
247
+ diagnostics,
248
+ };
249
+ }
250
+ function resolveScriptKind(extension) {
251
+ switch (extension) {
252
+ case ".tsx":
253
+ return ts.ScriptKind.TSX;
254
+ case ".jsx":
255
+ return ts.ScriptKind.JSX;
256
+ case ".js":
257
+ case ".mjs":
258
+ case ".cjs":
259
+ return ts.ScriptKind.JS;
260
+ case ".ts":
261
+ case ".mts":
262
+ case ".cts":
263
+ default:
264
+ return ts.ScriptKind.TS;
265
+ }
266
+ }
267
+ function hasModifier(node, kind) {
268
+ if (!ts.canHaveModifiers(node))
269
+ return false;
270
+ const modifiers = ts.getModifiers(node);
271
+ return modifiers?.some((m) => m.kind === kind) ?? false;
272
+ }
273
+ // Reads syntax diagnostics directly off the already-built SourceFile rather
274
+ // than running a second full parse via ts.transpileModule(). `parseDiagnostics`
275
+ // is not part of the public TypeScript.d.ts surface, but it is populated by
276
+ // createSourceFile() itself (every syntax error the parser recovers from is
277
+ // recorded there) and has been stable across TypeScript versions for years --
278
+ // this is a widely-used pattern for exactly this "syntax diagnostics without
279
+ // a second parse/transpile pass" use case. Benchmarked at ~5x faster than
280
+ // transpileModule() over this repo's own ~375 TS/JS files, which matters
281
+ // because collectSourceFacts.ts calls this analyzer once per eligible file
282
+ // on every audit run (including this repo's own self-audit).
283
+ function getSyntaxDiagnostics(relativePath, sourceFile) {
284
+ const parseDiagnostics = sourceFile.parseDiagnostics ?? [];
285
+ return parseDiagnostics.map((diagnostic) => toSourceFactDiagnostic(relativePath, diagnostic));
286
+ }
287
+ function toSourceFactDiagnostic(relativePath, diagnostic) {
288
+ const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n");
289
+ const line = diagnostic.file && diagnostic.start !== undefined
290
+ ? diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start).line + 1
291
+ : undefined;
292
+ return {
293
+ severity: diagnostic.category === ts.DiagnosticCategory.Warning ? "warning" : "error",
294
+ code: `TS${diagnostic.code}`,
295
+ message: line !== undefined ? `Line ${line}: ${message}` : message,
296
+ path: relativePath,
297
+ analyzerId: TYPESCRIPT_JAVASCRIPT_ANALYZER_ID,
298
+ };
299
+ }
@@ -22,6 +22,8 @@ export function summarizeInventory(inventory) {
22
22
  skippedFileCount: inventory.skippedFileCount,
23
23
  filesByCategory: inventory.filesByCategory,
24
24
  filesByExtension: inventory.filesByExtension,
25
+ filesByLanguage: inventory.filesByLanguage,
26
+ filesByRole: inventory.filesByRole,
25
27
  excludedDirectorySummary: inventory.excludedDirectorySummary,
26
28
  warnings: inventory.warnings,
27
29
  };
@@ -47,6 +49,16 @@ export function summarizeSourceOfTruth(sourceOfTruth) {
47
49
  warnings: sourceOfTruth.warnings,
48
50
  };
49
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
+ filesWithDiagnosticsCount: sourceFacts.files.filter((f) => f.diagnostics.length > 0).length,
59
+ warnings: sourceFacts.warnings,
60
+ };
61
+ }
50
62
  const MAX_JSON_RECOMMENDATIONS = 200;
51
63
  const MAX_TEXT_RECOMMENDATIONS = 10;
52
64
  const SEVERITY_ORDER = ["blocker", "high", "medium", "low", "info"];
@@ -176,6 +188,9 @@ export function buildAuditReportModel(result, opts) {
176
188
  },
177
189
  inventory: summarizeInventory(result.inventory),
178
190
  sourceOfTruth: summarizeSourceOfTruth(result.sourceOfTruth),
191
+ sourceFacts: summarizeSourceFacts(result.sourceFacts),
192
+ pythonProjectMetadata: result.pythonProjectMetadata,
193
+ securitySummary: result.securitySummary,
179
194
  detectors,
180
195
  issues,
181
196
  skippedDetectors: result.skippedDetectors,
@@ -18,6 +18,9 @@ export function renderAuditJsonReport(model) {
18
18
  summary: model.summary,
19
19
  inventory: model.inventory,
20
20
  sourceOfTruth: model.sourceOfTruth,
21
+ sourceFacts: model.sourceFacts,
22
+ pythonProjectMetadata: model.pythonProjectMetadata,
23
+ securitySummary: model.securitySummary,
21
24
  detectors: model.detectors,
22
25
  issues: model.issues,
23
26
  skippedDetectors: model.skippedDetectors,
@@ -70,10 +70,34 @@ export function renderAuditTextReport(model) {
70
70
  lines.push(divider("-"));
71
71
  lines.push(` total=${model.inventory.totalFileCount} scanned=${model.inventory.totalScannedFileCount} skipped=${model.inventory.skippedFileCount}`);
72
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}`);
73
75
  if (model.inventory.warnings.length > 0) {
74
76
  lines.push(` warnings: ${model.inventory.warnings.length}`);
75
77
  }
76
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
+ if (model.sourceFacts.filesWithDiagnosticsCount > 0) {
87
+ lines.push(` files with a per-file diagnostic: ${model.sourceFacts.filesWithDiagnosticsCount}`);
88
+ }
89
+ lines.push("");
90
+ // v0.3.2 Batch 3 -- informational only. Presence booleans and a
91
+ // best-effort project name/pytest-configuration flag, never a
92
+ // release-readiness or dependency-health signal -- see
93
+ // pythonProjectMetadata.ts's own header comment.
94
+ lines.push("Python project metadata");
95
+ lines.push(divider("-"));
96
+ const pm = model.pythonProjectMetadata;
97
+ lines.push(` pyproject.toml=${pm.hasPyprojectToml} requirements.txt=${pm.hasRequirementsTxt} setup.py=${pm.hasSetupPy} setup.cfg=${pm.hasSetupCfg} tox.ini=${pm.hasToxIni} pytest.ini=${pm.hasPytestIni}`);
98
+ lines.push(` project name detected: ${pm.projectName ?? "(none)"}`);
99
+ lines.push(` pytest configuration detected: ${pm.hasPytestConfiguration}`);
100
+ lines.push("");
77
101
  lines.push("Source-of-truth summary");
78
102
  lines.push(divider("-"));
79
103
  lines.push(` package: ${model.sourceOfTruth.packageName ?? "(none)"}@${model.sourceOfTruth.packageVersion ?? "?"}`);
@@ -83,6 +107,32 @@ export function renderAuditTextReport(model) {
83
107
  lines.push(` warnings: ${model.sourceOfTruth.warnings.length}`);
84
108
  }
85
109
  lines.push("");
110
+ // v0.3.2 Batch 4 -- bounded summary only. Never dumps raw scanner
111
+ // output/full finding text here (that lives in the original security
112
+ // validation report at securitySummary.reportPaths) -- this section only
113
+ // links to it and shows counts, matching the same "reference, don't
114
+ // duplicate" convention docs/security section 5 of the task spec asks for.
115
+ lines.push("Security validation summary");
116
+ lines.push(divider("-"));
117
+ if (!model.securitySummary.ran) {
118
+ lines.push(" (not run -- add \"security\" to --types to include it, e.g. --types code-rot,security)");
119
+ }
120
+ else {
121
+ const sec = model.securitySummary;
122
+ lines.push(` Verdict: ${sec.verdictLabel ?? "(unknown)"}`);
123
+ lines.push(` Checks: total=${sec.totalChecks} passed=${sec.checksPassed} warning=${sec.checksWarning} failed=${sec.checksFailed} skipped=${sec.checksSkipped}`);
124
+ lines.push(` Findings: blocker=${sec.findingCounts.blocker} major=${sec.findingCounts.major} minor=${sec.findingCounts.minor} informational=${sec.findingCounts.informational} (mapped to ${sec.mappedIssueCount} audit issue(s))`);
125
+ if (sec.targetDescription) {
126
+ lines.push(` Target: ${sanitizeLine(sec.targetDescription)}`);
127
+ }
128
+ if (sec.recommendedNextStep) {
129
+ lines.push(` Next step: ${sanitizeLine(sec.recommendedNextStep)}`);
130
+ }
131
+ if (sec.reportPaths.text || sec.reportPaths.json) {
132
+ lines.push(` Full report: ${sec.reportPaths.text ?? "(none)"} / ${sec.reportPaths.json ?? "(none)"}`);
133
+ }
134
+ }
135
+ lines.push("");
86
136
  lines.push("Issue summary");
87
137
  lines.push(divider("-"));
88
138
  lines.push(` Total issues: ${model.summary.totalIssues}`);
@@ -153,12 +203,18 @@ function renderIssueBlock(issue) {
153
203
  lines.push(` severity=${issue.severity} confidence=${issue.confidence} category=${sanitizeLine(issue.category)}`);
154
204
  lines.push(` detector=${issue.detectorId} id=${issue.id}`);
155
205
  for (const evidence of issue.evidence.slice(0, MAX_EVIDENCE_PER_ISSUE)) {
206
+ // v0.3.1 Batch 5 -- an evidence entry's `message` (the human-readable
207
+ // "why") and `excerpt` (a quoted snippet/reference list) are
208
+ // independent fields on the same entry -- render both when both are
209
+ // present. Previously an `excerpt` silently suppressed `message`
210
+ // entirely, which dropped the only readable explanation for
211
+ // source-facts-derived findings (e.g. duplicateImplementationDetector's
212
+ // "Source facts: an exported ... was parsed in N distinct files.") and
213
+ // left only a bare file-path excerpt in text output.
214
+ lines.push(` > ${sanitizeLine(evidence.message)}`);
156
215
  if (evidence.excerpt) {
157
216
  lines.push(...renderQuotedExcerpt(evidence.excerpt, " "));
158
217
  }
159
- else {
160
- lines.push(` > ${sanitizeLine(evidence.message)}`);
161
- }
162
218
  }
163
219
  lines.push(` recommendedAction: ${sanitizeLine(issue.recommendedAction)}`);
164
220
  return lines;
@@ -0,0 +1,70 @@
1
+ import { makeAuditEvidence } from "../core/auditIssue.js";
2
+ // ---------------------------------------------------------------------------
3
+ // v0.3.2 Batch 4 — maps a structured SecurityFinding (already produced by the
4
+ // existing securityValidation framework — see src/securityValidation/types.ts)
5
+ // into an AuditIssue. Pure, no I/O, no re-running of any check: this module
6
+ // only translates one already-computed result shape into another. Skipped
7
+ // optional checks never reach this function at all (see
8
+ // securityAuditAdapter.ts) — only findings attached to executed checks are
9
+ // mapped, so "skipped" never silently becomes an issue or a passed result.
10
+ // ---------------------------------------------------------------------------
11
+ export const SECURITY_AUDIT_DETECTOR_ID = "security-validation-adapter";
12
+ // SecuritySeverity -> AuditSeverity. Conservative mapping: "major" (a
13
+ // mandatory-check-adjacent severity in the security-validation verdict
14
+ // policy, see verdict.ts's calculateVerdict) maps to "high", never "medium",
15
+ // so a security major finding is never understated relative to a code-rot
16
+ // "high". "skipped" is not a real finding severity in practice (skippedCheck()
17
+ // in runSecurityValidation.ts sets it on the SecurityCheckResult, not on a
18
+ // SecurityFinding) but is mapped defensively to "info" for type completeness.
19
+ const SEVERITY_MAP = {
20
+ blocker: "blocker",
21
+ major: "high",
22
+ minor: "medium",
23
+ informational: "info",
24
+ skipped: "info",
25
+ };
26
+ // Security findings originate from already-executed, structured scanners/
27
+ // checks (npm audit, CodeQL, Semgrep, package-content inspection) rather than
28
+ // from a per-finding confidence value — SecurityFinding carries no confidence
29
+ // field of its own. "medium" is a conservative default that neither
30
+ // overstates a scanner's certainty (npm audit CVE matches can be version-range
31
+ // false positives) nor discounts it as unreliable.
32
+ const DEFAULT_CONFIDENCE = "medium";
33
+ const DEFAULT_FALSE_POSITIVE_RISK = "medium";
34
+ export function mapSecurityFindingToAuditIssue(finding) {
35
+ const severity = SEVERITY_MAP[finding.severity];
36
+ // Mirrors verdict.ts's calculateVerdict: blocker and major findings are
37
+ // what drive a "not-ready-security-blocker-remains" verdict — minor/
38
+ // informational findings never block release or implementation on their own.
39
+ const isBlockingSeverity = finding.severity === "blocker" || finding.severity === "major";
40
+ const evidence = finding.evidence
41
+ ? [
42
+ makeAuditEvidence({
43
+ kind: "observation",
44
+ message: `Security validation (${finding.category}): ${finding.title}`,
45
+ excerpt: finding.evidence,
46
+ source: `security-validation:${finding.category}`,
47
+ confidence: DEFAULT_CONFIDENCE,
48
+ }),
49
+ ]
50
+ : [];
51
+ return {
52
+ id: `security:${finding.id}`,
53
+ auditType: "security",
54
+ detectorId: SECURITY_AUDIT_DETECTOR_ID,
55
+ title: finding.title,
56
+ description: finding.description,
57
+ severity,
58
+ confidence: DEFAULT_CONFIDENCE,
59
+ falsePositiveRisk: DEFAULT_FALSE_POSITIVE_RISK,
60
+ category: finding.category,
61
+ evidence,
62
+ affectedFiles: finding.affectedFiles ?? [],
63
+ recommendedAction: finding.recommendation ?? "Review the security validation report for remediation guidance.",
64
+ suggestedFixStrategy: finding.recommendation ?? "See the linked security validation report for details.",
65
+ validationCommands: ["npm run security:validate"],
66
+ releaseBlocking: isBlockingSeverity,
67
+ implementationBlocking: isBlockingSeverity,
68
+ autoFixEligible: false,
69
+ };
70
+ }
@@ -0,0 +1,67 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { runSecurityValidation } from "../../securityValidation/validate/runSecurityValidation.js";
4
+ import { reportFilenamePrefix } from "../../securityValidation/validate/resolveTarget.js";
5
+ import { renderJsonReport, renderTextReport } from "../../securityValidation/report/renderSecurityReport.js";
6
+ import { buildSecurityReportFromSummary } from "../../securityValidation/report/buildSecurityReport.js";
7
+ import { DEFAULT_SECURITY_CHECKS, DEFAULT_SECURITY_PROFILE } from "../../securityValidation/validate/cliOptions.js";
8
+ import { verdictToHumanLabel } from "../../securityValidation/validate/verdict.js";
9
+ import { mapSecurityFindingToAuditIssue } from "./mapSecurityFindingToAuditIssue.js";
10
+ export async function runSecurityAuditAdapter(options) {
11
+ const { toolRoot, config } = options;
12
+ const summary = await runSecurityValidation({
13
+ cwd: toolRoot,
14
+ targetPath: config.targetPathArg,
15
+ });
16
+ // Only real findings on executed checks become AuditIssue entries. A
17
+ // skipped optional tool never has findings attached to it (see
18
+ // skippedCheck() in runSecurityValidation.ts), so it can never silently
19
+ // masquerade as an issue or as a passed check here.
20
+ const issues = summary.findings.map(mapSecurityFindingToAuditIssue);
21
+ const report = buildSecurityReportFromSummary(summary, {
22
+ profile: DEFAULT_SECURITY_PROFILE,
23
+ selectedChecks: [...DEFAULT_SECURITY_CHECKS],
24
+ formats: ["text", "json"],
25
+ });
26
+ // Always under my-dev-kit-lab's own reports/ tree (toolRoot), never under
27
+ // the audited target's root — matches the target-safety guarantee
28
+ // AuditTarget.safeReportOutputRoot already documents for code-rot reports.
29
+ const reportDir = path.join(toolRoot, "reports", "security");
30
+ if (!fs.existsSync(reportDir)) {
31
+ fs.mkdirSync(reportDir, { recursive: true });
32
+ }
33
+ const prefix = reportFilenamePrefix({
34
+ isSelf: summary.isSelf,
35
+ packageName: summary.packageName,
36
+ packageVersion: summary.packageVersion,
37
+ targetRoot: summary.targetRoot,
38
+ });
39
+ const textPath = path.join(reportDir, `${prefix}-security-validation.txt`);
40
+ const jsonPath = path.join(reportDir, `${prefix}-security-validation.json`);
41
+ fs.writeFileSync(textPath, renderTextReport(report), "utf8");
42
+ fs.writeFileSync(jsonPath, renderJsonReport(report), "utf8");
43
+ const findingCounts = {
44
+ blocker: summary.findings.filter((f) => f.severity === "blocker").length,
45
+ major: summary.findings.filter((f) => f.severity === "major").length,
46
+ minor: summary.findings.filter((f) => f.severity === "minor").length,
47
+ informational: summary.findings.filter((f) => f.severity === "informational").length,
48
+ };
49
+ const securitySummary = {
50
+ ran: true,
51
+ verdict: summary.verdict,
52
+ verdictLabel: verdictToHumanLabel(summary.verdict),
53
+ recommendedNextStep: summary.recommendedNextStep,
54
+ targetDescription: summary.targetDescription,
55
+ isSelf: summary.isSelf,
56
+ totalChecks: summary.checks.length,
57
+ checksPassed: summary.checks.filter((c) => c.status === "passed").length,
58
+ checksWarning: summary.checks.filter((c) => c.status === "warning").length,
59
+ checksFailed: summary.checks.filter((c) => c.status === "failed").length,
60
+ checksSkipped: summary.checks.filter((c) => c.status === "skipped").length,
61
+ findingCounts,
62
+ mappedIssueCount: issues.length,
63
+ verdictReasonSummary: summary.verdictReasonSummary ?? null,
64
+ reportPaths: { text: textPath, json: jsonPath },
65
+ };
66
+ return { issues, summary: securitySummary };
67
+ }
@@ -0,0 +1,17 @@
1
+ export const SECURITY_AUDIT_NOT_RUN_SUMMARY = {
2
+ ran: false,
3
+ verdict: null,
4
+ verdictLabel: null,
5
+ recommendedNextStep: null,
6
+ targetDescription: null,
7
+ isSelf: null,
8
+ totalChecks: 0,
9
+ checksPassed: 0,
10
+ checksWarning: 0,
11
+ checksFailed: 0,
12
+ checksSkipped: 0,
13
+ findingCounts: { blocker: 0, major: 0, minor: 0, informational: 0 },
14
+ mappedIssueCount: 0,
15
+ verdictReasonSummary: null,
16
+ reportPaths: { text: null, json: null },
17
+ };
@@ -0,0 +1,31 @@
1
+ export function buildSecurityReportFromSummary(summary, options = {}) {
2
+ return {
3
+ metadata: {
4
+ toolRoot: summary.toolRoot,
5
+ toolPackageName: summary.toolPackageName,
6
+ toolPackageVersion: summary.toolPackageVersion,
7
+ targetRoot: summary.targetRoot,
8
+ targetDescription: summary.targetDescription,
9
+ packageName: summary.packageName,
10
+ packageVersion: summary.packageVersion,
11
+ branch: summary.auditedBranch,
12
+ commit: summary.auditedCommit,
13
+ isSelf: summary.isSelf,
14
+ generatedAt: summary.finishedAt,
15
+ totalDurationMs: new Date(summary.finishedAt).getTime() - new Date(summary.startedAt).getTime(),
16
+ profile: options.profile,
17
+ selectedChecks: options.selectedChecks,
18
+ failOnThreshold: options.failOnThreshold,
19
+ formats: options.formats,
20
+ failOnBreached: options.failOnBreached,
21
+ isFullReleaseGate: summary.isFullReleaseGate,
22
+ },
23
+ sections: [],
24
+ allChecks: summary.checks,
25
+ allFindings: summary.findings,
26
+ verdict: summary.verdict,
27
+ recommendedNextStep: summary.recommendedNextStep,
28
+ attackResults: summary.attackResults,
29
+ verdictReasonSummary: summary.verdictReasonSummary,
30
+ };
31
+ }
@@ -8,6 +8,15 @@ export function resolveValidationTarget(targetPathArg, toolRoot) {
8
8
  // Returns the prefix to use for report filenames based on target metadata.
9
9
  // For self-validation: "v<version>" (preserves existing behavior).
10
10
  // For external targets: "<sanitized-name>-v<version>" or "<sanitized-dirname>".
11
+ //
12
+ // v0.3.2 Batch 4 — the parameter type is deliberately narrowed to a Pick of
13
+ // SecurityValidationTarget (rather than the full type) so the audit security
14
+ // adapter (src/audits/security/securityAuditAdapter.ts) can call this with a
15
+ // plain object built from a SecurityValidationSummary (which carries these
16
+ // same four fields but isn't itself a SecurityValidationTarget) instead of
17
+ // re-resolving the target a second time. Every existing caller already passes
18
+ // a full SecurityValidationTarget, which satisfies this narrower type
19
+ // unchanged.
11
20
  export function reportFilenamePrefix(target) {
12
21
  if (target.isSelf && target.packageVersion) {
13
22
  return `v${target.packageVersion}`;