backspec 0.0.1__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.
- backspec/__init__.py +16 -0
- backspec/host/__init__.py +0 -0
- backspec/host/adapters/__init__.py +0 -0
- backspec/host/adapters/cache.py +103 -0
- backspec/host/adapters/cache_admin.py +64 -0
- backspec/host/adapters/capabilities_file.py +39 -0
- backspec/host/adapters/claude_code.py +77 -0
- backspec/host/adapters/codex.py +67 -0
- backspec/host/adapters/fake.py +39 -0
- backspec/host/adapters/filesystem.py +50 -0
- backspec/host/adapters/review_yaml.py +33 -0
- backspec/host/adapters/treesitter.py +130 -0
- backspec/host/cli.py +313 -0
- backspec/host/composition.py +211 -0
- backspec/kernel/__init__.py +0 -0
- backspec/kernel/llm.py +64 -0
- backspec/kernel/prompts.py +10 -0
- backspec/kernel/templates.py +12 -0
- backspec/kernel/text.py +23 -0
- backspec/kernel/usage.py +50 -0
- backspec/modules/__init__.py +0 -0
- backspec/modules/capabilities/__init__.py +0 -0
- backspec/modules/capabilities/domain.py +108 -0
- backspec/modules/capabilities/facade.py +48 -0
- backspec/modules/capabilities/ports.py +21 -0
- backspec/modules/capabilities/use_cases.py +63 -0
- backspec/modules/discovery/__init__.py +0 -0
- backspec/modules/discovery/domain.py +87 -0
- backspec/modules/discovery/facade.py +16 -0
- backspec/modules/discovery/ports.py +14 -0
- backspec/modules/discovery/use_cases.py +19 -0
- backspec/modules/evidence/__init__.py +0 -0
- backspec/modules/evidence/domain.py +276 -0
- backspec/modules/evidence/facade.py +24 -0
- backspec/modules/evidence/ports.py +12 -0
- backspec/modules/evidence/use_cases.py +128 -0
- backspec/modules/extraction/__init__.py +0 -0
- backspec/modules/extraction/facade.py +6 -0
- backspec/modules/extraction/use_cases.py +228 -0
- backspec/modules/lint/__init__.py +0 -0
- backspec/modules/lint/domain.py +152 -0
- backspec/modules/lint/facade.py +7 -0
- backspec/modules/lint/use_cases.py +16 -0
- backspec/modules/report/__init__.py +0 -0
- backspec/modules/report/domain.py +128 -0
- backspec/modules/report/dto.py +92 -0
- backspec/modules/report/facade.py +11 -0
- backspec/modules/report/renderers.py +220 -0
- backspec/modules/report/use_cases.py +127 -0
- backspec/modules/requirements/__init__.py +0 -0
- backspec/modules/requirements/domain.py +272 -0
- backspec/modules/requirements/facade.py +26 -0
- backspec/modules/requirements/use_cases.py +135 -0
- backspec/modules/review/__init__.py +0 -0
- backspec/modules/review/domain.py +129 -0
- backspec/modules/review/facade.py +32 -0
- backspec/modules/review/ports.py +15 -0
- backspec/modules/review/use_cases.py +89 -0
- backspec/modules/staleness/__init__.py +0 -0
- backspec/modules/staleness/domain.py +75 -0
- backspec/modules/staleness/facade.py +17 -0
- backspec/modules/staleness/ports.py +7 -0
- backspec/modules/staleness/use_cases.py +39 -0
- backspec/modules/triage/__init__.py +0 -0
- backspec/modules/triage/domain.py +144 -0
- backspec/modules/triage/facade.py +24 -0
- backspec/modules/triage/use_cases.py +79 -0
- backspec/prompts/assess.md +32 -0
- backspec/prompts/assign.md +18 -0
- backspec/prompts/consolidate.md +52 -0
- backspec/prompts/observe.md +52 -0
- backspec/prompts/orient.md +33 -0
- backspec/prompts/synthesise.md +97 -0
- backspec/prompts/triage.md +34 -0
- backspec/py.typed +0 -0
- backspec/templates/libre-baskerville-400.woff2 +0 -0
- backspec/templates/libre-baskerville-700.woff2 +0 -0
- backspec/templates/libre-baskerville-OFL.txt +93 -0
- backspec/templates/report.html +336 -0
- backspec-0.0.1.dist-info/METADATA +430 -0
- backspec-0.0.1.dist-info/RECORD +85 -0
- backspec-0.0.1.dist-info/WHEEL +4 -0
- backspec-0.0.1.dist-info/entry_points.txt +2 -0
- backspec-0.0.1.dist-info/licenses/LICENSE +21 -0
- backspec-0.0.1.dist-info/licenses/src/backspec/templates/libre-baskerville-OFL.txt +93 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""The pipeline: discover, triage, observe and verify, orient, assign, synthesise, assess, report.
|
|
2
|
+
|
|
3
|
+
The program drives. A model is asked to read and to write statements; structure, citations and
|
|
4
|
+
merging are done here and in the modules this composes.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import collections
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Callable
|
|
11
|
+
|
|
12
|
+
from backspec.kernel.llm import LLMError
|
|
13
|
+
from backspec.kernel.prompts import PROMPT_VERSION
|
|
14
|
+
from backspec.kernel.usage import Spend
|
|
15
|
+
from backspec.modules.capabilities.facade import PLUMBING, UNASSIGNED, CapabilitiesFacade, Capability
|
|
16
|
+
from backspec.modules.discovery.facade import DiscoveryFacade
|
|
17
|
+
from backspec.modules.evidence.facade import EvidenceFacade
|
|
18
|
+
from backspec.modules.lint.facade import lint_findings
|
|
19
|
+
from backspec.modules.report.facade import Diagnostics, ExtractionResult, Run, StageUsage, build_coverage, earlier_evidence, earlier_triage
|
|
20
|
+
from backspec.modules.requirements.facade import Requirement, RequirementsFacade
|
|
21
|
+
from backspec.modules.review.facade import ReviewFacade, ReviewSummary
|
|
22
|
+
from backspec.modules.triage.facade import DEFAULT_AUDIT, TriageFacade
|
|
23
|
+
|
|
24
|
+
TOOL_VERSION = "0.0.1"
|
|
25
|
+
Progress = Callable[[str], None]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Settings:
|
|
30
|
+
include: list[str] = field(default_factory=list)
|
|
31
|
+
exclude: list[str] = field(default_factory=list)
|
|
32
|
+
default_excludes: bool = True
|
|
33
|
+
assess_support: bool = True
|
|
34
|
+
gate: bool = False # stop once the capabilities are settled, before assignment and synthesis
|
|
35
|
+
triage: bool = True # a cheap first look; files surely of no relevance are not read in full
|
|
36
|
+
triage_audit: float = DEFAULT_AUDIT # share of the skipped files read anyway; 0 is off, 1.0 reads them all
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def run(root: str, provider: str, settings: Settings, discovery: DiscoveryFacade, evidence: EvidenceFacade,
|
|
40
|
+
capabilities: CapabilitiesFacade, requirements: RequirementsFacade,
|
|
41
|
+
usage: Callable[[], tuple[int, int]] = lambda: (0, 0), progress: Progress = lambda _: None,
|
|
42
|
+
review: ReviewFacade | None = None, before: ExtractionResult | None = None,
|
|
43
|
+
reuse: bool = True, spend: Callable[[], Spend] = Spend, triage: TriageFacade | None = None) -> ExtractionResult:
|
|
44
|
+
"""`before` is the previous result, if there is one. Requirement ids are carried forward from
|
|
45
|
+
it, and with `reuse` so is everything whose inputs have not changed: observations of unchanged
|
|
46
|
+
files, the capabilities, where each observation was placed, and the requirements of any
|
|
47
|
+
capability whose observations are the same. A model is asked only about what is new."""
|
|
48
|
+
# An unusable capabilities file is refused before any file is read or model called.
|
|
49
|
+
from_file = capabilities.load()
|
|
50
|
+
kept = before if reuse else None
|
|
51
|
+
found = discovery.discover(settings.include, settings.exclude, settings.default_excludes)
|
|
52
|
+
progress(f"discovered {len(found.files)} files to read, {len(found.left)} left out")
|
|
53
|
+
|
|
54
|
+
triaged = None
|
|
55
|
+
if settings.triage and triage is not None:
|
|
56
|
+
verdicts = earlier_triage(kept, triage.fingerprint()) if kept else None
|
|
57
|
+
triaged = triage.assess(list(found.files), verdicts, settings.triage_audit)
|
|
58
|
+
progress(f"triaged {len(found.files)} files: {len(triaged.skipped)} skipped, "
|
|
59
|
+
f"{len(found.files) - len(triaged.skipped)} to read, {len(triaged.audited)} skipped files "
|
|
60
|
+
f"read anyway as an audit; {triaged.reused_files} verdicts reused, "
|
|
61
|
+
f"{len(triaged.unresolved)} without a verdict (read), in {triaged.batches} batches")
|
|
62
|
+
to_read = triaged.to_read(list(found.files)) if triaged else list(found.files)
|
|
63
|
+
|
|
64
|
+
earlier = earlier_evidence(kept, evidence.fingerprint()) if kept else None
|
|
65
|
+
observed = evidence.observe(to_read, earlier)
|
|
66
|
+
progress(f"observed {len(observed.observations)} verified, {len(observed.rejected)} rejected, "
|
|
67
|
+
f"in {observed.batches} batches; {observed.reused_files} unchanged files reused, "
|
|
68
|
+
f"{observed.partly_read_files} changed files read in part")
|
|
69
|
+
|
|
70
|
+
if triaged is not None and triaged.audited:
|
|
71
|
+
found_by_audit = {o.path for o in observed.observations} & set(triaged.audited)
|
|
72
|
+
progress(f"triage audit: {len(found_by_audit)} of {len(triaged.audited)} audited files had a verified observation")
|
|
73
|
+
|
|
74
|
+
caps: list[Capability] = []
|
|
75
|
+
assignments: dict[str, str] = {}
|
|
76
|
+
source = "oriented"
|
|
77
|
+
orient_failed = ""
|
|
78
|
+
if from_file is not None:
|
|
79
|
+
caps, source = from_file, "file"
|
|
80
|
+
progress(f"using {len(caps)} capabilities from {capabilities.location}")
|
|
81
|
+
elif kept and any(c.id not in (PLUMBING, UNASSIGNED) for c in kept.capabilities):
|
|
82
|
+
caps, source = [c for c in kept.capabilities if c.id != UNASSIGNED], "previous"
|
|
83
|
+
progress(f"using {len(caps)} capabilities from the previous result")
|
|
84
|
+
elif observed.observations:
|
|
85
|
+
try:
|
|
86
|
+
caps = capabilities.orient(observed.observations)
|
|
87
|
+
progress(f"oriented into {len(caps)} capabilities")
|
|
88
|
+
if settings.gate:
|
|
89
|
+
capabilities.save(caps)
|
|
90
|
+
progress(f"capabilities written to {capabilities.location}")
|
|
91
|
+
except LLMError as error: # nothing can be placed without capabilities: record it and stop
|
|
92
|
+
orient_failed = str(error)[:300]
|
|
93
|
+
|
|
94
|
+
unresolved = dict(triaged.unresolved) if triaged else {}
|
|
95
|
+
|
|
96
|
+
def describe(status: str, **more: object) -> Run:
|
|
97
|
+
calls, cached = usage()
|
|
98
|
+
used = spend()
|
|
99
|
+
return Run(status=status, tool_version=TOOL_VERSION, prompt_version=PROMPT_VERSION, provider=provider,
|
|
100
|
+
root=root, calls=calls, cached_calls=cached, capabilities_source=source,
|
|
101
|
+
input_tokens=used.input_tokens, output_tokens=used.output_tokens,
|
|
102
|
+
tokens_estimated=used.estimated, cost_usd=used.cost_usd, budget_reached=used.budget_reached,
|
|
103
|
+
usage_by_stage={s: StageUsage(**u) for s, u in used.by_stage.items()},
|
|
104
|
+
observe_fingerprint=evidence.fingerprint(), reused_files=observed.reused_files, **_analysis_counts(observed),
|
|
105
|
+
partly_read_files=observed.partly_read_files, synthesis_fingerprint=requirements.fingerprint(),
|
|
106
|
+
triage_fingerprint=triage.fingerprint() if triaged is not None and triage else "", **more)
|
|
107
|
+
|
|
108
|
+
all_observations = sorted(observed.observations, key=lambda o: (o.path, o.lines[0], o.id))
|
|
109
|
+
if orient_failed:
|
|
110
|
+
progress(f"orient failed: {orient_failed}")
|
|
111
|
+
return ExtractionResult(
|
|
112
|
+
run=describe("partial"), capabilities=[], requirements=[], observations=all_observations,
|
|
113
|
+
assignments={}, coverage=build_coverage(found, observed, [], {}),
|
|
114
|
+
diagnostics=Diagnostics(rejected=observed.rejected, failed_stages={"orient": orient_failed}))
|
|
115
|
+
if settings.gate:
|
|
116
|
+
# Stopped on purpose: the run is `partial` and says where it stopped.
|
|
117
|
+
progress("gate: stopping before assignment and synthesis; edit the file, then run again")
|
|
118
|
+
gated = ExtractionResult(
|
|
119
|
+
run=describe("partial", stopped_after="capabilities"), capabilities=caps, requirements=[],
|
|
120
|
+
observations=all_observations, assignments={}, coverage=build_coverage(found, observed, [], {}, triaged),
|
|
121
|
+
diagnostics=Diagnostics(rejected=observed.rejected, triage_unresolved=unresolved))
|
|
122
|
+
gated.diagnostics.lint = lint_findings(gated)
|
|
123
|
+
return gated
|
|
124
|
+
|
|
125
|
+
if observed.observations:
|
|
126
|
+
assignments = capabilities.assign(observed.observations, caps, kept.assignments if kept else None)
|
|
127
|
+
if UNASSIGNED in assignments.values():
|
|
128
|
+
caps.append(Capability(id=UNASSIGNED, name="Unassigned",
|
|
129
|
+
summary="Observations that were not placed in a capability."))
|
|
130
|
+
|
|
131
|
+
by_capability = collections.defaultdict(list)
|
|
132
|
+
for observation in observed.observations:
|
|
133
|
+
by_capability[assignments[observation.id]].append(observation)
|
|
134
|
+
product = [c for c in caps if c.id != PLUMBING]
|
|
135
|
+
|
|
136
|
+
# A capability whose observations are exactly those the previous result had keeps its requirements.
|
|
137
|
+
reused: list[Requirement] = []
|
|
138
|
+
reused_capabilities: list[str] = []
|
|
139
|
+
current = {o.id: o for o in observed.observations}
|
|
140
|
+
open_questions: dict[str, list[str]] = {}
|
|
141
|
+
if kept and kept.run.stopped_after is None and kept.run.synthesis_fingerprint == requirements.fingerprint():
|
|
142
|
+
placed_before = collections.defaultdict(set)
|
|
143
|
+
for observation_id, capability_id in kept.assignments.items():
|
|
144
|
+
placed_before[capability_id].add(observation_id)
|
|
145
|
+
made_before = collections.defaultdict(list)
|
|
146
|
+
for requirement in [*kept.requirements, *kept.hidden]:
|
|
147
|
+
made_before[requirement.capability].append(requirement)
|
|
148
|
+
unsettled = {*kept.diagnostics.failed_capabilities, *kept.diagnostics.unconsolidated}
|
|
149
|
+
for capability in product:
|
|
150
|
+
now = {o.id for o in by_capability.get(capability.id, [])}
|
|
151
|
+
if now and now == placed_before.get(capability.id) and capability.id not in unsettled:
|
|
152
|
+
reused_capabilities.append(capability.id)
|
|
153
|
+
# review is applied afresh below, so a decision withdrawn since then is withdrawn here too
|
|
154
|
+
reused += [_refreshed(r, current) for r in made_before[capability.id]]
|
|
155
|
+
if capability.id in kept.diagnostics.open_questions:
|
|
156
|
+
open_questions[capability.id] = kept.diagnostics.open_questions[capability.id]
|
|
157
|
+
synthesis = requirements.synthesise([c for c in product if c.id not in reused_capabilities], by_capability)
|
|
158
|
+
open_questions.update(synthesis.open_questions)
|
|
159
|
+
progress(f"synthesised {len(synthesis.requirements)} requirements; "
|
|
160
|
+
f"{len(reused)} kept from {len(reused_capabilities)} unchanged capabilities")
|
|
161
|
+
|
|
162
|
+
final = [*reused, *synthesis.requirements]
|
|
163
|
+
if settings.assess_support:
|
|
164
|
+
unjudged = [r for r in final if r.support == "not_assessed"]
|
|
165
|
+
if unjudged:
|
|
166
|
+
judged = {r.id: r for r in requirements.assess(unjudged, {f.path: f.lines for f in found.files})}
|
|
167
|
+
final = [judged.get(r.id, r) for r in final]
|
|
168
|
+
counts = collections.Counter(r.support for r in final)
|
|
169
|
+
progress("assessed support: " + ", ".join(f"{n} {s}" for s, n in sorted(counts.items())))
|
|
170
|
+
review_summary: ReviewSummary | None = None
|
|
171
|
+
hidden: list[Requirement] = []
|
|
172
|
+
previous = [*before.requirements, *before.hidden] if before else None
|
|
173
|
+
if review is not None and review.applies(previous):
|
|
174
|
+
reviewed = review.apply(final, previous)
|
|
175
|
+
final, review_summary, hidden = reviewed.requirements, reviewed.summary, reviewed.hidden
|
|
176
|
+
progress(f"review: {review_summary.carried} ids carried, {review_summary.rejected_hidden} rejected hidden, "
|
|
177
|
+
f"{review_summary.needs_review} need review, {len(review_summary.stale_entries)} stale entries")
|
|
178
|
+
final = sorted(final, key=lambda r: (r.capability, r.id))
|
|
179
|
+
|
|
180
|
+
complete = not observed.failed and not synthesis.failed and not spend().budget_reached
|
|
181
|
+
result = ExtractionResult(
|
|
182
|
+
run=describe("complete" if complete else "partial", reused_capabilities=sorted(reused_capabilities)),
|
|
183
|
+
capabilities=caps, requirements=final, hidden=sorted(hidden, key=lambda r: (r.capability, r.id)),
|
|
184
|
+
observations=all_observations, assignments=assignments,
|
|
185
|
+
coverage=build_coverage(found, observed, [*final, *hidden], assignments, triaged),
|
|
186
|
+
diagnostics=Diagnostics(rejected=observed.rejected, failed_capabilities=synthesis.failed,
|
|
187
|
+
unconsolidated=synthesis.unconsolidated,
|
|
188
|
+
dropped_scenarios=synthesis.dropped_scenarios, open_questions=open_questions,
|
|
189
|
+
review=review_summary, triage_unresolved=unresolved))
|
|
190
|
+
result.diagnostics.lint = lint_findings(result)
|
|
191
|
+
return result
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _analysis_counts(observed) -> dict[str, int]:
|
|
195
|
+
modes = list(observed.analysis.values())
|
|
196
|
+
return {"syntax_files": modes.count("syntax"), "text_files": modes.count("text")}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _refreshed(requirement: Requirement, current: dict) -> Requirement:
|
|
200
|
+
"""A kept requirement with its evidence pointing at where the cited code is now. The lines
|
|
201
|
+
may have moved since it was written; the cited text has not changed."""
|
|
202
|
+
def moved(evidence: list) -> list:
|
|
203
|
+
return [e.model_copy(update={"path": current[e.observation].path, "lines": current[e.observation].lines,
|
|
204
|
+
"quote": current[e.observation].quote,
|
|
205
|
+
"symbol": getattr(current[e.observation], "symbol", None)})
|
|
206
|
+
if e.observation in current else e for e in evidence]
|
|
207
|
+
scenarios = [s.model_copy(update={"evidence": moved(s.evidence)}) for s in requirement.scenarios]
|
|
208
|
+
return requirement.model_copy(update={"review": "unreviewed", "evidence": moved(requirement.evidence),
|
|
209
|
+
"scenarios": scenarios})
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def orient_only(settings: Settings, discovery: DiscoveryFacade, evidence: EvidenceFacade,
|
|
213
|
+
capabilities: CapabilitiesFacade, force: bool = False,
|
|
214
|
+
progress: Progress = lambda _: None) -> list[Capability]:
|
|
215
|
+
"""Discover, observe, orient and write the capabilities file; nothing after that.
|
|
216
|
+
Refuses to replace an existing file unless `force`, before any model is called."""
|
|
217
|
+
if capabilities.exists() and not force:
|
|
218
|
+
raise FileExistsError(f"{capabilities.location} already exists; pass --force to replace it")
|
|
219
|
+
found = discovery.discover(settings.include, settings.exclude, settings.default_excludes)
|
|
220
|
+
progress(f"discovered {len(found.files)} files to read, {len(found.left)} left out")
|
|
221
|
+
observed = evidence.observe(list(found.files))
|
|
222
|
+
progress(f"observed {len(observed.observations)} verified, {len(observed.rejected)} rejected")
|
|
223
|
+
if not observed.observations:
|
|
224
|
+
raise ValueError("no observations were verified, so there is nothing to orient from; no file was written")
|
|
225
|
+
caps = capabilities.orient(observed.observations)
|
|
226
|
+
capabilities.save(caps)
|
|
227
|
+
progress(f"capabilities written to {capabilities.location}")
|
|
228
|
+
return caps
|
|
File without changes
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Lint: deterministic checks on a result. No model is involved.
|
|
2
|
+
|
|
3
|
+
A finding is an `error` when the result contradicts itself (a citation that points at nothing, a
|
|
4
|
+
duplicate id) and a `warning` when it is merely suspect (a vague or compound statement). The
|
|
5
|
+
checks read a result only through the small protocols below, so this module needs nothing of the
|
|
6
|
+
report that holds the result.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Literal, Protocol, Sequence
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, Field
|
|
13
|
+
|
|
14
|
+
from backspec.kernel.text import squash
|
|
15
|
+
|
|
16
|
+
Severity = Literal["error", "warning"]
|
|
17
|
+
MIN_STATEMENT = 25
|
|
18
|
+
PLUMBING, UNASSIGNED = "plumbing", "unassigned"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Finding(BaseModel):
|
|
22
|
+
code: str
|
|
23
|
+
severity: Severity
|
|
24
|
+
ids: list[str] = Field(default_factory=list) # the requirement, capability or observation ids concerned
|
|
25
|
+
message: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class LintReport(BaseModel):
|
|
29
|
+
findings: list[Finding] = Field(default_factory=list)
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def errors(self) -> list[Finding]:
|
|
33
|
+
return [f for f in self.findings if f.severity == "error"]
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def warnings(self) -> list[Finding]:
|
|
37
|
+
return [f for f in self.findings if f.severity == "warning"]
|
|
38
|
+
|
|
39
|
+
def failed(self, strict: bool = False) -> bool:
|
|
40
|
+
return bool(self.errors or (strict and self.warnings))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _Named(Protocol):
|
|
44
|
+
id: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class _Evidence(Protocol):
|
|
48
|
+
observation: str
|
|
49
|
+
role: str
|
|
50
|
+
path: str
|
|
51
|
+
lines: tuple[int, int]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class _Requirement(Protocol):
|
|
55
|
+
id: str
|
|
56
|
+
capability: str
|
|
57
|
+
statement: str
|
|
58
|
+
support: str
|
|
59
|
+
evidence: Sequence[_Evidence]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class _Observation(Protocol):
|
|
63
|
+
id: str
|
|
64
|
+
path: str
|
|
65
|
+
lines: tuple[int, int]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class _Run(Protocol):
|
|
69
|
+
stopped_after: str | None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Linted(Protocol):
|
|
73
|
+
run: _Run
|
|
74
|
+
capabilities: Sequence[_Named]
|
|
75
|
+
requirements: Sequence[_Requirement]
|
|
76
|
+
observations: Sequence[_Observation]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def normalise(statement: str) -> str:
|
|
80
|
+
return squash(statement).lower()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def joins_obligations(statement: str) -> bool:
|
|
84
|
+
return "; " in statement or " and also " in statement or statement.count(" and ") > 2
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def check(result: Linted) -> list[Finding]:
|
|
88
|
+
found: list[Finding] = []
|
|
89
|
+
capability_ids = {c.id for c in result.capabilities}
|
|
90
|
+
observations = {o.id: o for o in result.observations}
|
|
91
|
+
|
|
92
|
+
def add(code: str, severity: Severity, ids: list[str], message: str) -> None:
|
|
93
|
+
found.append(Finding(code=code, severity=severity, ids=ids, message=message))
|
|
94
|
+
|
|
95
|
+
seen: dict[str, int] = {}
|
|
96
|
+
for requirement in result.requirements:
|
|
97
|
+
seen[requirement.id] = seen.get(requirement.id, 0) + 1
|
|
98
|
+
for requirement_id, count in seen.items():
|
|
99
|
+
if count > 1:
|
|
100
|
+
add("duplicate_requirement_id", "error", [requirement_id], f"{count} requirements share the id {requirement_id}")
|
|
101
|
+
|
|
102
|
+
for r in result.requirements:
|
|
103
|
+
if r.capability not in capability_ids:
|
|
104
|
+
add("unknown_capability", "error", [r.id, r.capability],
|
|
105
|
+
f"{r.id} names the capability {r.capability!r}, which the result does not have")
|
|
106
|
+
if not r.evidence:
|
|
107
|
+
add("no_evidence", "error", [r.id], f"{r.id} has no evidence")
|
|
108
|
+
for e in r.evidence:
|
|
109
|
+
seen_observation = observations.get(e.observation)
|
|
110
|
+
if seen_observation is None:
|
|
111
|
+
add("unknown_observation", "error", [r.id, e.observation],
|
|
112
|
+
f"{r.id} cites {e.observation}, which is not among the observations")
|
|
113
|
+
elif (e.path, tuple(e.lines)) != (seen_observation.path, tuple(seen_observation.lines)):
|
|
114
|
+
add("evidence_mismatch", "error", [r.id, e.observation],
|
|
115
|
+
f"{r.id}: evidence says {e.path}:{e.lines[0]}-{e.lines[1]} but {e.observation} is "
|
|
116
|
+
f"{seen_observation.path}:{seen_observation.lines[0]}-{seen_observation.lines[1]}")
|
|
117
|
+
|
|
118
|
+
if result.run.stopped_after is None:
|
|
119
|
+
used = {r.capability for r in result.requirements}
|
|
120
|
+
for capability in result.capabilities:
|
|
121
|
+
if capability.id not in (PLUMBING, UNASSIGNED) and capability.id not in used:
|
|
122
|
+
add("capability_without_requirements", "warning", [capability.id],
|
|
123
|
+
f"capability {capability.id} has no requirements")
|
|
124
|
+
|
|
125
|
+
by_statement: dict[tuple[str, str], list[str]] = {}
|
|
126
|
+
for r in result.requirements:
|
|
127
|
+
by_statement.setdefault((r.capability, normalise(r.statement)), []).append(r.id)
|
|
128
|
+
for (capability, _), ids in by_statement.items():
|
|
129
|
+
if len(ids) > 1:
|
|
130
|
+
add("duplicate_statement", "warning", ids, f"{', '.join(ids)} say the same thing in {capability}")
|
|
131
|
+
|
|
132
|
+
for r in result.requirements:
|
|
133
|
+
if len(r.statement.strip()) < MIN_STATEMENT:
|
|
134
|
+
add("short_statement", "warning", [r.id], f"{r.id}: the statement is shorter than {MIN_STATEMENT} characters")
|
|
135
|
+
if joins_obligations(r.statement):
|
|
136
|
+
add("compound_statement", "warning", [r.id], f"{r.id}: the statement probably joins several obligations; consider splitting it")
|
|
137
|
+
if r.evidence and all(e.role == "context" for e in r.evidence):
|
|
138
|
+
add("context_only_evidence", "warning", [r.id], f"{r.id}: all of its evidence is context, none establishes it")
|
|
139
|
+
if r.support in ("insufficient", "conflicting"):
|
|
140
|
+
add("weak_support", "warning", [r.id], f"{r.id}: the support check found it {r.support}")
|
|
141
|
+
return found
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def summary(report: LintReport) -> str:
|
|
145
|
+
"""Findings grouped by severity, then a one-line count."""
|
|
146
|
+
lines: list[str] = []
|
|
147
|
+
for title, items in (("errors", report.errors), ("warnings", report.warnings)):
|
|
148
|
+
if items:
|
|
149
|
+
lines.append(f"{title}:")
|
|
150
|
+
lines += [f" {f.code} {f.message}" for f in items]
|
|
151
|
+
lines.append(f"{len(report.errors)} errors, {len(report.warnings)} warnings")
|
|
152
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Lint, as the rest of backspec sees it."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from backspec.modules.lint.domain import Finding, Linted, LintReport, Severity, summary
|
|
5
|
+
from backspec.modules.lint.use_cases import lint, lint_findings
|
|
6
|
+
|
|
7
|
+
__all__ = ["Finding", "Linted", "LintReport", "Severity", "lint", "lint_findings", "summary"]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Run the checks on a result."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from backspec.modules.lint.domain import Finding, Linted, LintReport, check
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def lint(result: Linted) -> LintReport:
|
|
8
|
+
return LintReport(findings=check(result))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def lint_findings(result: Linted) -> list[Finding]:
|
|
12
|
+
"""The findings for a run. Lint is an aid: whatever goes wrong inside it never fails a run."""
|
|
13
|
+
try:
|
|
14
|
+
return check(result)
|
|
15
|
+
except Exception:
|
|
16
|
+
return []
|
|
File without changes
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""The per-file ledger. Every file found ends in exactly one state, with a reason.
|
|
2
|
+
|
|
3
|
+
This is processing coverage: what was read. It says nothing about whether the requirements in
|
|
4
|
+
a file were found.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
State = Literal["excluded", "skipped", "extracted", "no_requirements", "candidates_rejected", "failed",
|
|
15
|
+
"triaged_out"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class FileCoverage(BaseModel):
|
|
19
|
+
path: str
|
|
20
|
+
state: State
|
|
21
|
+
reason: str = ""
|
|
22
|
+
observations: int = 0
|
|
23
|
+
rejected: int = 0
|
|
24
|
+
lines: int = 0 # length of the file as it was read, for files that were read
|
|
25
|
+
cited_by: int = 0 # requirements that cite this file
|
|
26
|
+
source_hash: str = "" # the file as it was read, for files that were read
|
|
27
|
+
analysis: Literal["syntax", "text"] = "text" # how a file that was read was analysed
|
|
28
|
+
line_digest: str = "" # a short digest per line, so a later run can tell which lines changed
|
|
29
|
+
triage_role: str = "" # the role triage gave the file; empty if it was not triaged
|
|
30
|
+
triage_relevance: str = "" # high, medium, low, none or unknown
|
|
31
|
+
triage_skipped: bool = False # triage judged it surely irrelevant, so it was not read unless audited
|
|
32
|
+
triage_audited: bool = False # skipped by triage but read anyway, as part of the audit
|
|
33
|
+
found_by_audit: bool = False # audited, and a verified observation came of it
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def file_state(observations: int, rejected: int, failed: str | None, nothing_reason: str | None) -> tuple[State, str]:
|
|
37
|
+
"""The state of a file that was sent to the observe stage."""
|
|
38
|
+
if failed:
|
|
39
|
+
return "failed", failed
|
|
40
|
+
if observations:
|
|
41
|
+
return "extracted", ""
|
|
42
|
+
if rejected:
|
|
43
|
+
return "candidates_rejected", f"{rejected} observation(s) failed the citation check"
|
|
44
|
+
return "no_requirements", nothing_reason or "the model reported nothing for this file"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class RenderOutput:
|
|
49
|
+
"""What a renderer returns: one text document, or a set of files (relative path -> text)."""
|
|
50
|
+
text: str | None = None
|
|
51
|
+
files: dict[str, str] = field(default_factory=dict)
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def is_tree(self) -> bool:
|
|
55
|
+
return self.text is None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
TITLE_MAX = 60
|
|
59
|
+
_CLAUSE_END = re.compile(r"[,;:]\s|\s[-\u2013\u2014]\s|[.!?]\s")
|
|
60
|
+
_TRAILING = " \t\n.,;:!?-\u2013\u2014"
|
|
61
|
+
_MODAL = re.compile(r"\b(SHALL|MUST)\b")
|
|
62
|
+
_LOWER_MODAL = re.compile(r"\b(shall|must)\b", re.I)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def one_line(text: str) -> str:
|
|
66
|
+
"""The text on one line, runs of white space made single spaces."""
|
|
67
|
+
return " ".join(text.split())
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def requirement_title(statement: str) -> str:
|
|
71
|
+
"""A short title from a statement: its first clause, cut at a word to at most TITLE_MAX
|
|
72
|
+
characters, with no trailing punctuation."""
|
|
73
|
+
text = one_line(statement)
|
|
74
|
+
match = _CLAUSE_END.search(text)
|
|
75
|
+
clause = text[:match.start()] if match else text
|
|
76
|
+
clause = clause.strip(_TRAILING) or text.strip(_TRAILING) or text
|
|
77
|
+
if len(clause) > TITLE_MAX:
|
|
78
|
+
cut = clause[:TITLE_MAX + 1]
|
|
79
|
+
clause = cut[:cut.rfind(" ")] if " " in cut[1:] else cut[:TITLE_MAX]
|
|
80
|
+
clause = clause.strip(_TRAILING) or clause
|
|
81
|
+
return clause
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def with_shall(statement: str) -> str:
|
|
85
|
+
"""The statement rewritten only as far as needed to contain SHALL or MUST. One that has
|
|
86
|
+
them is unchanged; one with a lower-case shall or must has it upper-cased; any other is
|
|
87
|
+
wrapped, never reworded."""
|
|
88
|
+
text = one_line(statement)
|
|
89
|
+
if _MODAL.search(text):
|
|
90
|
+
return text
|
|
91
|
+
if _LOWER_MODAL.search(text):
|
|
92
|
+
return _LOWER_MODAL.sub(lambda m: m.group(1).upper(), text)
|
|
93
|
+
return f"The system SHALL ensure: {text}"
|
|
94
|
+
WORTH_A_LOOK_LINES = 80
|
|
95
|
+
TRUNCATE_AT = 30
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class CoverageReport(BaseModel):
|
|
99
|
+
"""A saved result's coverage, read for people: what happened to each file and what to look at."""
|
|
100
|
+
counts: dict[str, int]
|
|
101
|
+
files: dict[str, list[FileCoverage]] # state -> files, every state but `excluded`
|
|
102
|
+
unused_observations: dict[str, int] # capability -> observations no requirement cites
|
|
103
|
+
worth_a_look: list[FileCoverage]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def worth_looking_at(entry: FileCoverage) -> bool:
|
|
107
|
+
"""A file that was read, is long, and yielded nothing or was never cited."""
|
|
108
|
+
return bool(entry.source_hash) and entry.lines > WORTH_A_LOOK_LINES and (
|
|
109
|
+
entry.observations == 0 or entry.cited_by == 0)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _clip(lines: list[str], show_all: bool) -> list[str]:
|
|
113
|
+
if show_all or len(lines) <= TRUNCATE_AT:
|
|
114
|
+
return lines
|
|
115
|
+
return lines[:TRUNCATE_AT] + [f" ... and {len(lines) - TRUNCATE_AT} more (--all shows every line)"]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def coverage_text(report: CoverageReport, show_all: bool = False) -> str:
|
|
119
|
+
out = ["files per state: " + (", ".join(f"{n} {s}" for s, n in sorted(report.counts.items())) or "none")]
|
|
120
|
+
for state, entries in report.files.items():
|
|
121
|
+
out += ["", f"{state} ({len(entries)}):"]
|
|
122
|
+
out += _clip([f" {e.path}" + (f" ({e.reason})" if e.reason else "") for e in entries], show_all)
|
|
123
|
+
out += ["", "unused observations per capability:"]
|
|
124
|
+
out += _clip([f" {c}: {n}" for c, n in report.unused_observations.items()] or [" none"], show_all)
|
|
125
|
+
out += ["", "worth a look (read, over %d lines, no observation or cited by no requirement):" % WORTH_A_LOOK_LINES]
|
|
126
|
+
out += _clip([f" {e.path} ({e.lines} lines, {e.observations} observations, cited by {e.cited_by})"
|
|
127
|
+
for e in report.worth_a_look] or [" none"], show_all)
|
|
128
|
+
return "\n".join(out)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""The result of a run: the one document backspec writes. JSON is its canonical form."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
from backspec.modules.capabilities.facade import Capability
|
|
9
|
+
from backspec.modules.evidence.facade import Observation, Rejected
|
|
10
|
+
from backspec.modules.lint.facade import Finding
|
|
11
|
+
from backspec.modules.report.domain import FileCoverage
|
|
12
|
+
from backspec.modules.requirements.facade import Requirement
|
|
13
|
+
from backspec.modules.review.facade import ReviewSummary
|
|
14
|
+
|
|
15
|
+
SCHEMA_VERSION = "0.1"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class StageUsage(BaseModel):
|
|
19
|
+
calls: int = 0 # replies the model produced
|
|
20
|
+
cached: int = 0 # replies served from the cache: they cost nothing
|
|
21
|
+
input_tokens: int = 0
|
|
22
|
+
output_tokens: int = 0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Run(BaseModel):
|
|
26
|
+
status: Literal["complete", "partial"]
|
|
27
|
+
tool_version: str
|
|
28
|
+
prompt_version: str
|
|
29
|
+
provider: str
|
|
30
|
+
root: str
|
|
31
|
+
calls: int = 0
|
|
32
|
+
cached_calls: int = 0
|
|
33
|
+
capabilities_source: Literal["file", "oriented", "previous"] = "oriented"
|
|
34
|
+
observe_fingerprint: str = "" # how files were observed; a later run reuses observations only if it matches
|
|
35
|
+
reused_files: int = 0 # files unchanged since the previous result, not read again
|
|
36
|
+
partly_read_files: int = 0 # files changed since the previous result: only the changed parts were read
|
|
37
|
+
syntax_files: int = 0 # files read with symbols from a parser
|
|
38
|
+
text_files: int = 0 # files read as plain text
|
|
39
|
+
synthesis_fingerprint: str = "" # how requirements were written and judged
|
|
40
|
+
triage_fingerprint: str = "" # how files were triaged; a later run reuses verdicts only if it matches
|
|
41
|
+
reused_capabilities: list[str] = Field(default_factory=list) # evidence unchanged, requirements kept
|
|
42
|
+
stopped_after: Literal["capabilities"] | None = None # set when a gated run stopped early
|
|
43
|
+
input_tokens: int = 0
|
|
44
|
+
output_tokens: int = 0
|
|
45
|
+
tokens_estimated: bool = False # true if any call's tokens were estimated, not reported
|
|
46
|
+
cost_usd: float | None = None # the backend's own cost figure, summed, when it reports one
|
|
47
|
+
usage_by_stage: dict[str, StageUsage] = Field(default_factory=dict)
|
|
48
|
+
budget_reached: bool = False # a call was not sent because the token budget was reached
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TriageAudit(BaseModel):
|
|
52
|
+
"""Whether triage threw real evidence away: some skipped files were read anyway."""
|
|
53
|
+
skipped: int = 0 # files triage skipped
|
|
54
|
+
audited: int = 0 # of those, read anyway and read without failure
|
|
55
|
+
yielded: int = 0 # audited files that had a verified observation
|
|
56
|
+
yielded_paths: list[str] = Field(default_factory=list)
|
|
57
|
+
failed_paths: list[str] = Field(default_factory=list) # audited files whose read failed; not counted in `audited`
|
|
58
|
+
miss_rate_interval: tuple[float, float] | None = None # 95% Clopper-Pearson for yielded / audited; null if none audited
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Coverage(BaseModel):
|
|
62
|
+
summary: dict[str, int]
|
|
63
|
+
files: list[FileCoverage]
|
|
64
|
+
triage_audit: TriageAudit | None = None # set when triage ran
|
|
65
|
+
unused_observations: dict[str, int] = Field(default_factory=dict) # capability -> count
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Diagnostics(BaseModel):
|
|
69
|
+
rejected: list[Rejected] = Field(default_factory=list)
|
|
70
|
+
failed_capabilities: dict[str, str] = Field(default_factory=dict)
|
|
71
|
+
unconsolidated: dict[str, str] = Field(default_factory=dict) # capability -> why its merge pass failed
|
|
72
|
+
dropped_scenarios: int = 0 # scenarios the model wrote that were left out (incomplete or uncited)
|
|
73
|
+
open_questions: dict[str, list[str]] = Field(default_factory=dict)
|
|
74
|
+
triage_unresolved: dict[str, str] = Field(default_factory=dict) # path -> why triage gave no verdict; the file was read
|
|
75
|
+
review: ReviewSummary | None = None # set when ids were carried forward or a review file applied
|
|
76
|
+
lint: list[Finding] = Field(default_factory=list) # deterministic checks on the finished result
|
|
77
|
+
failed_stages: dict[str, str] = Field(default_factory=dict) # a stage the whole run depends on -> why it failed
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ExtractionResult(BaseModel):
|
|
81
|
+
schema_version: str = SCHEMA_VERSION
|
|
82
|
+
run: Run
|
|
83
|
+
capabilities: list[Capability]
|
|
84
|
+
requirements: list[Requirement]
|
|
85
|
+
hidden: list[Requirement] = Field(default_factory=list) # rejected in review: kept here, left out of the catalogue
|
|
86
|
+
observations: list[Observation]
|
|
87
|
+
assignments: dict[str, str] = Field(default_factory=dict) # observation id -> capability id
|
|
88
|
+
coverage: Coverage
|
|
89
|
+
diagnostics: Diagnostics = Field(default_factory=Diagnostics)
|
|
90
|
+
|
|
91
|
+
def to_json(self) -> str:
|
|
92
|
+
return self.model_dump_json(indent=1)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""The report, as the rest of backspec sees it."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from backspec.modules.report.domain import CoverageReport, FileCoverage, RenderOutput, coverage_text
|
|
5
|
+
from backspec.modules.report.dto import Coverage, Diagnostics, ExtractionResult, Run, StageUsage
|
|
6
|
+
from backspec.modules.report.renderers import UnknownFormat, formats, render
|
|
7
|
+
from backspec.modules.report.use_cases import build_coverage, coverage_report, earlier_evidence, earlier_triage, to_markdown
|
|
8
|
+
|
|
9
|
+
__all__ = ["Coverage", "CoverageReport", "Diagnostics", "ExtractionResult", "FileCoverage", "RenderOutput", "Run", "StageUsage",
|
|
10
|
+
"UnknownFormat", "build_coverage", "coverage_report", "coverage_text", "earlier_evidence", "earlier_triage", "formats",
|
|
11
|
+
"render", "to_markdown"]
|