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.
Files changed (85) hide show
  1. backspec/__init__.py +16 -0
  2. backspec/host/__init__.py +0 -0
  3. backspec/host/adapters/__init__.py +0 -0
  4. backspec/host/adapters/cache.py +103 -0
  5. backspec/host/adapters/cache_admin.py +64 -0
  6. backspec/host/adapters/capabilities_file.py +39 -0
  7. backspec/host/adapters/claude_code.py +77 -0
  8. backspec/host/adapters/codex.py +67 -0
  9. backspec/host/adapters/fake.py +39 -0
  10. backspec/host/adapters/filesystem.py +50 -0
  11. backspec/host/adapters/review_yaml.py +33 -0
  12. backspec/host/adapters/treesitter.py +130 -0
  13. backspec/host/cli.py +313 -0
  14. backspec/host/composition.py +211 -0
  15. backspec/kernel/__init__.py +0 -0
  16. backspec/kernel/llm.py +64 -0
  17. backspec/kernel/prompts.py +10 -0
  18. backspec/kernel/templates.py +12 -0
  19. backspec/kernel/text.py +23 -0
  20. backspec/kernel/usage.py +50 -0
  21. backspec/modules/__init__.py +0 -0
  22. backspec/modules/capabilities/__init__.py +0 -0
  23. backspec/modules/capabilities/domain.py +108 -0
  24. backspec/modules/capabilities/facade.py +48 -0
  25. backspec/modules/capabilities/ports.py +21 -0
  26. backspec/modules/capabilities/use_cases.py +63 -0
  27. backspec/modules/discovery/__init__.py +0 -0
  28. backspec/modules/discovery/domain.py +87 -0
  29. backspec/modules/discovery/facade.py +16 -0
  30. backspec/modules/discovery/ports.py +14 -0
  31. backspec/modules/discovery/use_cases.py +19 -0
  32. backspec/modules/evidence/__init__.py +0 -0
  33. backspec/modules/evidence/domain.py +276 -0
  34. backspec/modules/evidence/facade.py +24 -0
  35. backspec/modules/evidence/ports.py +12 -0
  36. backspec/modules/evidence/use_cases.py +128 -0
  37. backspec/modules/extraction/__init__.py +0 -0
  38. backspec/modules/extraction/facade.py +6 -0
  39. backspec/modules/extraction/use_cases.py +228 -0
  40. backspec/modules/lint/__init__.py +0 -0
  41. backspec/modules/lint/domain.py +152 -0
  42. backspec/modules/lint/facade.py +7 -0
  43. backspec/modules/lint/use_cases.py +16 -0
  44. backspec/modules/report/__init__.py +0 -0
  45. backspec/modules/report/domain.py +128 -0
  46. backspec/modules/report/dto.py +92 -0
  47. backspec/modules/report/facade.py +11 -0
  48. backspec/modules/report/renderers.py +220 -0
  49. backspec/modules/report/use_cases.py +127 -0
  50. backspec/modules/requirements/__init__.py +0 -0
  51. backspec/modules/requirements/domain.py +272 -0
  52. backspec/modules/requirements/facade.py +26 -0
  53. backspec/modules/requirements/use_cases.py +135 -0
  54. backspec/modules/review/__init__.py +0 -0
  55. backspec/modules/review/domain.py +129 -0
  56. backspec/modules/review/facade.py +32 -0
  57. backspec/modules/review/ports.py +15 -0
  58. backspec/modules/review/use_cases.py +89 -0
  59. backspec/modules/staleness/__init__.py +0 -0
  60. backspec/modules/staleness/domain.py +75 -0
  61. backspec/modules/staleness/facade.py +17 -0
  62. backspec/modules/staleness/ports.py +7 -0
  63. backspec/modules/staleness/use_cases.py +39 -0
  64. backspec/modules/triage/__init__.py +0 -0
  65. backspec/modules/triage/domain.py +144 -0
  66. backspec/modules/triage/facade.py +24 -0
  67. backspec/modules/triage/use_cases.py +79 -0
  68. backspec/prompts/assess.md +32 -0
  69. backspec/prompts/assign.md +18 -0
  70. backspec/prompts/consolidate.md +52 -0
  71. backspec/prompts/observe.md +52 -0
  72. backspec/prompts/orient.md +33 -0
  73. backspec/prompts/synthesise.md +97 -0
  74. backspec/prompts/triage.md +34 -0
  75. backspec/py.typed +0 -0
  76. backspec/templates/libre-baskerville-400.woff2 +0 -0
  77. backspec/templates/libre-baskerville-700.woff2 +0 -0
  78. backspec/templates/libre-baskerville-OFL.txt +93 -0
  79. backspec/templates/report.html +336 -0
  80. backspec-0.0.1.dist-info/METADATA +430 -0
  81. backspec-0.0.1.dist-info/RECORD +85 -0
  82. backspec-0.0.1.dist-info/WHEEL +4 -0
  83. backspec-0.0.1.dist-info/entry_points.txt +2 -0
  84. backspec-0.0.1.dist-info/licenses/LICENSE +21 -0
  85. 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"]