secure-code-agent 0.12.6__tar.gz → 0.12.8__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 (53) hide show
  1. {secure_code_agent-0.12.6/src/secure_code_agent.egg-info → secure_code_agent-0.12.8}/PKG-INFO +6 -4
  2. {secure_code_agent-0.12.6 → secure_code_agent-0.12.8}/README.md +5 -3
  3. {secure_code_agent-0.12.6 → secure_code_agent-0.12.8/src/secure_code_agent.egg-info}/PKG-INFO +6 -4
  4. {secure_code_agent-0.12.6 → secure_code_agent-0.12.8}/src/secure_code_audit/__init__.py +1 -1
  5. {secure_code_agent-0.12.6 → secure_code_agent-0.12.8}/src/secure_code_audit/cli.py +41 -1
  6. {secure_code_agent-0.12.6 → secure_code_agent-0.12.8}/src/secure_code_audit/config.py +6 -0
  7. {secure_code_agent-0.12.6 → secure_code_agent-0.12.8}/src/secure_code_audit/remediation.py +88 -0
  8. {secure_code_agent-0.12.6 → secure_code_agent-0.12.8}/src/secure_code_audit/renderers.py +13 -2
  9. {secure_code_agent-0.12.6 → secure_code_agent-0.12.8}/src/secure_code_audit/scoring.py +18 -1
  10. {secure_code_agent-0.12.6 → secure_code_agent-0.12.8}/LICENSE +0 -0
  11. {secure_code_agent-0.12.6 → secure_code_agent-0.12.8}/pyproject.toml +0 -0
  12. {secure_code_agent-0.12.6 → secure_code_agent-0.12.8}/setup.cfg +0 -0
  13. {secure_code_agent-0.12.6 → secure_code_agent-0.12.8}/src/secure_code_agent.egg-info/SOURCES.txt +0 -0
  14. {secure_code_agent-0.12.6 → secure_code_agent-0.12.8}/src/secure_code_agent.egg-info/dependency_links.txt +0 -0
  15. {secure_code_agent-0.12.6 → secure_code_agent-0.12.8}/src/secure_code_agent.egg-info/entry_points.txt +0 -0
  16. {secure_code_agent-0.12.6 → secure_code_agent-0.12.8}/src/secure_code_agent.egg-info/requires.txt +0 -0
  17. {secure_code_agent-0.12.6 → secure_code_agent-0.12.8}/src/secure_code_agent.egg-info/top_level.txt +0 -0
  18. {secure_code_agent-0.12.6 → secure_code_agent-0.12.8}/src/secure_code_audit/baseline.py +0 -0
  19. {secure_code_agent-0.12.6 → secure_code_agent-0.12.8}/src/secure_code_audit/capabilities.py +0 -0
  20. {secure_code_agent-0.12.6 → secure_code_agent-0.12.8}/src/secure_code_audit/data/semgrep-offline.yaml +0 -0
  21. {secure_code_agent-0.12.6 → secure_code_agent-0.12.8}/src/secure_code_audit/demo.py +0 -0
  22. {secure_code_agent-0.12.6 → secure_code_agent-0.12.8}/src/secure_code_audit/findings.py +0 -0
  23. {secure_code_agent-0.12.6 → secure_code_agent-0.12.8}/src/secure_code_audit/git_tools.py +0 -0
  24. {secure_code_agent-0.12.6 → secure_code_agent-0.12.8}/src/secure_code_audit/history.py +0 -0
  25. {secure_code_agent-0.12.6 → secure_code_agent-0.12.8}/src/secure_code_audit/instructions.py +0 -0
  26. {secure_code_agent-0.12.6 → secure_code_agent-0.12.8}/src/secure_code_audit/mcp_server.py +0 -0
  27. {secure_code_agent-0.12.6 → secure_code_agent-0.12.8}/src/secure_code_audit/pillar.py +0 -0
  28. {secure_code_agent-0.12.6 → secure_code_agent-0.12.8}/src/secure_code_audit/practice.py +0 -0
  29. {secure_code_agent-0.12.6 → secure_code_agent-0.12.8}/src/secure_code_audit/ruleset.py +0 -0
  30. {secure_code_agent-0.12.6 → secure_code_agent-0.12.8}/src/secure_code_audit/sarif.py +0 -0
  31. {secure_code_agent-0.12.6 → secure_code_agent-0.12.8}/src/secure_code_audit/scanner_status.py +0 -0
  32. {secure_code_agent-0.12.6 → secure_code_agent-0.12.8}/src/secure_code_audit/scanners/__init__.py +0 -0
  33. {secure_code_agent-0.12.6 → secure_code_agent-0.12.8}/src/secure_code_audit/scanners/bandit_scanner.py +0 -0
  34. {secure_code_agent-0.12.6 → secure_code_agent-0.12.8}/src/secure_code_audit/scanners/base.py +0 -0
  35. {secure_code_agent-0.12.6 → secure_code_agent-0.12.8}/src/secure_code_audit/scanners/builtin_rules.py +0 -0
  36. {secure_code_agent-0.12.6 → secure_code_agent-0.12.8}/src/secure_code_audit/scanners/checkov_scanner.py +0 -0
  37. {secure_code_agent-0.12.6 → secure_code_agent-0.12.8}/src/secure_code_audit/scanners/floor.py +0 -0
  38. {secure_code_agent-0.12.6 → secure_code_agent-0.12.8}/src/secure_code_audit/scanners/gitleaks_scanner.py +0 -0
  39. {secure_code_agent-0.12.6 → secure_code_agent-0.12.8}/src/secure_code_audit/scanners/gosec_scanner.py +0 -0
  40. {secure_code_agent-0.12.6 → secure_code_agent-0.12.8}/src/secure_code_audit/scanners/hadolint_scanner.py +0 -0
  41. {secure_code_agent-0.12.6 → secure_code_agent-0.12.8}/src/secure_code_audit/scanners/njsscan_scanner.py +0 -0
  42. {secure_code_agent-0.12.6 → secure_code_agent-0.12.8}/src/secure_code_audit/scanners/npm_audit_scanner.py +0 -0
  43. {secure_code_agent-0.12.6 → secure_code_agent-0.12.8}/src/secure_code_audit/scanners/osv_scanner.py +0 -0
  44. {secure_code_agent-0.12.6 → secure_code_agent-0.12.8}/src/secure_code_audit/scanners/pip_audit_scanner.py +0 -0
  45. {secure_code_agent-0.12.6 → secure_code_agent-0.12.8}/src/secure_code_audit/scanners/rubocop_scanner.py +0 -0
  46. {secure_code_agent-0.12.6 → secure_code_agent-0.12.8}/src/secure_code_audit/scanners/scorecard_scanner.py +0 -0
  47. {secure_code_agent-0.12.6 → secure_code_agent-0.12.8}/src/secure_code_audit/scanners/semgrep_scanner.py +0 -0
  48. {secure_code_agent-0.12.6 → secure_code_agent-0.12.8}/src/secure_code_audit/scanners/trivy_scanner.py +0 -0
  49. {secure_code_agent-0.12.6 → secure_code_agent-0.12.8}/src/secure_code_audit/scanners/trufflehog_scanner.py +0 -0
  50. {secure_code_agent-0.12.6 → secure_code_agent-0.12.8}/src/secure_code_audit/standards.py +0 -0
  51. {secure_code_agent-0.12.6 → secure_code_agent-0.12.8}/src/secure_code_audit/suppressions.py +0 -0
  52. {secure_code_agent-0.12.6 → secure_code_agent-0.12.8}/src/secure_code_audit/triage.py +0 -0
  53. {secure_code_agent-0.12.6 → secure_code_agent-0.12.8}/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.6
