@dailephd/my-dev-kit-lab 0.3.1 → 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.
@@ -356,7 +356,7 @@ function classifyFile(relativePath, basename, extension) {
356
356
  if (relativePath.startsWith(".github/workflows/") && (extension === ".yml" || extension === ".yaml")) {
357
357
  return "ci";
358
358
  }
359
- if (isTestFile(relativePath, basename))
359
+ if (isTestFile(relativePath, basename, extension))
360
360
  return "tests";
361
361
  // Top-level scripts/ directory -- classified before generic "source" so
362
362
  // scripts (which are also .ts/.js) land in their own category.
@@ -374,11 +374,37 @@ function classifyFile(relativePath, basename, extension) {
374
374
  return "source";
375
375
  return "unknown";
376
376
  }
377
- function isTestFile(relativePath, basename) {
377
+ // v0.3.2 Batch 1 -- conservative pytest naming conventions. Gated to .py so
378
+ // this can never reclassify a non-Python file. "tests/**/*.py" and
379
+ // "**/tests/**/*.py" are already covered by the generic, extension-agnostic
380
+ // check above; only the singular "test/" directory and the test_*.py /
381
+ // *_test.py filename conventions are Python-specific additions here.
382
+ function isPythonTestFile(relativePath, basename) {
383
+ if (relativePath.startsWith("test/") || relativePath.includes("/test/"))
384
+ return true;
385
+ return /^test_.*\.py$/.test(basename) || /^.*_test\.py$/.test(basename);
386
+ }
387
+ function isTestFile(relativePath, basename, extension) {
378
388
  if (relativePath.startsWith("tests/") || relativePath.includes("/tests/"))
379
389
  return true;
380
- return /\.(test|spec)\.[jt]sx?$/.test(basename);
390
+ if (/\.(test|spec)\.[jt]sx?$/.test(basename))
391
+ return true;
392
+ if (extension === ".py" && isPythonTestFile(relativePath, basename))
393
+ return true;
394
+ return false;
381
395
  }
396
+ // v0.3.2 Batch 1 -- recognized Python project/config metadata filenames.
397
+ // Matched by basename only (same convention as PACKAGE_FILE_NAMES above),
398
+ // not restricted to root, since these are specific enough not to collide
399
+ // with unrelated fixture/benchmark content. Deliberately narrow: does not
400
+ // match arbitrary *.toml/*.ini/*.txt files, only these exact names.
401
+ const PYTHON_PROJECT_METADATA_FILE_NAMES = new Set([
402
+ "pyproject.toml",
403
+ "requirements.txt",
404
+ "setup.cfg",
405
+ "tox.ini",
406
+ "pytest.ini",
407
+ ]);
382
408
  function isConfigFile(relativePath, basename, extension) {
383
409
  if (/^tsconfig(\..+)?\.json$/.test(basename))
384
410
  return true;
@@ -388,6 +414,12 @@ function isConfigFile(relativePath, basename, extension) {
388
414
  return true;
389
415
  if (basename === ".editorconfig" || basename === ".gitignore" || basename === ".npmignore")
390
416
  return true;
417
+ // v0.3.2 Batch 1 -- setup.py has a .py extension and would otherwise fall
418
+ // through to the generic KNOWN_SOURCE_EXTENSIONS "source" classification
419
+ // below; the other names have no source-extension conflict but are listed
420
+ // here for the same "project metadata, not source" reasoning.
421
+ if (basename === "setup.py" || PYTHON_PROJECT_METADATA_FILE_NAMES.has(basename))
422
+ return true;
391
423
  // Root-level dotfile configs (e.g. .prettierrc, .babelrc) not already
392
424
  // covered above -- deliberately conservative (root-only, not recursive)
393
425
  // to avoid misclassifying dotfiles inside fixture/benchmark projects.
@@ -0,0 +1,328 @@
1
+ // ---------------------------------------------------------------------------
2
+ // v0.3.2 Batch 1 -- conservative, dependency-free Python source analyzer.
3
+ //
4
+ // There is no Python parser available in this Node/TypeScript project, and
5
+ // per this batch's own scope this analyzer must not add a new parser
6
+ // dependency. Facts are therefore extracted with a deliberately narrow,
7
+ // line-oriented regex scan -- not a real tokenizer, not an AST, and not a
8
+ // syntax validator. This is the single-file, best-effort analogue of
9
+ // typescriptJavaScriptAnalyzer.ts's AST walk: same LanguageAnalyzer contract,
10
+ // same SourceFileFacts shape, same size-bounding philosophy, but confidence
11
+ // is necessarily lower since nothing here proves the file is syntactically
12
+ // valid Python.
13
+ //
14
+ // What this DOES extract (see requirements 3 of the batch spec):
15
+ // - import module[, module2][ as alias]
16
+ // - from [.[.]]module import name[ as alias][, ...] (single physical line)
17
+ // - from module import *
18
+ // - module-level `def`/`async def`/`class` declarations
19
+ // - class-body method declarations, via a simple indentation-based scope
20
+ // stack (not scope-accurate for deeply nested blocks, just "is this def
21
+ // more indented than the nearest enclosing class line")
22
+ // - a single-line `__all__ = [...]`/`(...)` string list, used to set
23
+ // `exported` precisely when present
24
+ //
25
+ // What this explicitly does NOT do (see requirements 3's "do not implement"
26
+ // list): type checking, full AST/semantic resolution, virtualenv/dependency
27
+ // resolution, runtime or dynamic import resolution, decorator/metaclass/
28
+ // fixture semantics, or any claim of dead-code/duplicate-implementation
29
+ // proof. `__import__(...)`/`importlib.import_module(...)` calls are recorded
30
+ // only as an informational diagnostic, never as a resolved import fact.
31
+ // ---------------------------------------------------------------------------
32
+ export const PYTHON_ANALYZER_ID = "python-analyzer";
33
+ const SUPPORTED_EXTENSIONS = [".py"];
34
+ // Mirrors typescriptJavaScriptAnalyzer.ts's MAX_ANALYZABLE_BYTES bound and
35
+ // reasoning -- a large file is not worth line-scanning, and stays at
36
+ // file-level-only rather than file-level-only masquerading as parsed.
37
+ const MAX_ANALYZABLE_BYTES = 1_000_000;
38
+ const IMPORT_LINE_PATTERN = /^\s*import\s+(.+)$/;
39
+ const FROM_IMPORT_LINE_PATTERN = /^\s*from\s+(\.{0,2}[\w.]*)\s+import\s+(.+)$/;
40
+ const DEF_LINE_PATTERN = /^(\s*)(?:async\s+def|def)\s+([A-Za-z_]\w*)\s*\(/;
41
+ const CLASS_LINE_PATTERN = /^(\s*)class\s+([A-Za-z_]\w*)\s*[:(]/;
42
+ const ALL_ASSIGNMENT_PATTERN = /^\s*__all__\s*=\s*[[(](.*)$/;
43
+ const DYNAMIC_IMPORT_PATTERN = /\b(?:__import__\s*\(|importlib\.import_module\s*\()/;
44
+ const QUOTED_NAME_PATTERN = /(['"])((?:(?!\1).)*)\1/g;
45
+ export const PYTHON_ANALYZER = {
46
+ id: PYTHON_ANALYZER_ID,
47
+ supportedLanguages: ["python"],
48
+ supportedExtensions: SUPPORTED_EXTENSIONS,
49
+ analyzeFile,
50
+ };
51
+ function analyzeFile(input) {
52
+ const { relativePath, language, role, content, inventoryEntry } = input;
53
+ if (Buffer.byteLength(content, "utf8") > MAX_ANALYZABLE_BYTES) {
54
+ return {
55
+ relativePath,
56
+ language,
57
+ role,
58
+ parseStatus: "file-level-only",
59
+ analyzerId: PYTHON_ANALYZER_ID,
60
+ lineCount: inventoryEntry.lineCount,
61
+ imports: [],
62
+ exports: [],
63
+ declarations: [],
64
+ references: [],
65
+ diagnostics: [
66
+ {
67
+ severity: "info",
68
+ code: "file-too-large-for-analysis",
69
+ message: `File exceeds the ${MAX_ANALYZABLE_BYTES}-byte analyzer bound; only inventory-level facts are available.`,
70
+ path: relativePath,
71
+ analyzerId: PYTHON_ANALYZER_ID,
72
+ },
73
+ ],
74
+ };
75
+ }
76
+ const diagnostics = [];
77
+ const { logical, hadUnterminatedTripleQuote } = buildLogicalLines(content);
78
+ const imports = [];
79
+ for (let i = 0; i < logical.length; i++) {
80
+ const line = logical[i];
81
+ const lineNumber = i + 1;
82
+ if (parseFromImportLine(line, lineNumber, imports, diagnostics, relativePath))
83
+ continue;
84
+ parseImportLine(line, lineNumber, imports);
85
+ }
86
+ const exportedSet = scanAllExport(logical);
87
+ const declarations = scanDeclarations(logical, exportedSet);
88
+ scanDynamicImportPatterns(logical, diagnostics, relativePath);
89
+ const exports = exportedSet ? [...exportedSet].map((name) => ({ name, kind: "named" })) : [];
90
+ if (hadUnterminatedTripleQuote) {
91
+ diagnostics.push({
92
+ severity: "error",
93
+ code: "unterminated-triple-quoted-string",
94
+ message: "File appears to contain an unterminated triple-quoted string (unbalanced \"\"\"/''' delimiters); facts from this best-effort scan may be incomplete.",
95
+ path: relativePath,
96
+ analyzerId: PYTHON_ANALYZER_ID,
97
+ });
98
+ }
99
+ const parseStatus = hadUnterminatedTripleQuote ? "parse-error" : "parsed";
100
+ return {
101
+ relativePath,
102
+ language,
103
+ role,
104
+ parseStatus,
105
+ analyzerId: PYTHON_ANALYZER_ID,
106
+ lineCount: inventoryEntry.lineCount,
107
+ imports,
108
+ exports,
109
+ declarations,
110
+ references: [],
111
+ diagnostics,
112
+ };
113
+ }
114
+ // ---------------------------------------------------------------------------
115
+ // Comment/triple-quoted-string stripping (heuristic, not a tokenizer).
116
+ // ---------------------------------------------------------------------------
117
+ // Strips a trailing `# comment`, respecting simple single/double-quoted
118
+ // strings on the same line (so `x = "a # b"` is not truncated at the `#`
119
+ // inside the string). Does not understand triple-quoted strings -- those are
120
+ // handled one level up by buildLogicalLines(), which never passes a
121
+ // mid-triple-quote-block line to this function.
122
+ function stripLineComment(line) {
123
+ let inSingle = false;
124
+ let inDouble = false;
125
+ for (let i = 0; i < line.length; i++) {
126
+ const ch = line[i];
127
+ if (ch === "\\" && (inSingle || inDouble)) {
128
+ i++;
129
+ continue;
130
+ }
131
+ if (ch === "'" && !inDouble)
132
+ inSingle = !inSingle;
133
+ else if (ch === '"' && !inSingle)
134
+ inDouble = !inDouble;
135
+ else if (ch === "#" && !inSingle && !inDouble)
136
+ return line.slice(0, i);
137
+ }
138
+ return line;
139
+ }
140
+ // Produces one "logical" line per physical line: comments stripped, and any
141
+ // content inside a triple-quoted string block (`"""..."""` / `'''...'''`)
142
+ // blanked out so it can never be misread as an import/def/class statement.
143
+ // Deliberately narrow: only tracks a single open triple-quote marker at a
144
+ // time and does not handle more than one triple-quoted segment opening and
145
+ // closing on the same physical line.
146
+ function buildLogicalLines(content) {
147
+ const rawLines = content.split(/\r\n|\r|\n/);
148
+ const logical = [];
149
+ let openQuote = null;
150
+ for (const rawLine of rawLines) {
151
+ if (openQuote) {
152
+ const closeIdx = rawLine.indexOf(openQuote);
153
+ if (closeIdx === -1) {
154
+ logical.push("");
155
+ continue;
156
+ }
157
+ openQuote = null;
158
+ logical.push(stripLineComment(rawLine.slice(closeIdx + 3)));
159
+ continue;
160
+ }
161
+ const stripped = stripLineComment(rawLine);
162
+ const tripleMatch = stripped.match(/"""|'''/);
163
+ if (!tripleMatch) {
164
+ logical.push(stripped);
165
+ continue;
166
+ }
167
+ const marker = tripleMatch[0];
168
+ const firstIdx = stripped.indexOf(marker);
169
+ const secondIdx = stripped.indexOf(marker, firstIdx + marker.length);
170
+ if (secondIdx === -1) {
171
+ logical.push(stripped.slice(0, firstIdx));
172
+ openQuote = marker;
173
+ }
174
+ else {
175
+ logical.push(stripped.slice(0, firstIdx) + stripped.slice(secondIdx + marker.length));
176
+ }
177
+ }
178
+ return { logical, hadUnterminatedTripleQuote: openQuote !== null };
179
+ }
180
+ function leadingWhitespaceCount(line) {
181
+ let count = 0;
182
+ for (const ch of line) {
183
+ if (ch === " " || ch === "\t")
184
+ count += 1;
185
+ else
186
+ break;
187
+ }
188
+ return count;
189
+ }
190
+ // ---------------------------------------------------------------------------
191
+ // Imports.
192
+ // ---------------------------------------------------------------------------
193
+ function parseImportLine(line, lineNumber, imports) {
194
+ const match = line.match(IMPORT_LINE_PATTERN);
195
+ if (!match)
196
+ return;
197
+ for (const rawSegment of match[1].split(",")) {
198
+ const segment = rawSegment.trim();
199
+ if (!segment)
200
+ continue;
201
+ const asMatch = segment.match(/^([\w.]+)\s+as\s+(\w+)$/);
202
+ if (asMatch) {
203
+ imports.push({ source: asMatch[1], kind: "static", importedNames: [asMatch[2]], line: lineNumber });
204
+ }
205
+ else {
206
+ imports.push({ source: segment, kind: "static", line: lineNumber });
207
+ }
208
+ }
209
+ }
210
+ function parseFromImportLine(line, lineNumber, imports, diagnostics, relativePath) {
211
+ const match = line.match(FROM_IMPORT_LINE_PATTERN);
212
+ if (!match)
213
+ return false;
214
+ const modulePath = match[1] || ".";
215
+ let namesPart = match[2].trim();
216
+ if (namesPart.startsWith("(") && namesPart.endsWith(")")) {
217
+ namesPart = namesPart.slice(1, -1);
218
+ }
219
+ else if (namesPart.startsWith("(")) {
220
+ // Multi-line parenthesized from-import list -- this single-line scanner
221
+ // does not follow continuation lines. Recorded as a diagnostic rather
222
+ // than silently dropped or guessed at.
223
+ diagnostics.push({
224
+ severity: "info",
225
+ code: "multiline-from-import-unsupported",
226
+ message: "Multi-line parenthesized from-import list is not analyzed by this conservative single-line scanner.",
227
+ path: relativePath,
228
+ analyzerId: PYTHON_ANALYZER_ID,
229
+ });
230
+ return true;
231
+ }
232
+ if (namesPart === "*") {
233
+ imports.push({ source: modulePath, kind: "static", line: lineNumber });
234
+ return true;
235
+ }
236
+ const importedNames = [];
237
+ for (const rawSegment of namesPart.split(",")) {
238
+ const segment = rawSegment.trim();
239
+ if (!segment)
240
+ continue;
241
+ const asMatch = segment.match(/^(\w+)\s+as\s+(\w+)$/);
242
+ importedNames.push(asMatch ? asMatch[2] : segment);
243
+ }
244
+ imports.push({
245
+ source: modulePath,
246
+ kind: "static",
247
+ line: lineNumber,
248
+ ...(importedNames.length > 0 ? { importedNames } : {}),
249
+ });
250
+ return true;
251
+ }
252
+ // ---------------------------------------------------------------------------
253
+ // __all__ (optional, single-line only).
254
+ // ---------------------------------------------------------------------------
255
+ function scanAllExport(logical) {
256
+ for (const line of logical) {
257
+ const match = line.match(ALL_ASSIGNMENT_PATTERN);
258
+ if (!match)
259
+ continue;
260
+ const names = [...match[1].matchAll(QUOTED_NAME_PATTERN)].map((m) => m[2]);
261
+ if (names.length > 0)
262
+ return new Set(names);
263
+ }
264
+ return null;
265
+ }
266
+ function isExported(name, exportedSet) {
267
+ if (exportedSet)
268
+ return exportedSet.has(name);
269
+ // Conservative convention-based heuristic when no __all__ is present: a
270
+ // leading underscore is Python's own "not public" signal. This is a
271
+ // heuristic, not a semantic guarantee -- see module header comment.
272
+ return !name.startsWith("_");
273
+ }
274
+ // ---------------------------------------------------------------------------
275
+ // Declarations (functions, async functions, classes, class methods).
276
+ // ---------------------------------------------------------------------------
277
+ function scanDeclarations(logical, exportedSet) {
278
+ const declarations = [];
279
+ // Indent levels of currently-open `class` blocks, outermost first. A line
280
+ // whose indentation drops to or below the top of this stack has exited
281
+ // that class body.
282
+ const classIndentStack = [];
283
+ for (let i = 0; i < logical.length; i++) {
284
+ const line = logical[i];
285
+ if (line.trim().length === 0)
286
+ continue;
287
+ const lineNumber = i + 1;
288
+ const indent = leadingWhitespaceCount(line);
289
+ while (classIndentStack.length > 0 && indent <= classIndentStack[classIndentStack.length - 1]) {
290
+ classIndentStack.pop();
291
+ }
292
+ const classMatch = line.match(CLASS_LINE_PATTERN);
293
+ if (classMatch) {
294
+ const name = classMatch[2];
295
+ declarations.push({ name, kind: "class", exported: isExported(name, exportedSet), line: lineNumber });
296
+ classIndentStack.push(indent);
297
+ continue;
298
+ }
299
+ const defMatch = line.match(DEF_LINE_PATTERN);
300
+ if (defMatch) {
301
+ const name = defMatch[2];
302
+ const isMethod = classIndentStack.length > 0 && indent > classIndentStack[classIndentStack.length - 1];
303
+ declarations.push({
304
+ name,
305
+ kind: isMethod ? "method" : "function",
306
+ // Methods are not tracked against __all__/underscore-export
307
+ // semantics -- __all__ only ever names module-level symbols in
308
+ // real Python, so claiming a method is "exported" would overclaim.
309
+ exported: isMethod ? false : isExported(name, exportedSet),
310
+ line: lineNumber,
311
+ });
312
+ }
313
+ }
314
+ return declarations;
315
+ }
316
+ function scanDynamicImportPatterns(logical, diagnostics, relativePath) {
317
+ for (const line of logical) {
318
+ if (!DYNAMIC_IMPORT_PATTERN.test(line))
319
+ continue;
320
+ diagnostics.push({
321
+ severity: "info",
322
+ code: "dynamic-import-pattern-unsupported",
323
+ message: "Detected a __import__/importlib.import_module call; dynamic imports are not resolved as import facts.",
324
+ path: relativePath,
325
+ analyzerId: PYTHON_ANALYZER_ID,
326
+ });
327
+ }
328
+ }
@@ -0,0 +1,80 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ const PYPROJECT_PROJECT_NAME_PATTERN = /^\s*name\s*=\s*["']([^"']+)["']\s*$/m;
4
+ const PYPROJECT_PYTEST_SECTION_PATTERN = /^\s*\[tool\.pytest\.ini_options\]\s*$/m;
5
+ const SETUP_CFG_PYTEST_SECTION_PATTERN = /^\s*\[tool:pytest\]\s*$/m;
6
+ const INI_SECTION_HEADER_PATTERN = /^\s*\[([^\]]+)\]\s*$/;
7
+ const INI_NAME_KEY_PATTERN = /^\s*name\s*=\s*(.+?)\s*$/;
8
+ // Extracts the `name = ...` value from a setup.cfg-style [metadata] section
9
+ // via a simple line scan (no INI parser dependency) -- stops at the next
10
+ // "[section]" header or end of file.
11
+ function extractSetupCfgProjectName(content) {
12
+ const lines = content.split(/\r\n|\r|\n/);
13
+ let inMetadataSection = false;
14
+ for (const line of lines) {
15
+ const sectionMatch = line.match(INI_SECTION_HEADER_PATTERN);
16
+ if (sectionMatch) {
17
+ inMetadataSection = sectionMatch[1].trim().toLowerCase() === "metadata";
18
+ continue;
19
+ }
20
+ if (!inMetadataSection)
21
+ continue;
22
+ const nameMatch = line.match(INI_NAME_KEY_PATTERN);
23
+ if (nameMatch)
24
+ return nameMatch[1];
25
+ }
26
+ return null;
27
+ }
28
+ export function collectPythonProjectMetadata(targetRoot, inventory) {
29
+ const warnings = [];
30
+ const knownPaths = new Set(inventory.files.map((f) => f.relativePath));
31
+ const has = (name) => knownPaths.has(name);
32
+ const hasPyprojectToml = has("pyproject.toml");
33
+ const hasRequirementsTxt = has("requirements.txt");
34
+ const hasSetupPy = has("setup.py");
35
+ const hasSetupCfg = has("setup.cfg");
36
+ const hasToxIni = has("tox.ini");
37
+ const hasPytestIni = has("pytest.ini");
38
+ let projectName = null;
39
+ let pytestSectionDetected = false;
40
+ if (hasPyprojectToml) {
41
+ const content = readSafely(targetRoot, "pyproject.toml", warnings);
42
+ if (content !== null) {
43
+ const nameMatch = content.match(PYPROJECT_PROJECT_NAME_PATTERN);
44
+ if (nameMatch)
45
+ projectName = nameMatch[1];
46
+ if (PYPROJECT_PYTEST_SECTION_PATTERN.test(content))
47
+ pytestSectionDetected = true;
48
+ }
49
+ }
50
+ if (hasSetupCfg) {
51
+ const content = readSafely(targetRoot, "setup.cfg", warnings);
52
+ if (content !== null) {
53
+ if (projectName === null) {
54
+ projectName = extractSetupCfgProjectName(content);
55
+ }
56
+ if (SETUP_CFG_PYTEST_SECTION_PATTERN.test(content))
57
+ pytestSectionDetected = true;
58
+ }
59
+ }
60
+ return {
61
+ hasPyprojectToml,
62
+ hasRequirementsTxt,
63
+ hasSetupPy,
64
+ hasSetupCfg,
65
+ hasToxIni,
66
+ hasPytestIni,
67
+ hasPytestConfiguration: hasPytestIni || pytestSectionDetected,
68
+ projectName,
69
+ warnings,
70
+ };
71
+ }
72
+ function readSafely(targetRoot, relativePath, warnings) {
73
+ try {
74
+ return fs.readFileSync(path.join(targetRoot, relativePath), "utf8");
75
+ }
76
+ catch (err) {
77
+ warnings.push(`Could not read ${relativePath}: ${err instanceof Error ? err.message : String(err)}`);
78
+ return null;
79
+ }
80
+ }
@@ -55,6 +55,7 @@ export function summarizeSourceFacts(sourceFacts) {
55
55
  filesByLanguage: sourceFacts.filesByLanguage,
56
56
  filesByParseStatus: sourceFacts.filesByParseStatus,
57
57
  analyzerDiagnosticCount: sourceFacts.analyzerDiagnostics.length,
58
+ filesWithDiagnosticsCount: sourceFacts.files.filter((f) => f.diagnostics.length > 0).length,
58
59
  warnings: sourceFacts.warnings,
59
60
  };
60
61
  }
@@ -188,6 +189,8 @@ export function buildAuditReportModel(result, opts) {
188
189
  inventory: summarizeInventory(result.inventory),
189
190
  sourceOfTruth: summarizeSourceOfTruth(result.sourceOfTruth),
190
191
  sourceFacts: summarizeSourceFacts(result.sourceFacts),
192
+ pythonProjectMetadata: result.pythonProjectMetadata,
193
+ securitySummary: result.securitySummary,
191
194
  detectors,
192
195
  issues,
193
196
  skippedDetectors: result.skippedDetectors,
@@ -19,6 +19,8 @@ export function renderAuditJsonReport(model) {
19
19
  inventory: model.inventory,
20
20
  sourceOfTruth: model.sourceOfTruth,
21
21
  sourceFacts: model.sourceFacts,
22
+ pythonProjectMetadata: model.pythonProjectMetadata,
23
+ securitySummary: model.securitySummary,
22
24
  detectors: model.detectors,
23
25
  issues: model.issues,
24
26
  skippedDetectors: model.skippedDetectors,
@@ -83,6 +83,20 @@ export function renderAuditTextReport(model) {
83
83
  if (model.sourceFacts.analyzerDiagnosticCount > 0) {
84
84
  lines.push(` analyzer diagnostics: ${model.sourceFacts.analyzerDiagnosticCount}`);
85
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}`);
86
100
  lines.push("");
87
101
  lines.push("Source-of-truth summary");
88
102
  lines.push(divider("-"));
@@ -93,6 +107,32 @@ export function renderAuditTextReport(model) {
93
107
  lines.push(` warnings: ${model.sourceOfTruth.warnings.length}`);
94
108
  }
95
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("");
96
136
  lines.push("Issue summary");
97
137
  lines.push(divider("-"));
98
138
  lines.push(` Total issues: ${model.summary.totalIssues}`);
@@ -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
+ }