secure-code-agent 0.12.12__tar.gz → 0.13.0__tar.gz

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 (58) hide show
  1. {secure_code_agent-0.12.12/src/secure_code_agent.egg-info → secure_code_agent-0.13.0}/PKG-INFO +2 -2
  2. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/README.md +1 -1
  3. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0/src/secure_code_agent.egg-info}/PKG-INFO +2 -2
  4. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/src/secure_code_audit/__init__.py +1 -1
  5. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/src/secure_code_audit/cli.py +29 -2
  6. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/src/secure_code_audit/git_tools.py +41 -0
  7. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/src/secure_code_audit/renderers.py +13 -0
  8. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/src/secure_code_audit/scanner_status.py +124 -0
  9. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/src/secure_code_audit/scoring.py +24 -3
  10. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/LICENSE +0 -0
  11. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/pyproject.toml +0 -0
  12. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/setup.cfg +0 -0
  13. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/src/secure_code_agent.egg-info/SOURCES.txt +0 -0
  14. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/src/secure_code_agent.egg-info/dependency_links.txt +0 -0
  15. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/src/secure_code_agent.egg-info/entry_points.txt +0 -0
  16. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/src/secure_code_agent.egg-info/requires.txt +0 -0
  17. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/src/secure_code_agent.egg-info/top_level.txt +0 -0
  18. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/src/secure_code_audit/baseline.py +0 -0
  19. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/src/secure_code_audit/capabilities.py +0 -0
  20. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/src/secure_code_audit/config.py +0 -0
  21. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/src/secure_code_audit/data/semgrep-offline.yaml +0 -0
  22. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/src/secure_code_audit/data/standards.yaml +0 -0
  23. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/src/secure_code_audit/demo.py +0 -0
  24. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/src/secure_code_audit/findings.py +0 -0
  25. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/src/secure_code_audit/history.py +0 -0
  26. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/src/secure_code_audit/instructions.py +0 -0
  27. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/src/secure_code_audit/mcp_server.py +0 -0
  28. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/src/secure_code_audit/pillar.py +0 -0
  29. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/src/secure_code_audit/practice.py +0 -0
  30. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/src/secure_code_audit/remediation.py +0 -0
  31. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/src/secure_code_audit/ruleset.py +0 -0
  32. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/src/secure_code_audit/sarif.py +0 -0
  33. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/src/secure_code_audit/scanners/__init__.py +0 -0
  34. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/src/secure_code_audit/scanners/_exclusions.py +0 -0
  35. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/src/secure_code_audit/scanners/_execution.py +0 -0
  36. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/src/secure_code_audit/scanners/_finding_builder.py +0 -0
  37. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/src/secure_code_audit/scanners/_resolution.py +0 -0
  38. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/src/secure_code_audit/scanners/bandit_scanner.py +0 -0
  39. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/src/secure_code_audit/scanners/base.py +0 -0
  40. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/src/secure_code_audit/scanners/builtin_rules.py +0 -0
  41. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/src/secure_code_audit/scanners/checkov_scanner.py +0 -0
  42. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/src/secure_code_audit/scanners/floor.py +0 -0
  43. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/src/secure_code_audit/scanners/gitleaks_scanner.py +0 -0
  44. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/src/secure_code_audit/scanners/gosec_scanner.py +0 -0
  45. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/src/secure_code_audit/scanners/hadolint_scanner.py +0 -0
  46. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/src/secure_code_audit/scanners/njsscan_scanner.py +0 -0
  47. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/src/secure_code_audit/scanners/npm_audit_scanner.py +0 -0
  48. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/src/secure_code_audit/scanners/osv_scanner.py +0 -0
  49. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/src/secure_code_audit/scanners/pip_audit_scanner.py +0 -0
  50. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/src/secure_code_audit/scanners/rubocop_scanner.py +0 -0
  51. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/src/secure_code_audit/scanners/scorecard_scanner.py +0 -0
  52. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/src/secure_code_audit/scanners/semgrep_scanner.py +0 -0
  53. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/src/secure_code_audit/scanners/trivy_scanner.py +0 -0
  54. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/src/secure_code_audit/scanners/trufflehog_scanner.py +0 -0
  55. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/src/secure_code_audit/standards.py +0 -0
  56. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/src/secure_code_audit/suppressions.py +0 -0
  57. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/src/secure_code_audit/triage.py +0 -0
  58. {secure_code_agent-0.12.12 → secure_code_agent-0.13.0}/src/secure_code_audit/verify.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: secure-code-agent