3
+ Version: 0.12.8
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
@@ -60,7 +60,9 @@ secure-code-agent --fail-on-gate \
60
60
  --sarif-output secure-code.sarif
61
61
  ```
62
62
 
63
- The sibling of [`maintainability-agent`](https://github.com/marshallguillory86/maintainability-agent). Same shape: deterministic CI gate · plain-file outputs · per-host skill bundle. Different concern: security, not maintainability.
63
+ **Runs on its own.** Install it, point it at a repository, get the gate, the report and the work order. Nothing else is required, and most people who use it will use only this.
64
+
65
+ It is also the sibling of [`maintainability-agent`](https://github.com/marshallguillory86/maintainability-agent) — same shape (deterministic CI gate · plain-file outputs · per-host skill bundle), different concern — and can feed that tool's Security pillar through [one file](docs/ma-integration.md). That is an integration, not a dependency: a family resemblance, not a component relationship. The two are separate products, installed separately, with their own dependency pins.
64
66
 
65
67
  ---
66
68
 
@@ -513,7 +515,7 @@ For agents that support invokable skills, this repo ships a portable skill under
513
515
  ## GitHub Action
514
516
 
515
517
  ```yaml
516
- - uses: marshallguillory86/secure-code-agent@v0.12.6
518
+ - uses: marshallguillory86/secure-code-agent@v0.12.8
517
519
  with:
