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 +7 -0
- backstitch/__main__.py +8 -0
- backstitch/analysis_llm.py +214 -0
- backstitch/analysis_packets.py +202 -0
- backstitch/analysis_results.py +248 -0
- backstitch/cli.py +873 -0
- backstitch/config.py +51 -0
- backstitch/exclusions.py +526 -0
- backstitch/grammar.py +49 -0
- backstitch/markdown_specs.py +345 -0
- backstitch/models.py +183 -0
- backstitch/profiles.py +34 -0
- backstitch/prompts/backstitch_style_analysis.md +36 -0
- backstitch/py.typed +1 -0
- backstitch/python_refs.py +380 -0
- backstitch/reporting.py +90 -0
- backstitch/resolver.py +926 -0
- backstitch/settings.py +620 -0
- backstitch/target_roots.py +88 -0
- backstitch-0.1.0.dist-info/METADATA +104 -0
- backstitch-0.1.0.dist-info/RECORD +24 -0
- backstitch-0.1.0.dist-info/WHEEL +4 -0
- backstitch-0.1.0.dist-info/entry_points.txt +2 -0
- backstitch-0.1.0.dist-info/licenses/LICENSE +21 -0
backstitch/__init__.py
ADDED
backstitch/__main__.py
ADDED
|
@@ -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"
|