3
- Version: 0.12.12
3
+ Version: 0.13.0
4
4
  Summary: Deterministic security gate + bounded AI remediation prompt generator. NIST SSDF / OWASP ASVS / CWE Top 25 anchored.
5
5
  Author: Marshall Guillory
6
6
  License: MIT
@@ -554,7 +554,7 @@ For agents that support invokable skills, this repo ships a portable skill under
554
554
  ## GitHub Action
555
555
 
556
556
  ```yaml
557
- - uses: marshallguillory86/secure-code-agent@v0.12.12
557
+ - uses: marshallguillory86/secure-code-agent@v0.13.0
558
558
  with:
559
559
  config: secure-code-agent.json
560
560
  fail-on-gate: true
@@ -505,7 +505,7 @@ For agents that support invokable skills, this repo ships a portable skill under
505
505
  ## GitHub Action
506
506
 
507
507
  ```yaml
508
- - uses: marshallguillory86/secure-code-agent@v0.12.12
508
+ - uses: marshallguillory86/secure-code-agent@v0.13.0
509
509
  with:
510
510
  config: secure-code-agent.json
511
511
  fail-on-gate: true
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: secure-code-agent
3
- Version: 0.12.12
3
+ Version: 0.13.0
4
4
  Summary: Deterministic security gate + bounded AI remediation prompt generator. NIST SSDF / OWASP ASVS / CWE Top 25 anchored.
5
5
  Author: Marshall Guillory
6
6
  License: MIT
@@ -554,7 +554,7 @@ For agents that support invokable skills, this repo ships a portable skill under
554
554
  ## GitHub Action
555
555
 
556
556
  ```yaml
557
- - uses: marshallguillory86/secure-code-agent@v0.12.12
557
+ - uses: marshallguillory86/secure-code-agent@v0.13.0
558
558
  with:
559
559
  config: secure-code-agent.json
560
560
  fail-on-gate: true
@@ -12,4 +12,4 @@
12
12
  #:
13
13
  #: PyPI is immutable, so 0.4.0 stays wrong. 0.5.0 is the first build whose
14
14
  #: artifacts name their own producer correctly.
15
- __version__ = "0.12.12"
15
+ __version__ = "0.13.0"
@@ -44,13 +44,20 @@ from secure_code_audit.findings import (
44
44
  anchor,
45
45
  merge_corroborating,
46
46
  )
47
- from secure_code_audit.git_tools import find_repo_root, is_excluded, is_test_path, loc_under
47
+ from secure_code_audit.git_tools import (
48
+ extensions_under,
49
+ find_repo_root,
50
+ is_excluded,
51
+ is_test_path,
52
+ loc_under,
53
+ )
48
54
  from secure_code_audit.scanner_status import (
49
55
  COVERING_OUTCOMES,
50
56
  ScannerExecution,
51
57
  ScannerOutcome,
52
58
  evaluate_coverage,
53
59
  execution_from_result,
60
+ languages_for,
54
61
  )
55
62
  from secure_code_audit.scanners import floor
56
63
  from secure_code_audit.scoring import (
@@ -622,7 +629,27 @@ def _score_everything(
622
629
  ),
623
630
  *(execution.name for execution in imported_executions),
624
631
  ]
625
- coverage = evaluate_coverage(executions, required)
632
+ # The languages the *scored* tree actually contains, so COMPLETE can mean
633
+ # "this tree was covered" rather than "every configured scanner ran".
634
+ # `gin` — 7,146 lines of Go — reported COMPLETE and `code_vulnerabilities:
635
+ # 5.0` while bandit, njsscan and rubocop found nothing in Go source and
636
+ # gosec was not in the run. P7's own falsification condition.
637
+ #
638
+ # Scoped exactly as the scoring denominator is, which is why it is
639
+ # `extensions_under` and not `_repository_inventory`: the broad inventory
640
+ # answers "is there any Go here at all" for applicability, and a Go fixture
641
+ # under `tests/` must not oblige a Python project to install gosec.
642
+ primary_languages = languages_for(
643
+ extensions_under(
644
+ target,
645
+ cfg.include_extensions,
646
+ cfg.exclude_patterns,
647
+ cfg.test_patterns,
648
+ own_artifacts,
649
+ cfg.docs_patterns,
650
+ )
651
+ )
652
+ coverage = evaluate_coverage(executions, required, primary_languages)
626
653
  # Gates see the dependency advisories; the score does not.