518
520
  config: secure-code-agent.json
519
521
  fail-on-gate: true
@@ -612,4 +614,4 @@ MIT — see [`LICENSE`](LICENSE).
612
614
 
613
615
  ---
614
616
 
615
- Built by [Marshall Guillory](https://github.com/marshallguillory86). The companion to [`maintainability-agent`](https://github.com/marshallguillory86/maintainability-agent) — both tools encode a single thesis: *AI agents need deterministic boundaries, not best-effort guardrails.*
617
+ Built by [Marshall Guillory](https://github.com/marshallguillory86), who also builds [`maintainability-agent`](https://github.com/marshallguillory86/maintainability-agent). Two independent tools, released and installed separately, encoding one thesis: *AI agents need deterministic boundaries, not best-effort guardrails.*
@@ -12,7 +12,9 @@ secure-code-agent --fail-on-gate \
12
12
  --sarif-output secure-code.sarif
13
13
  ```
14
14
 
15
- The sibling of [`maintainability-agent`](https://github.com/marshallguillory86/maintainability-agent). Same shape: deterministic CI gate · plain-file outputs · per-host skill bundle. Different concern: security, not maintainability.
15
+ **Runs on its own.** Install it, point it at a repository, get the gate, the report and the work order. Nothing else is required, and most people who use it will use only this.
16
+
17
+ It is also the sibling of [`maintainability-agent`](https://github.com/marshallguillory86/maintainability-agent) — same shape (deterministic CI gate · plain-file outputs · per-host skill bundle), different concern — and can feed that tool's Security pillar through [one file](docs/ma-integration.md). That is an integration, not a dependency: a family resemblance, not a component relationship. The two are separate products, installed separately, with their own dependency pins.
16
18
 
17
19
  ---
18
20
 
@@ -465,7 +467,7 @@ For agents that support invokable skills, this repo ships a portable skill under
465
467
  ## GitHub Action
466
468
 
467
469
  ```yaml
468
- - uses: marshallguillory86/secure-code-agent@v0.12.6
470
+ - uses: marshallguillory86/secure-code-agent@v0.12.8
469
471
  with:
470
472
  config: secure-code-agent.json
471
473
  fail-on-gate: true
@@ -564,4 +566,4 @@ MIT — see [`LICENSE`](LICENSE).
564
566
 
565
567
  ---
566
568
 
567
- Built by [Marshall Guillory](https://github.com/marshallguillory86). The companion to [`maintainability-agent`](https://github.com/marshallguillory86/maintainability-agent) — both tools encode a single thesis: *AI agents need deterministic boundaries, not best-effort guardrails.*
569
+ Built by [Marshall Guillory](https://github.com/marshallguillory86), who also builds [`maintainability-agent`](https://github.com/marshallguillory86/maintainability-agent). Two independent tools, released and installed separately, encoding one thesis: *AI agents need deterministic boundaries, not best-effort guardrails.*
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: secure-code-agent
3
- Version: 0.12.6
3
+ Version: 0.12.8
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
@@ -60,7 +60,9 @@ secure-code-agent --fail-on-gate \
60
60
  --sarif-output secure-code.sarif
61
61
  ```
62
62
 
63
- The sibling of [`maintainability-agent`](https://github.com/marshallguillory86/maintainability-agent). Same shape: deterministic CI gate · plain-file outputs · per-host skill bundle. Different concern: security, not maintainability.
63
+ **Runs on its own.** Install it, point it at a repository, get the gate, the report and the work order. Nothing else is required, and most people who use it will use only this.
64
+
65
+ It is also the sibling of [`maintainability-agent`](https://github.com/marshallguillory86/maintainability-agent) — same shape (deterministic CI gate · plain-file outputs · per-host skill bundle), different concern — and can feed that tool's Security pillar through [one file](docs/ma-integration.md). That is an integration, not a dependency: a family resemblance, not a component relationship. The two are separate products, installed separately, with their own dependency pins.
64
66
 
65
67
  ---
66
68
 
@@ -513,7 +515,7 @@ For agents that support invokable skills, this repo ships a portable skill under
513
515
  ## GitHub Action
514
516
 
515
517
  ```yaml
516
- - uses: marshallguillory86/secure-code-agent@v0.12.6
518
+ - uses: marshallguillory86/secure-code-agent@v0.12.8
517
519
  with:
518
520
  config: secure-code-agent.json
519
521
  fail-on-gate: true
@@ -612,4 +614,4 @@ MIT — see [`LICENSE`](LICENSE).
612
614
 
613
615
  ---
614
616
 
615
- Built by [Marshall Guillory](https://github.com/marshallguillory86). The companion to [`maintainability-agent`](https://github.com/marshallguillory86/maintainability-agent) — both tools encode a single thesis: *AI agents need deterministic boundaries, not best-effort guardrails.*
617
+ Built by [Marshall Guillory](https://github.com/marshallguillory86), who also builds [`maintainability-agent`](https://github.com/marshallguillory86/maintainability-agent). Two independent tools, released and installed separately, encoding one thesis: *AI agents need deterministic boundaries, not best-effort guardrails.*
@@ -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.6"
15
+ __version__ = "0.12.8"
@@ -98,6 +98,14 @@ def _parser() -> argparse.ArgumentParser:
98
98
  )
99
99
  p.add_argument("--comment-output", help="PR-comment markdown output path.")
100
100
  p.add_argument("--prompt-output", help="Remediation prompt output path.")
101
+ p.add_argument(
102
+ "--work-order-json",
103
+ help=(
104
+ "The remediation work order as JSON: the same triage and the same "
105
+ "caps as the prompt, as facts rather than prose, for a tool that "
106
+ "renders its own presentation."
107
+ ),
108
+ )
101
109
  p.add_argument(
102
110
  "--security-pillar",
103
111
  help=(
@@ -561,7 +569,18 @@ def _do_audit(args: argparse.Namespace) -> int:
561
569
  summarize_axis("test tree", test_findings, test_loc),
562
570
  summarize_axis("documentation", docs_findings, docs_loc or None),
563
571
  summarize_axis("dependencies", dependency_findings),
564
- *(summarize_axis(name, found) for name, found in declared_axes.items()),
572
+ # The note is the reason the project wrote, carried verbatim. A
573
+ # declared axis whose page shows counts and no statement of why is a
574
+ # grade moved in silence — the one thing this mechanism exists not to
575
+ # be (D25).
576
+ *(
577
+ summarize_axis(
578
+ name,
579
+ found,
580
+ note=cfg.capabilities[name.removeprefix(AXIS_PREFIX)],
581
+ )
582
+ for name, found in declared_axes.items()
583
+ ),
565
584
  )
566
585
  # Naming an import on the command line asserts that it contributes coverage,
567
586
  # so a broken one fails the gate even if no config requires that scanner.
@@ -867,6 +886,10 @@ def _own_artifacts(paths: _OutputPaths, cfg: config_mod.Config, root: Path) -> f
867
886
  paths.json_out,
868
887
  paths.sarif,
869
888
  paths.code_scanning_sarif,
889
+ # The work order as data (D27). Here for the same reason the prompt
890
+ # is: it quotes finding text and code snippets, so the next run
891
+ # would score this tool's own output as source.
892
+ paths.work_order_json,
870
893
  paths.comment,
871
894
  paths.prompt,
872
895
  paths.security_pillar,
@@ -1019,6 +1042,20 @@ def _write_outputs(
1019
1042
  # The work order needs the axis to tier a finding: a secret in a
1020
1043
  # test fixture is a suppression decision, not a patch target.
1021
1044
  remediation.write(findings, paths.prompt, root, lambda f: renderers.axis_of(f, axes))
1045
+ # The same work order as facts. `maintainability-agent` embeds this one
1046
+ # in an HTML report, and prose is the thing a consumer cannot
1047
+ # re-present: with only Markdown it wrapped the text in `<pre>` and a
1048
+ # reader got `##` headings inside a rendered page (D27).
1049
+ if paths.work_order_json is not None:
1050
+ paths.work_order_json.parent.mkdir(parents=True, exist_ok=True)
1051
+ paths.work_order_json.write_text(
1052
+ json.dumps(
1053
+ remediation.as_data(findings, root, lambda f: renderers.axis_of(f, axes)),
1054
+ indent=2,
1055
+ )
1056
+ + "\n",
1057
+ encoding="utf-8",
1058
+ )
1022
1059
  # The artifact maintainability-agent ingests (D3). Written last because it
1023
1060
  # is the only output that carries both axes plus the practice level.
1024
1061
  if paths.security_pillar is not None and pillar is not None:
@@ -1051,6 +1088,8 @@ class _OutputPaths:
1051
1088
  prompt: Path | None
1052
1089
  security_pillar: Path | None
1053
1090
  code_scanning_sarif: Path | None = None
1091
+ #: The work order as data, for a tool that renders its own (D27).
1092
+ work_order_json: Path | None = None
1054
1093
 
1055
1094
 
1056
1095
  def _resolve_outputs(args: argparse.Namespace, cfg: config_mod.Config, root: Path) -> _OutputPaths:
@@ -1096,6 +1135,7 @@ def _resolve_outputs(args: argparse.Namespace, cfg: config_mod.Config, root: Pat
1096
1135
  prompt=_p(args.prompt_output, "prompt_path"),
1097
1136
  security_pillar=_p(args.security_pillar, "security_pillar_path"),
1098
1137
  code_scanning_sarif=_p(args.code_scanning_sarif_output, "code_scanning_sarif_path"),
1138
+ work_order_json=_p(args.work_order_json, "work_order_json_path"),
1099
1139
  )
1100
1140
 
1101
1141
 
@@ -147,6 +147,12 @@ DEFAULT_OUTPUTS: dict[str, str] = {
147
147
  "code_scanning_sarif_path": "secure-code.code-scanning.sarif",
148
148
  "comment_path": "secure-code-pr-comment.md",
149
149
  "prompt_path": "secure-code-remediation-prompt.md",
150
+ # The work order as data, for a tool that renders its own presentation
151
+ # (D27). Off unless declared, like the code-scanning SARIF: the prompt
152
+ # is the artifact an operator reads, and a second copy in JSON is only
153
+ # wanted by a consumer. The name is still declared so this tool never
154
+ # scans a file it wrote.
155
+ "work_order_json_path": "secure-code-work-order.json",
150
156
  "baseline_path": "secure-code-baseline.json",
151
157
  # Append-only trend. The score's one genuine use is movement over time.
152
158
  "history_path": ".secure-code/history.jsonl",
@@ -10,6 +10,7 @@ from __future__ import annotations
10
10
 
11
11
  from collections.abc import Iterable
12
12
  from pathlib import Path
13
+ from typing import Any
13
14
 
14
15
  from secure_code_audit import triage
15
16
  from secure_code_audit.findings import Finding
@@ -245,6 +246,93 @@ def generate(
245
246
  return "\n".join(parts)
246
247
 
247
248
 
249
+ #: The work order's data schema. Bumped when a consumer would have to
250
+ #: change to keep reading it; new optional keys do not bump it.
251
+ WORK_ORDER_SCHEMA_VERSION = 1
252
+
253
+
254
+ def _finding_data(f: Finding, root: Path | None, note: str | None = None) -> dict[str, Any]:
255
+ """One finding, as the facts a renderer needs and nothing more.
256
+
257
+ Deliberately not the whole `Finding`. The fingerprint, the baseline
258
+ flag and the suppression note are this tool's bookkeeping; a consumer
259
+ drawing a work order needs what it is, where it is, what to do, and
260
+ the citation that makes it checkable.
261
+ """
262
+ return {
263
+ "rule_id": f.rule_id,
264
+ "scanner": f.scanner,
265
+ "title": f.short_desc or f.message,
266
+ "message": f.message,
267
+ "fix_hint": f.fix_hint,
268
+ "severity": f.severity.value,
269
+ "confidence": f.confidence.value,
270
+ "category": f.category.value,
271
+ "path": _display_path(f.file_path, root),
272
+ "line_start": f.line_start,
273
+ "line_end": f.line_end,
274
+ "location": _location(f, root),
275
+ "code_snippet": f.code_snippet,
276
+ "standards": {
277
+ "cwe": f.canonical_cwe,
278
+ "cwe_top25": f.cwe_top25,
279
+ "owasp_top10": f.owasp_top10,
280
+ "asvs_section": f.asvs_section,
281
+ "nist_ssdf": f.nist_ssdf,
282
+ },
283
+ # Only §REVIEW findings carry one: it is why the tier demoted them.
284
+ "review_note": note,
285
+ }
286
+
287
+
288
+ def as_data(
289
+ findings: Iterable[Finding],
290
+ root: Path | None = None,
291
+ axis_of=lambda _f: "primary",
292
+ ) -> dict[str, Any]:
293
+ """The work order as facts, for a consumer that renders its own.
294
+
295
+ `generate` writes Markdown, which is right for the operator and for
296
+ the agent that reads the prompt. It is the wrong thing to hand a
297
+ *tool*: `maintainability-agent` embeds this work order in an HTML
298
+ report and had only prose, so it wrapped it in `<pre>` and a reader
299
+ got raw Markdown — headings as `##`, bold as asterisks — inside a
300
+ page where everything else was rendered.
301
+
302
+ Prose is the one thing a consumer cannot re-present. This is the same
303
+ triage, the same order and the same caps, as data: the tiers come from
304
+ `triage.partition` exactly as the Markdown's do, so the two can never
305
+ describe different work.
306
+
307
+ The caps are kept rather than dropped. A consumer showing more than
308
+ the prompt shows would disagree with the artifact the operator reads,
309
+ and `omitted` says how many are not here — the same number
310
+ `_overflow` prints.
311
+ """
312
+ tiers = triage.partition(findings, axis_of, root)
313
+ fix = tiers[triage.Tier.FIX]
314
+ review = tiers[triage.Tier.REVIEW]
315
+ accept = tiers[triage.Tier.ACCEPT]
316
+ return {
317
+ "schema_version": WORK_ORDER_SCHEMA_VERSION,
318
+ "counts": {"fix": len(fix), "review": len(review), "accept": len(accept)},
319
+ "fix": {
320
+ "shown": [_finding_data(f, root) for f in fix[:_MAX_BLOCKS]],
321
+ "omitted": max(0, len(fix) - _MAX_BLOCKS),
322
+ },
323
+ "review": {
324
+ "shown": [
325
+ _finding_data(f, root, triage.reason_for(f, root))
326
+ for f in review[:_MAX_REVIEW_LINES]
327
+ ],
328
+ "omitted": max(0, len(review) - _MAX_REVIEW_LINES),
329
+ },
330
+ # The accept tier is a count and a summary in the Markdown too: it is
331
+ # the test tree and documentation, which nobody patches one by one.
332
+ "accept": {"summary": _accept_summary(accept, root) if accept else ""},
333
+ }
334
+
335
+
248
336
  def _location(f: Finding, root: Path | None) -> str:
249
337
  span = f"-{f.line_end}" if f.line_end and f.line_end != f.line_start else ""
250
338
  return f"{_display_path(f.file_path, root)}:{f.line_start}{span}"
@@ -167,7 +167,7 @@ def _axes_to_dict(axes: Iterable[AxisReport]) -> dict:
167
167
  "count": axis.count,
168
168
  "scored": False,
169
169
  "gated": axis.name == "dependencies",
170
- "note": _AXIS_NOTES.get(axis.name, ""),
170
+ "note": _axis_note(axis),
171
171
  "per_severity_count": {s.value: n for s, n in axis.per_severity_count.items()},
172
172
  "per_category_count": {c.value: n for c, n in axis.per_category_count.items()},
173
173
  # Not repeated here: every one of these appears in the top-level
@@ -206,6 +206,17 @@ _AXIS_NOTES = {
206
206
  }
207
207
 
208
208
 
209
+ def _axis_note(axis: AxisReport) -> str:
210
+ """Why this axis sits outside the score, in the report's own words.
211
+
212
+ An axis that carries its own note wins. Only a declared axis does, and
213
+ its note is the reason the audited project wrote in its configuration —
214
+ the tool cannot supply that sentence, and a declared axis printed without
215
+ it is a grade moved with no statement of why.
216
+ """
217
+ return axis.note or _AXIS_NOTES.get(axis.name, "")
218
+
219
+
209
220
  def write_json(
210
221
  findings: Iterable[Finding],
211
222
  score: ScoreReport,
@@ -361,7 +372,7 @@ def _axis_section(report: AxisReport | None) -> str:
361
372
  )
362
373
  )
363
374
  out.append(f"- **By severity:** {by_severity}")
364
- note = _AXIS_NOTES.get(report.name)
375
+ note = _axis_note(report)
365
376
  if note:
366
377
  out.append(f"- {note}")
367
378
  out.append("")
@@ -554,6 +554,17 @@ class AxisReport:
554
554
  #: advisories are counted against a lockfile, not a line count, so this is
555
555
  #: None for them rather than a misleading zero.
556
556
  loc: int | None = None
557
+ #: Why this axis sits outside the score, in the report's own words. Empty
558
+ #: for the fixed axes, whose notes the renderer holds because they are the
559
+ #: same sentence for every project.
560
+ #:
561
+ #: A declared axis is the case this field exists for. Its justification is
562
+ #: not a constant the tool can write: it is the reason *this* project
563
+ #: stated in its configuration. 0.12.6 shipped the declaration reaching the
564
+ #: routing and never reaching the page, so a reader saw
565
+ #: `## Declared: spawns_processes` with counts under it and no statement of
566
+ #: why — which is the disclosure the whole mechanism rests on (D25).
567
+ note: str = ""
557
568
 
558
569
  @property
559
570
  def count(self) -> int:
@@ -580,7 +591,12 @@ class AxisReport:
580
591
  )
581
592
 
582
593
 
583
- def summarize_axis(name: str, findings: Iterable[Finding], loc: int | None = None) -> AxisReport:
594
+ def summarize_axis(
595
+ name: str,
596
+ findings: Iterable[Finding],
597
+ loc: int | None = None,
598
+ note: str = "",
599
+ ) -> AxisReport:
584
600
  """Count an axis without scoring it.
585
601
 
586
602
  Not named `test_*` anything: pytest collects any callable whose name begins
@@ -601,6 +617,7 @@ def summarize_axis(name: str, findings: Iterable[Finding], loc: int | None = Non
601
617
  return AxisReport(
602
618
  name=name,
603
619
  loc=loc,
620
+ note=note,
604
621
  findings=findings,
605
622
  per_severity_count=per_severity,
606
623
  per_category_count=per_category,