backstitch 0.1.0__py3-none-any.whl

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.
backstitch/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Backstitch package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = ["__version__"]
6
+
7
+ __version__ = "0.1.0"
backstitch/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Module entry point for `python -m backstitch`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
@@ -0,0 +1,214 @@
1
+ """Semantic analysis over packets through the ``llm`` Python API.
2
+
3
+ Spec: docs/specs/02-backstitch-core.md [SC-7], [SC-13]
4
+
5
+ The adapter boundary exists so tests prove prompt construction, iteration,
6
+ parsing, and malformed-output handling with fakes; only the default adapter
7
+ touches ``llm`` and real models. Findings are advisory [SC-7].
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import re
14
+ from collections.abc import Callable, Iterable
15
+ from concurrent.futures import ThreadPoolExecutor
16
+ from typing import Any
17
+
18
+ from backstitch.analysis_results import validate_analysis_row
19
+
20
+ ModelAdapter = Callable[[str], str]
21
+
22
+ _FENCE_RE = re.compile(r"^```[\w-]*\n(?P<body>.*)\n```\s*$", re.DOTALL)
23
+
24
+
25
+ def default_adapter(model_name: str | None = None) -> ModelAdapter:
26
+ """Build the real adapter for an ``llm`` model.
27
+
28
+ With no name, ``llm``'s configured default model is used (whatever
29
+ ``llm models default`` reports).
30
+ """
31
+
32
+ import llm
33
+
34
+ model = llm.get_model(model_name) if model_name else llm.get_model()
35
+
36
+ def call(prompt: str) -> str:
37
+ return str(model.prompt(prompt).text())
38
+
39
+ return call
40
+
41
+
42
+ def build_prompt(packet: dict[str, Any]) -> str:
43
+ """Compose the review prompt: instructions, then the bounded packet."""
44
+
45
+ body = {k: v for k, v in packet.items() if k != "instructions"}
46
+ return f"{packet['instructions']}\n\n{json.dumps(body)}"
47
+
48
+
49
+ def _packet_evidence_bounds(
50
+ packet: dict[str, Any],
51
+ ) -> dict[str, tuple[tuple[int, int], ...]]:
52
+ """What the model was shown, as path -> allowed line ranges ([SC-7]).
53
+
54
+ Owner snippets and the spec section text are line-bounded, so evidence
55
+ must fall inside one of those ranges. Linked tests are named by PATH
56
+ only -- the model never saw their content, so a path with no ranges is
57
+ known to the packet but cannot carry line evidence.
58
+ """
59
+
60
+ bounds: dict[str, list[tuple[int, int]]] = {}
61
+ spec_path = packet.get("spec_path")
62
+ # Blank paths never name a packet member: an empty or whitespace-only
63
+ # string must not become a citable evidence path (load-time validation
64
+ # rejects these, but analyze_packets is also a library entry point).
65
+ if isinstance(spec_path, str) and spec_path.strip():
66
+ ranges = bounds.setdefault(spec_path, [])
67
+ start = packet.get("section_start_line")
68
+ text = packet.get("section_text")
69
+ if isinstance(start, int) and isinstance(text, str) and text.splitlines():
70
+ ranges.append((start, start + len(text.splitlines()) - 1))
71
+ for test in packet.get("tests", ()):
72
+ if isinstance(test, str) and test.strip():
73
+ bounds.setdefault(test, [])
74
+ for owner in packet.get("owners", ()):
75
+ if (
76
+ not isinstance(owner, dict)
77
+ or not isinstance(owner.get("path"), str)
78
+ or not owner["path"].strip()
79
+ ):
80
+ continue
81
+ ranges = bounds.setdefault(owner["path"], [])
82
+ start = owner.get("start_line")
83
+ snippet = owner.get("snippet")
84
+ # An EMPTY snippet (directory mappings) showed no line content:
85
+ # like tests, the path is known but carries no valid line evidence.
86
+ if isinstance(start, int) and isinstance(snippet, str) and snippet.splitlines():
87
+ ranges.append((start, start + len(snippet.splitlines()) - 1))
88
+ return {path: tuple(ranges) for path, ranges in bounds.items()}
89
+
90
+
91
+ def _parse_model_output(raw: str, packet: dict[str, Any]) -> dict[str, Any] | str:
92
+ packet_id = packet["packet_id"]
93
+ text = raw.strip()
94
+ fence = _FENCE_RE.match(text)
95
+ if fence:
96
+ text = fence.group("body").strip()
97
+ try:
98
+ row = json.loads(text)
99
+ except json.JSONDecodeError:
100
+ return "model output is not valid JSON"
101
+ validated = validate_analysis_row(
102
+ row, None, allowed_evidence=_packet_evidence_bounds(packet)
103
+ )
104
+ if isinstance(validated, str):
105
+ return f"model output invalid: {validated}"
106
+ if validated.packet_id != packet_id:
107
+ return (
108
+ f"model output packet_id `{validated.packet_id}` does not match the packet"
109
+ )
110
+ assert isinstance(row, dict)
111
+ return row
112
+
113
+
114
+ def _error_record(packet_id: str, message: str) -> dict[str, Any]:
115
+ # [SC-7]: one bad response yields one `ambiguous`/error record for the
116
+ # packet -- a consumer of the results JSONL never loses a packet-level
117
+ # result to a model failure. `packet_id` comes from the packet, never
118
+ # the response, and the record passes validate_analysis_row.
119
+ return {
120
+ "packet_id": packet_id,
121
+ "classification": "ambiguous",
122
+ "summary": f"analysis error: {message}",
123
+ "rationale": message,
124
+ "evidence": [],
125
+ "error": message,
126
+ }
127
+
128
+
129
+ def analyze_packets(
130
+ packets: Iterable[dict[str, Any]],
131
+ adapter: ModelAdapter,
132
+ concurrency: int = 1,
133
+ ) -> tuple[list[dict[str, Any]], list[str]]:
134
+ """Run the adapter over every packet; collect rows and per-packet errors.
135
+
136
+ Every packet yields exactly one row: a failed packet yields an
137
+ `ambiguous`/error record ([SC-7]) and its message is also collected in
138
+ ``errors`` for stderr. Output rows keep packet order regardless of
139
+ concurrency. A failure on one packet never aborts the others.
140
+ """
141
+
142
+ packet_list = list(packets)
143
+
144
+ def run_one(packet: dict[str, Any]) -> tuple[dict[str, Any], str | None]:
145
+ packet_id = packet.get("packet_id", "<missing packet_id>")
146
+ if "instructions" not in packet:
147
+ message = "packet has no `instructions` field"
148
+ return _error_record(packet_id, message), f"{packet_id}: {message}"
149
+ try:
150
+ raw = adapter(build_prompt(packet))
151
+ except Exception as exc: # noqa: BLE001 - adapter is an external boundary
152
+ message = f"model call failed: {exc}"
153
+ return _error_record(packet_id, message), f"{packet_id}: {message}"
154
+ parsed = _parse_model_output(raw, packet)
155
+ if isinstance(parsed, str):
156
+ return _error_record(packet_id, parsed), f"{packet_id}: {parsed}"
157
+ return parsed, None
158
+
159
+ if concurrency <= 1:
160
+ outcomes = [run_one(packet) for packet in packet_list]
161
+ else:
162
+ with ThreadPoolExecutor(max_workers=concurrency) as pool:
163
+ outcomes = list(pool.map(run_one, packet_list))
164
+
165
+ rows = [row for row, _ in outcomes]
166
+ errors = [error for _, error in outcomes if error is not None]
167
+ return rows, errors
168
+
169
+
170
+ def analyze_exit_code(rows: list[dict[str, Any]], errors: list[str]) -> int:
171
+ """Exit 2 when analysis produced nothing but failures; 0 otherwise.
172
+
173
+ [SC-5]: exit 1 is reserved for deterministic findings about the target
174
+ repository, and semantic findings are advisory -- `analyze` never
175
+ returns 1. Total failure (every packet errored; rows include the
176
+ per-packet error records) is a statement about the tool or the model,
177
+ so it is exit 2. Partial failure still exits 0 because the output is
178
+ usable; total failure must be scriptable without scraping stderr.
179
+ """
180
+
181
+ return 2 if errors and len(errors) == len(rows) else 0
182
+
183
+
184
+ def render_results_jsonl(rows: list[dict[str, Any]]) -> str:
185
+ """Render analysis rows as JSONL, one result per line."""
186
+
187
+ return "".join(json.dumps(row) + "\n" for row in rows)
188
+
189
+
190
+ def resolve_model_name(
191
+ explicit: str | None = None,
192
+ *,
193
+ configured: str | None = None,
194
+ ) -> str | None:
195
+ """[CFG-5] model precedence: --model, LLM_MODEL, config, llm default.
196
+
197
+ CLI beats env beats config -- [CFG-5]'s assembly order puts environment
198
+ variables ABOVE the config file, so `LLM_MODEL` overrides
199
+ `analyze.model` whenever --model is omitted.
200
+
201
+ Returns ``None`` to mean "let ``llm`` use its configured default" so the
202
+ lazy-import boundary stays in ``default_adapter``.
203
+ """
204
+
205
+ import os
206
+
207
+ if explicit is not None and explicit.strip():
208
+ return explicit.strip()
209
+ env = os.environ.get("LLM_MODEL", "").strip()
210
+ if env:
211
+ return env
212
+ if configured is not None and configured.strip():
213
+ return configured.strip()
214
+ return None
@@ -0,0 +1,202 @@
1
+ """Bounded semantic-review packet generation from deterministic results.
2
+
3
+ Spec: docs/specs/02-backstitch-core.md [SC-6], [SC-7]
4
+
5
+ Packets are the semantic review boundary: the model judges only what a
6
+ packet contains and never roams the repository [SC-7]. Generation is
7
+ deterministic and never calls ``llm``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import dataclasses
13
+ import json
14
+ from importlib import resources
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from backstitch.config import ProfileConfig
19
+ from backstitch.models import Report, SpecSection
20
+ from backstitch.python_refs import python_symbol_spans
21
+ from backstitch.resolver import scan_repository
22
+
23
+ MAX_SNIPPET_LINES = 120
24
+ MAX_OWNERS_PER_PACKET = 8
25
+ MAX_SECTION_LINES = 100
26
+
27
+
28
+ def _instructions() -> str:
29
+ return (
30
+ resources.files("backstitch") / "prompts" / "backstitch_style_analysis.md"
31
+ ).read_text(encoding="utf-8")
32
+
33
+
34
+ def _is_test_path(path: str) -> bool:
35
+ pure = Path(path)
36
+ if pure.name.startswith("test_"):
37
+ return True
38
+ return bool(pure.parts) and pure.parts[0] == "tests"
39
+
40
+
41
+ def _section_text(
42
+ section: SpecSection,
43
+ file_lines: list[str],
44
+ siblings: list[SpecSection],
45
+ warnings: list[str],
46
+ ) -> str:
47
+ if section.kind != "heading":
48
+ line = file_lines[section.line - 1] if section.line <= len(file_lines) else ""
49
+ return line.strip()
50
+ next_headings = [
51
+ s.line for s in siblings if s.kind == "heading" and s.line > section.line
52
+ ]
53
+ end = min(next_headings) - 1 if next_headings else len(file_lines)
54
+ block = file_lines[section.line - 1 : end]
55
+ if len(block) > MAX_SECTION_LINES:
56
+ block = block[:MAX_SECTION_LINES]
57
+ warnings.append(f"section text truncated to {MAX_SECTION_LINES} lines")
58
+ return "\n".join(block).rstrip()
59
+
60
+
61
+ def _owner_snippet(
62
+ repo_root: Path,
63
+ path: str,
64
+ symbol: str | None,
65
+ warnings: list[str],
66
+ ) -> tuple[str, int]:
67
+ target = repo_root / path
68
+ if not target.is_file():
69
+ warnings.append(f"owner `{path}` is not a file; no snippet included")
70
+ return "", 1
71
+ try:
72
+ lines = target.read_text(encoding="utf-8", errors="replace").splitlines()
73
+ except OSError as exc:
74
+ warnings.append(f"owner `{path}` could not be read ({exc})")
75
+ return "", 1
76
+ start = 1
77
+ if symbol is not None and path.endswith(".py"):
78
+ spans = python_symbol_spans(target)
79
+ span = spans.get(symbol) if spans else None
80
+ if span is not None:
81
+ start, end = span
82
+ lines = lines[start - 1 : end]
83
+ else:
84
+ warnings.append(f"symbol `{symbol}` not found in `{path}`; using file head")
85
+ if len(lines) > MAX_SNIPPET_LINES:
86
+ lines = lines[:MAX_SNIPPET_LINES]
87
+ warnings.append(f"snippet for `{path}` truncated to {MAX_SNIPPET_LINES} lines")
88
+ return "\n".join(lines), start
89
+
90
+
91
+ def generate_packets(
92
+ repo_root: Path,
93
+ profile: ProfileConfig,
94
+ report: Report | None = None,
95
+ ) -> list[dict[str, Any]]:
96
+ """Generate one packet per spec section that has resolved edges."""
97
+
98
+ root = repo_root.resolve()
99
+ if report is None:
100
+ report = scan_repository(root, profile)
101
+ instructions = _instructions()
102
+
103
+ sections_by_file: dict[str, list[SpecSection]] = {}
104
+ for section in report.spec_sections:
105
+ sections_by_file.setdefault(section.path, []).append(section)
106
+
107
+ spec_lines: dict[str, list[str]] = {}
108
+ for path in sections_by_file:
109
+ spec_lines[path] = (
110
+ (root / path).read_text(encoding="utf-8", errors="replace").splitlines()
111
+ )
112
+
113
+ edges_by_key: dict[tuple[str, str], list] = {}
114
+ for edge in report.edges:
115
+ edges_by_key.setdefault((edge.spec_path, edge.section_id), []).append(edge)
116
+
117
+ packets: list[dict[str, Any]] = []
118
+ seen_keys: set[tuple[str, str]] = set()
119
+ for section in report.spec_sections:
120
+ key = (section.path, section.section_id)
121
+ if key in seen_keys:
122
+ continue
123
+ seen_keys.add(key)
124
+ edges = edges_by_key.get(key, [])
125
+ if not edges:
126
+ continue
127
+
128
+ warnings: list[str] = []
129
+ owner_keys: list[tuple[str, str | None]] = []
130
+ tests: list[str] = []
131
+ for edge in edges:
132
+ if edge.kind == "backlink" and _is_test_path(edge.code_path):
133
+ if edge.code_path not in tests:
134
+ tests.append(edge.code_path)
135
+ continue
136
+ symbol = edge.code_symbol
137
+ if edge.kind == "backlink" and symbol == "module":
138
+ symbol = None
139
+ owner_key = (edge.code_path, symbol)
140
+ if owner_key not in owner_keys:
141
+ owner_keys.append(owner_key)
142
+
143
+ if len(owner_keys) > MAX_OWNERS_PER_PACKET:
144
+ omitted = len(owner_keys) - MAX_OWNERS_PER_PACKET
145
+ owner_keys = owner_keys[:MAX_OWNERS_PER_PACKET]
146
+ warnings.append(f"{omitted} additional owners omitted")
147
+
148
+ owners = []
149
+ for path, symbol in owner_keys:
150
+ snippet, start_line = _owner_snippet(root, path, symbol, warnings)
151
+ owners.append(
152
+ {
153
+ "path": path,
154
+ "symbol": symbol,
155
+ "start_line": start_line,
156
+ "snippet": snippet,
157
+ }
158
+ )
159
+
160
+ owner_paths = {path for path, _ in owner_keys}
161
+ issues = [
162
+ dataclasses.asdict(issue)
163
+ for issue in report.issues
164
+ if issue.section_id == section.section_id
165
+ and (
166
+ issue.path == section.path
167
+ or issue.path in owner_paths
168
+ or issue.path in tests
169
+ )
170
+ ]
171
+
172
+ packets.append(
173
+ {
174
+ "packet_id": f"{section.path}#{section.section_id}",
175
+ "spec_path": section.path,
176
+ "section_id": section.section_id,
177
+ "title": section.title,
178
+ "section_text": _section_text(
179
+ section,
180
+ spec_lines[section.path],
181
+ sections_by_file[section.path],
182
+ warnings,
183
+ ),
184
+ # [SC-6]: the section's starting line anchors evidence
185
+ # line-locality checks against the shown section text.
186
+ "section_start_line": section.line,
187
+ "owners": owners,
188
+ "tests": tests,
189
+ "issues": issues,
190
+ # [SC-6] field name is contractual: consumers look for
191
+ # `packet_warnings` for truncation/omission context.
192
+ "packet_warnings": warnings,
193
+ "instructions": instructions,
194
+ }
195
+ )
196
+ return packets
197
+
198
+
199
+ def render_packets_jsonl(packets: list[dict[str, Any]]) -> str:
200
+ """Render packets as JSONL, one packet per line."""
201
+
202
+ return "".join(json.dumps(packet) + "\n" for packet in packets)
@@ -0,0 +1,248 @@
1
+ """Validation and aggregation of semantic analysis results.
2
+
3
+ Spec: docs/specs/02-backstitch-core.md [SC-6], [SC-7], [SC-13]
4
+
5
+ Invalid analysis rows are analysis-summary errors, never repository trace
6
+ errors, and semantic findings never change deterministic issue severity.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from collections.abc import Mapping
13
+ from dataclasses import dataclass
14
+ from typing import Any
15
+
16
+ CLASSIFICATIONS = (
17
+ "ok",
18
+ "confirmed_mismatch",
19
+ "probable_mismatch",
20
+ "missing_trace",
21
+ "ambiguous",
22
+ )
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class AnalysisResult:
27
+ """One validated semantic finding for a packet."""
28
+
29
+ packet_id: str
30
+ classification: str
31
+ confidence: float | None
32
+ rationale: str
33
+ evidence: tuple[tuple[str, int], ...]
34
+ summary: str
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class AnalysisLoad:
39
+ """Validated results plus row-level input problems."""
40
+
41
+ results: tuple[AnalysisResult, ...]
42
+ errors: tuple[str, ...]
43
+
44
+
45
+ def validate_analysis_row(
46
+ row: Any,
47
+ known_packet_ids: set[str] | None,
48
+ *,
49
+ allowed_evidence: Mapping[str, tuple[tuple[int, int], ...]] | None = None,
50
+ ) -> AnalysisResult | str:
51
+ """Validate one untrusted model-output row ([SC-7]).
52
+
53
+ ``allowed_evidence``, when provided, keeps evidence packet-local: the
54
+ model may only cite paths that were in the packet it was shown, and
55
+ only lines inside one of that path's ``(start, end)`` inclusive
56
+ ranges. An empty range tuple means the path was named in the packet
57
+ WITHOUT line-bounded content (linked tests): it cannot carry line
58
+ evidence at all -- any cited line there is fabricated.
59
+ """
60
+
61
+ if not isinstance(row, dict):
62
+ return "row is not a JSON object"
63
+ packet_id = row.get("packet_id")
64
+ if not isinstance(packet_id, str) or not packet_id.strip():
65
+ return "missing or invalid `packet_id`"
66
+ if known_packet_ids is not None and packet_id not in known_packet_ids:
67
+ return f"unknown packet ID `{packet_id}`"
68
+ classification = row.get("classification")
69
+ if classification not in CLASSIFICATIONS:
70
+ return (
71
+ f"unsupported classification {classification!r}; expected one of"
72
+ f" {', '.join(CLASSIFICATIONS)}"
73
+ )
74
+ summary = row.get("summary")
75
+ if not isinstance(summary, str) or not summary.strip():
76
+ # Blank means absent, same as locators and rationales.
77
+ return "missing or invalid `summary`"
78
+ confidence = row.get("confidence")
79
+ if confidence is not None and (
80
+ isinstance(confidence, bool)
81
+ or not isinstance(confidence, int | float)
82
+ or not 0.0 <= confidence <= 1.0
83
+ ):
84
+ # The prompt contract asks for a confidence between 0 and 1;
85
+ # anything else is a malformed row, not a very confident one.
86
+ return "invalid `confidence`; expected a number between 0 and 1"
87
+ rationale = row.get("rationale", "")
88
+ if not isinstance(rationale, str):
89
+ return "invalid `rationale`; expected a string"
90
+ if confidence is None and not rationale.strip():
91
+ # The [SC-7] record contract requires a confidence OR rationale
92
+ # field; a row carrying neither is malformed, not low-effort.
93
+ return "missing `confidence` or `rationale`; rows must carry at least one"
94
+ evidence_raw = row.get("evidence")
95
+ evidence: list[tuple[str, int]] = []
96
+ if not isinstance(evidence_raw, list):
97
+ # `evidence` is a required key (an empty list is a valid value:
98
+ # it states explicitly that no evidence is cited).
99
+ return "missing or invalid `evidence`; expected a list"
100
+ for item in evidence_raw:
101
+ if (
102
+ not isinstance(item, dict)
103
+ or not isinstance(item.get("path"), str)
104
+ or not item["path"].strip()
105
+ or isinstance(item.get("line"), bool)
106
+ or not isinstance(item.get("line"), int)
107
+ or item["line"] < 1
108
+ ):
109
+ return "invalid `evidence` item; expected {non-empty path, line >= 1}"
110
+ if allowed_evidence is not None:
111
+ # [SC-7]: model output is untrusted; evidence must stay inside
112
+ # the packet boundary -- both the path and the line.
113
+ ranges = allowed_evidence.get(item["path"])
114
+ if ranges is None:
115
+ return f"evidence path `{item['path']}` is not part of the packet"
116
+ if not ranges:
117
+ return (
118
+ f"evidence line {item['line']} in `{item['path']}` is"
119
+ " fabricated: the packet named this path without"
120
+ " line-bounded content"
121
+ )
122
+ if not any(start <= item["line"] <= end for start, end in ranges):
123
+ return (
124
+ f"evidence line {item['line']} in `{item['path']}` is"
125
+ " outside the packet's shown content"
126
+ )
127
+ evidence.append((item["path"], item["line"]))
128
+ return AnalysisResult(
129
+ packet_id=packet_id,
130
+ classification=classification,
131
+ confidence=float(confidence) if confidence is not None else None,
132
+ rationale=rationale,
133
+ evidence=tuple(evidence),
134
+ summary=summary,
135
+ )
136
+
137
+
138
+ def load_analysis_results(text: str, known_packet_ids: set[str] | None) -> AnalysisLoad:
139
+ """Parse analysis JSONL; bad rows become errors, not exceptions."""
140
+
141
+ results: list[AnalysisResult] = []
142
+ errors: list[str] = []
143
+ for line_no, line in enumerate(text.splitlines(), start=1):
144
+ if not line.strip():
145
+ continue
146
+ try:
147
+ row = json.loads(line)
148
+ except json.JSONDecodeError as exc:
149
+ errors.append(f"line {line_no}: invalid JSON ({exc.msg})")
150
+ continue
151
+ validated = validate_analysis_row(row, known_packet_ids)
152
+ if isinstance(validated, str):
153
+ errors.append(f"line {line_no}: {validated}")
154
+ else:
155
+ results.append(validated)
156
+ return AnalysisLoad(results=tuple(results), errors=tuple(errors))
157
+
158
+
159
+ def packet_ids_from_report(report_data: dict[str, Any]) -> set[str]:
160
+ """Derive valid packet IDs from a deterministic report.
161
+
162
+ Packet generation ([SC-6]) emits a packet only for sections that have
163
+ at least one trace edge, so the valid-ID universe is edge-bearing
164
+ sections -- a forged analysis row for a section that never produced a
165
+ packet must be rejected, not summarized.
166
+ """
167
+
168
+ return {
169
+ f"{edge['spec_path']}#{edge['section_id']}"
170
+ for edge in report_data.get("edges", [])
171
+ }
172
+
173
+
174
+ def render_analysis_summary(summary: Mapping[str, int], load: AnalysisLoad) -> str:
175
+ """Render deterministic and semantic findings, kept clearly separate.
176
+
177
+ ``summary`` is the deterministic report's counts mapping — either
178
+ ``Report.summary()`` or the ``summary`` key of a parsed JSON report.
179
+ Structurally valid JSON missing the count keys is malformed input
180
+ ([SC-5]): raise a one-line ``ValueError`` for the CLI to map to exit 2,
181
+ never a ``KeyError`` traceback (known fable defect fixed at port time).
182
+ """
183
+
184
+ # The [SC-6] summary contract has six count keys, not just the three
185
+ # this renderer happens to print.
186
+ count_keys = (
187
+ "spec_sections",
188
+ "code_refs",
189
+ "spec_mappings",
190
+ "errors",
191
+ "warnings",
192
+ "infos",
193
+ )
194
+ missing = [key for key in count_keys if key not in summary]
195
+ if missing:
196
+ msg = (
197
+ "deterministic report summary is missing required count"
198
+ f" keys: {', '.join(missing)}"
199
+ )
200
+ raise ValueError(msg)
201
+ bad = [
202
+ key
203
+ for key in count_keys
204
+ if isinstance(summary[key], bool)
205
+ or not isinstance(summary[key], int)
206
+ or summary[key] < 0
207
+ ]
208
+ if bad:
209
+ # Counts are non-negative integers; anything else would render a
210
+ # nonsense line like "[] warnings" instead of failing the input.
211
+ msg = f"deterministic report summary has non-count values for: {', '.join(bad)}"
212
+ raise ValueError(msg)
213
+
214
+ lines = [
215
+ "backstitch analysis summary",
216
+ "",
217
+ (
218
+ f"deterministic: {summary['errors']} errors,"
219
+ f" {summary['warnings']} warnings, {summary['infos']} infos"
220
+ " (unchanged by semantic analysis)"
221
+ ),
222
+ "",
223
+ "semantic findings (advisory):",
224
+ ]
225
+ if load.results:
226
+ counts: dict[str, int] = {}
227
+ for result in load.results:
228
+ counts[result.classification] = counts.get(result.classification, 0) + 1
229
+ lines.append(
230
+ " " + ", ".join(f"{counts[c]} {c}" for c in CLASSIFICATIONS if c in counts)
231
+ )
232
+ for result in load.results:
233
+ confidence = (
234
+ f" (confidence {result.confidence:.2f})"
235
+ if result.confidence is not None
236
+ else ""
237
+ )
238
+ lines.append(
239
+ f" {result.packet_id} [{result.classification}]"
240
+ f" {result.summary}{confidence}"
241
+ )
242
+ else:
243
+ lines.append(" none")
244
+ if load.errors:
245
+ lines.append("")
246
+ lines.append("analysis input problems:")
247
+ lines.extend(f" {error}" for error in load.errors)
248
+ return "\n".join(lines) + "\n"