627
654
  # The gate needs to know *why* the baseline is empty to describe a first
628
655
  # run truthfully. Passed in the config dict rather than as a parameter so
@@ -229,6 +229,47 @@ def loc_under(
229
229
  return primary, test, docs
230
230
 
231
231
 
232
+ def extensions_under(
233
+ root: Path,
234
+ include_exts: Iterable[str],
235
+ excludes: Iterable[str],
236
+ test_patterns: Iterable[str] = (),
237
+ skip: Iterable[Path] = (),
238
+ docs_patterns: Iterable[str] = (),
239
+ ) -> tuple[str, ...]:
240
+ """The file extensions of the **primary** tree, sorted.
241
+
242
+ The input to coverage's question "was this tree covered". Scoped exactly
243
+ as `loc_under`'s primary count is, and for the same reason: a claim about
244
+ coverage has to describe the tree the score describes.
245
+
246
+ Primary only. A Go fixture under `tests/` does not oblige a Python
247
+ project to install `gosec` — the test tree is reported and not scored, so
248
+ a language appearing only there cannot make the score unsupported.
249
+ """
250
+ test_patterns = tuple(test_patterns)
251
+ docs_patterns = tuple(docs_patterns)
252
+ skip = {path.resolve() for path in skip}
253
+ found: set[str] = set()
254
+ # `rglob` on a file yields nothing, the same case `loc_under` documents.
255
+ candidates = root.rglob("*") if root.is_dir() else [root]
256
+ for path in candidates:
257
+ if not path.is_file():
258
+ continue
259
+ if path.resolve() in skip:
260
+ continue
261
+ if is_excluded(path, root, excludes):
262
+ continue
263
+ if not in_scope(path, include_exts):
264
+ continue
265
+ if test_patterns and is_test_path(path, root, test_patterns):
266
+ continue
267
+ if docs_patterns and is_test_path(path, root, docs_patterns):
268
+ continue
269
+ found.add(path.suffix)
270
+ return tuple(sorted(found))
271
+
272
+
232
273
  def _git() -> str | None:
233
274
  """Resolve `git` to an absolute path, once.
234
275
 
@@ -137,6 +137,10 @@ def _coverage_to_dict(coverage: CoverageReport | None) -> dict | None:
137
137
  "required": list(coverage.required),
138
138
  "unverified": list(coverage.unverified),
139
139
  "failures": list(coverage.failures),
140
+ # Languages the scored tree contains that nothing in the run reads.
141
+ # Carried here because MA's security pillar and any other machine
142
+ # consumer needs to know the grade's breadth, not just its value.
143
+ "unread_languages": list(coverage.unread_languages),
140
144
  "scanners": [
141
145
  {
142
146
  "name": execution.name,
@@ -305,6 +309,13 @@ def _summary_section(
305
309
  out = ["## Summary", ""]
306
310
  if coverage is not None:
307
311
  out.append(f"- **Scanner coverage:** {coverage.status.value.upper()}")
312
+ # The language *and* the remedy, in the summary rather than only in
313
+ # the scanners section. "scanner coverage is partial" tells a reader
314
+ # that something is missing, not what — the same half-answer that
315
+ # said `did not complete: failed` while trivy's 429 and its fix sat
316
+ # one field away in the same record.
317
+ for entry in coverage.unread_languages:
318
+ out.append(f"- **Language not covered:** {entry}")
308
319
  # No verdict means nobody decided this run could claim a grade, so it
309
320
  # cannot. Falling back to a locally-invented condition here is what put a
310
321
  # second decision rule in the codebase in the first place.
@@ -421,6 +432,8 @@ def _scanners_section(
421
432
  out.append(f"- Coverage: **{coverage.status.value.upper()}**")
422
433
  if coverage.required:
423
434
  out.append(f"- Required: {', '.join(coverage.required)}")
435
+ for entry in coverage.unread_languages:
436
+ out.append(f"- Not read by any scanner that ran: {entry}")
424
437
  if coverage.unverified:
425
438
  out.append(
426
439
  f"- Unverified (imported artifact, execution not observed): "
@@ -91,6 +91,12 @@ class CoverageReport:
91
91
  #: than a process we watched. Reported everywhere so COMPLETE is never
92
92
  #: read as "we verified all of this ourselves".
93
93
  unverified: tuple[str, ...] = ()
94
+ #: Languages present in the tree that nothing in this run reads for code
95
+ #: vulnerabilities. Separate from `failures` on purpose: a required
96
+ #: scanner that did not run broke a promise the operator made and fails
97
+ #: the gate, while an unread language is a limit of the floor and
98
+ #: withholds the verified grade instead.
99
+ unread_languages: tuple[str, ...] = ()
94
100
 
95
101
 
96
102
  def execution_from_result(
@@ -150,10 +156,115 @@ def worst_by_name(
150
156
  return by_name
151
157
 
152
158
 
159
+ #: Which scanners in this floor actually read a language for *code* defects.
160
+ #:
161
+ #: **Verified by running them, not adopted from anywhere.** An earlier version
162
+ #: of this lifted `calibration/calibrate.py`'s `LANGUAGE_SCANNERS` wholesale
163
+ #: and was wrong, because that map answers a different question: the study
164
+ #: fixes its scanner set and uses "did the *dedicated* language scanner run"
165
+ #: as a proxy for coverage depth. Read as "what reads this language at all" it
166
+ #: asserts things that are false — that nothing reads Go without `gosec`
167
+ #: (semgrep's offline profile carries five Go rules, and they fire), and that
168
+ #: nothing reads Python without `bandit` (`builtin_rules` is Python-only and
169
+ #: found the findings in the very fixture that caught this).
170
+ #:
171
+ #: So this lists every scanner that genuinely reads the language, and a
172
+ #: language is flagged only when **nothing** read it. That is a weaker claim
173
+ #: than the study's and it is the one that is true.
174
+ #:
175
+ #: The depth question the study is really asking — "five generic rules is a
176
+ #: floor, not coverage" — is deliberately *not* answered here. `COMPLETE` and
177
+ #: `PARTIAL` cannot express it, and inventing a third state is a product
178
+ #: decision rather than a bug fix.
179
+ LANGUAGE_SCANNERS: dict[str, tuple[str, ...]] = {
180
+ # `builtin_rules` is Python-only; semgrep's offline profile adds two more.
181
+ "python": ("bandit", "semgrep", "builtin_rules"),
182
+ "javascript": ("njsscan", "semgrep"),
183
+ "typescript": ("njsscan", "semgrep"),
184
+ "ruby": ("rubocop", "semgrep"),
185
+ # No dedicated Go scanner in the floor, but the offline profile's five Go
186
+ # rules do read it: command injection, weak hash, weak cipher, TLS
187
+ # verification disabled, weak random. Verified firing on the shipped Go
188
+ # fixture.
189
+ "go": ("gosec", "semgrep"),
190
+ # Likewise Java: five offline rules. D12 is about PMD and SpotBugs not
191
+ # providing a *dedicated* scanner, which is a statement about depth.
192
+ "java": ("semgrep",),
193
+ # Nothing in the floor reads shell for code defects — no entry to name.
194
+ "shell": (),
195
+ }
196
+
197
+
198
+ #: File extension -> the language `LANGUAGE_SCANNERS` keys on.
199
+ #:
200
+ #: Only extensions whose language this floor claims to scan for *code*
201
+ #: defects. `.yaml`, `.json`, `Dockerfile` and `.md` are deliberately absent:
202
+ #: they are covered on other axes by checkov and trivy, and mapping them to a
203
+ #: language would make PARTIAL the permanent state of every repository.
204
+ _EXTENSION_LANGUAGE: dict[str, str] = {
205
+ ".py": "python",
206
+ ".js": "javascript",
207
+ ".mjs": "javascript",
208
+ ".cjs": "javascript",
209
+ ".jsx": "javascript",
210
+ ".ts": "typescript",
211
+ ".tsx": "typescript",
212
+ ".rb": "ruby",
213
+ ".go": "go",
214
+ ".java": "java",
215
+ ".sh": "shell",
216
+ ".bash": "shell",
217
+ ".zsh": "shell",
218
+ }
219
+
220
+
221
+ def languages_for(extensions: Iterable[str]) -> tuple[str, ...]:
222
+ """The code-vulnerability languages these extensions represent, sorted.
223
+
224
+ Deduplicated: `.js` and `.mjs` are both JavaScript and the result names it
225
+ once. An extension with no entry is dropped rather than guessed at.
226
+ """
227
+ return tuple(
228
+ sorted({_EXTENSION_LANGUAGE[ext] for ext in extensions if ext in _EXTENSION_LANGUAGE})
229
+ )
230
+
231
+
232
+ def _unread_languages(languages: Iterable[str], covered_by: set[str]) -> tuple[str, ...]:
233
+ """Languages present in the tree that no scanner in this run reads.
234
+
235
+ `gin` — 7,146 lines of Go — reported `coverage: complete` and
236
+ `code_vulnerabilities: 5.0` while bandit, njsscan and rubocop found
237
+ nothing in Go source and `gosec` was not in the run. That is P7's own
238
+ falsification condition, and COMPLETE was a fact about the scanner list
239
+ rather than about the tree.
240
+ """
241
+ out: list[str] = []
242
+ for language in dict.fromkeys(languages):
243
+ readers = LANGUAGE_SCANNERS.get(language)
244
+ if readers is None:
245
+ # Not a code-vulnerability language in this floor's terms.
246
+ continue
247
+ if any(name in covered_by for name in readers):
248
+ continue
249
+ if readers:
250
+ out.append(f"{language}: no scanner that reads it ran ({', '.join(readers)})")
251
+ else:
252
+ # Nothing exists to suggest, so do not invent a name.
253
+ out.append(f"{language}: no scanner in this floor reads it")
254
+ return tuple(out)
255
+
256
+
153
257
  def evaluate_coverage(
154
258
  executions: Iterable[ScannerExecution],
155
259
  required: Iterable[str],
260
+ languages: Iterable[str] = (),
156
261
  ) -> CoverageReport:
262
+ """What examined this tree, and whether that was enough to cover it.
263
+
264
+ `languages` names the programming languages actually present in the
265
+ scanned tree. Omitting it keeps the previous behaviour exactly, so every
266
+ existing call site and every older report keeps its meaning.
267
+ """
157
268
  executions = tuple(executions)
158
269
  required = tuple(dict.fromkeys(required))
159
270
  by_name = worst_by_name(executions)
@@ -188,8 +299,20 @@ def evaluate_coverage(
188
299
  if execution.outcome is ScannerOutcome.UNVERIFIED
189
300
  )
190
301
 
302
+ covered_by = {
303
+ execution.name for execution in by_name.values() if execution.outcome in COVERING_OUTCOMES
304
+ }
305
+ unread = _unread_languages(languages, covered_by)
306
+
191
307
  if failures:
192
308
  status = CoverageStatus.FAILED
309
+ elif unread:
310
+ # PARTIAL, not FAILED. PARTIAL withholds the *verified* grade and
311
+ # leaves `require_scanners` alone, which is how a partially covered
312
+ # repository already behaves. FAILED would refuse to audit anything
313
+ # containing a shell script. An operator who wants the stricter
314
+ # reading of P7 has `require_scanners` for it.
315
+ status = CoverageStatus.PARTIAL
193
316
  elif any(
194
317
  execution.outcome not in (*COVERING_OUTCOMES, ScannerOutcome.NOT_APPLICABLE)
195
318
  for execution in executions
@@ -204,4 +327,5 @@ def evaluate_coverage(
204
327
  unverified=unverified,
205
328
  executions=executions,
206
329
  failures=tuple(failures),
330
+ unread_languages=unread,
207
331
  )
@@ -723,8 +723,8 @@ def score(
723
723
  # one state, and only one of them was closed.
724
724
  nothing_scanned = loc_scanned <= 0
725
725
  for cat in Category:
726
- count = sum(1 for f in findings if f.category == cat and not f.suppressed)
727
- per_category_count[cat] = count
726
+ reported = [f for f in findings if f.category == cat and not f.suppressed]
727
+ per_category_count[cat] = len(reported)
728
728
  # A finding *is* evidence the category was measurable, whatever the
729
729
  # scanner inventory says — otherwise a tool reporting outside its
730
730
  # declared domain would have its findings graded as unmeasured. The
@@ -732,7 +732,28 @@ def score(
732
732
  # on a tree that counted no lines (a credential in a `.env`, a
733
733
  # misconfiguration in a bare dotfile — LOC counts only configured
734
734
  # source extensions) is still evidence, and is still graded.
735
- if count == 0 and (nothing_scanned or cat not in measured):
735
+ #
736
+ # But only a finding that could *move* the score is evidence that a
737
+ # score may be produced. A weightless one cannot: `INFORMATIONAL` is
738
+ # 0.0 in `SEVERITY_WEIGHT`, so it contributes nothing to the subtotal
739
+ # and the category grades a perfect 5.0 on the strength of its own
740
+ # presence.
741
+ #
742
+ # That is how the published 0.12.12 wheel reported 5.00/A+ on an empty
743
+ # tree. A clean install ships no scanners, every adapter emitted its
744
+ # `{name}.tool_unavailable` notice — POLICY_DOCS, INFORMATIONAL — and
745
+ # that notice graded its own category, which then became the overall:
746
+ # `{...all null..., "policy_docs": 5.0}`. D33 was right about scanner
747
+ # findings and wrong about control findings: a notice saying "this tool
748
+ # could not run" is evidence of the opposite of examination.
749
+ #
750
+ # Deliberately expressed as weight rather than as a list of control
751
+ # rule ids. `scoring` must not know about the scanner layer (see this
752
+ # module's own boundary note), the suffixes are four strings that would
753
+ # drift, and the real property is simply "could this have changed the
754
+ # number".
755
+ evidence = sum(1 for f in reported if SEVERITY_WEIGHT[f.severity] > 0.0)
756
+ if evidence == 0 and (nothing_scanned or cat not in measured):
736
757
  per_category[cat] = None
737
758
  continue
738
759
  subtotal = category_subtotal(findings, cat)