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,135 @@
|
|
|
1
|
+
"""Synthesise requirements capability by capability, then challenge each one."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from backspec.kernel import prompts
|
|
8
|
+
from backspec.kernel.llm import LLMError, LLMPort, LLMRequest, ask
|
|
9
|
+
from backspec.kernel.text import short_hash
|
|
10
|
+
from backspec.modules.capabilities.facade import Capability
|
|
11
|
+
from backspec.modules.evidence.facade import Observation
|
|
12
|
+
from backspec.modules.requirements.domain import (AssessReply, ConsolidateReply, Requirement, SynthReply, assess_block,
|
|
13
|
+
build_requirement, consolidate_input, merge_requirements, settle, synth_input)
|
|
14
|
+
|
|
15
|
+
SYNTH_BATCH = 180 # observations per synthesis call
|
|
16
|
+
CONSOLIDATE_MIN = 8 # a capability with more requirements than max(this, a third of its observations) is consolidated
|
|
17
|
+
ASSESS_BATCH = 12 # requirements per support call
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def fingerprint() -> str:
|
|
21
|
+
"""Changes whenever the way requirements are written, merged or judged does. Requirements an
|
|
22
|
+
earlier run made another way are not reused."""
|
|
23
|
+
return short_hash(prompts.load("synthesise"), prompts.load("consolidate"), prompts.load("assess"),
|
|
24
|
+
SYNTH_BATCH, CONSOLIDATE_MIN, ASSESS_BATCH)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Synthesis:
|
|
29
|
+
requirements: list[Requirement] = field(default_factory=list)
|
|
30
|
+
open_questions: dict[str, list[str]] = field(default_factory=dict)
|
|
31
|
+
failed: dict[str, str] = field(default_factory=dict) # capability -> why
|
|
32
|
+
unconsolidated: dict[str, str] = field(default_factory=dict) # capability -> why the merge pass failed
|
|
33
|
+
dropped_scenarios: int = 0 # scenarios left out: a part missing, or no real observation cited
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def synthesise(capabilities: list[Capability], by_capability: dict[str, list[Observation]],
|
|
37
|
+
llm: LLMPort, workers: int = 4) -> Synthesis:
|
|
38
|
+
instructions = prompts.load("synthesise")
|
|
39
|
+
jobs = []
|
|
40
|
+
for capability in capabilities:
|
|
41
|
+
observations = by_capability.get(capability.id, [])
|
|
42
|
+
for start in range(0, len(observations), SYNTH_BATCH):
|
|
43
|
+
jobs.append((capability, observations[start:start + SYNTH_BATCH]))
|
|
44
|
+
|
|
45
|
+
def run(job: tuple[Capability, list[Observation]]) -> SynthReply | LLMError:
|
|
46
|
+
capability, observations = job
|
|
47
|
+
prompt = (f"{instructions}\n\n# The capability\n\nCAPABILITY: {capability.id}\nNAME: {capability.name}\n"
|
|
48
|
+
f"SUMMARY: {capability.summary}\n\n# The observations\n\n{synth_input(observations)}")
|
|
49
|
+
try:
|
|
50
|
+
return ask(llm, LLMRequest("synthesise", prompt, "strong"), SynthReply)
|
|
51
|
+
except LLMError as error:
|
|
52
|
+
return error
|
|
53
|
+
|
|
54
|
+
with ThreadPoolExecutor(max_workers=max(1, workers)) as pool:
|
|
55
|
+
replies = list(pool.map(run, jobs))
|
|
56
|
+
result = Synthesis()
|
|
57
|
+
seen: set[str] = set()
|
|
58
|
+
for (capability, observations), reply in zip(jobs, replies):
|
|
59
|
+
if isinstance(reply, LLMError):
|
|
60
|
+
result.failed[capability.id] = str(reply)[:300]
|
|
61
|
+
continue
|
|
62
|
+
known = {o.id: o for o in observations}
|
|
63
|
+
for raw in reply.requirements:
|
|
64
|
+
requirement, dropped = build_requirement(raw, capability.id, known)
|
|
65
|
+
result.dropped_scenarios += dropped
|
|
66
|
+
if requirement and requirement.id not in seen:
|
|
67
|
+
seen.add(requirement.id)
|
|
68
|
+
result.requirements.append(requirement)
|
|
69
|
+
if reply.open_questions:
|
|
70
|
+
result.open_questions.setdefault(capability.id, []).extend(reply.open_questions)
|
|
71
|
+
consolidate(result, capabilities, by_capability, {c for c, _ in jobs_by_cap(jobs, True)}, llm, workers)
|
|
72
|
+
return result
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def jobs_by_cap(jobs: list[tuple[Capability, list[Observation]]], multi_only: bool) -> list[tuple[str, int]]:
|
|
76
|
+
"""(capability id, number of batches) for each capability; with multi_only, only those
|
|
77
|
+
synthesised in more than one batch."""
|
|
78
|
+
counts: dict[str, int] = {}
|
|
79
|
+
for capability, _ in jobs:
|
|
80
|
+
counts[capability.id] = counts.get(capability.id, 0) + 1
|
|
81
|
+
return [(c, n) for c, n in counts.items() if n > 1 or not multi_only]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def consolidate(result: Synthesis, capabilities: list[Capability], by_capability: dict[str, list[Observation]],
|
|
85
|
+
batched: set[str], llm: LLMPort, workers: int = 4) -> None:
|
|
86
|
+
"""Merge requirements that state the same behaviour, for each capability that was synthesised
|
|
87
|
+
in more than one batch or came out too fine. Failure keeps the originals and is recorded."""
|
|
88
|
+
by_id = {c.id: c for c in capabilities}
|
|
89
|
+
own: dict[str, list[Requirement]] = {}
|
|
90
|
+
for requirement in result.requirements:
|
|
91
|
+
own.setdefault(requirement.capability, []).append(requirement)
|
|
92
|
+
due = [cid for cid, reqs in own.items()
|
|
93
|
+
if cid in by_id and len(reqs) > 1
|
|
94
|
+
and (cid in batched or len(reqs) > max(CONSOLIDATE_MIN, len(by_capability.get(cid, [])) / 3))]
|
|
95
|
+
if not due:
|
|
96
|
+
return
|
|
97
|
+
instructions = prompts.load("consolidate")
|
|
98
|
+
|
|
99
|
+
def run(cid: str) -> ConsolidateReply | LLMError:
|
|
100
|
+
prompt = (f"{instructions}\n\n# The capability\n\nCAPABILITY: {cid}\nNAME: {by_id[cid].name}\n"
|
|
101
|
+
f"SUMMARY: {by_id[cid].summary}\n\n# The requirements\n\n{consolidate_input(own[cid])}")
|
|
102
|
+
try:
|
|
103
|
+
return ask(llm, LLMRequest("consolidate", prompt, "strong"), ConsolidateReply)
|
|
104
|
+
except LLMError as error:
|
|
105
|
+
return error
|
|
106
|
+
|
|
107
|
+
with ThreadPoolExecutor(max_workers=max(1, workers)) as pool:
|
|
108
|
+
replies = list(pool.map(run, due))
|
|
109
|
+
for cid, reply in zip(due, replies):
|
|
110
|
+
if isinstance(reply, LLMError):
|
|
111
|
+
result.unconsolidated[cid] = str(reply)[:300]
|
|
112
|
+
else:
|
|
113
|
+
own[cid] = merge_requirements(own[cid], reply.groups)
|
|
114
|
+
order = list(own)
|
|
115
|
+
result.requirements = [r for cid in order for r in own[cid]]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def assess(requirements: list[Requirement], file_lines: dict[str, list[str]], llm: LLMPort,
|
|
119
|
+
workers: int = 4) -> list[Requirement]:
|
|
120
|
+
"""Each requirement with a support verdict, keyed by id. One left without a verdict stays
|
|
121
|
+
`not_assessed`; it is never assumed to be supported."""
|
|
122
|
+
instructions = prompts.load("assess")
|
|
123
|
+
batches = [requirements[start:start + ASSESS_BATCH] for start in range(0, len(requirements), ASSESS_BATCH)]
|
|
124
|
+
|
|
125
|
+
def run(batch: list[Requirement]) -> AssessReply:
|
|
126
|
+
blocks = "\n".join(assess_block(r, file_lines) for r in batch)
|
|
127
|
+
try:
|
|
128
|
+
return ask(llm, LLMRequest("assess", f"{instructions}\n\n# The requirements\n\n{blocks}", "strong"), AssessReply)
|
|
129
|
+
except LLMError:
|
|
130
|
+
return AssessReply()
|
|
131
|
+
|
|
132
|
+
with ThreadPoolExecutor(max_workers=max(1, workers)) as pool:
|
|
133
|
+
replies = list(pool.map(run, batches))
|
|
134
|
+
verdicts = {v.id: v for reply in replies for v in reply.verdicts}
|
|
135
|
+
return [settle(requirement, verdicts.get(requirement.id)) for requirement in requirements]
|
|
File without changes
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Review: a person's decisions about requirements, and ids that survive a re-run.
|
|
2
|
+
|
|
3
|
+
A requirement's id is a hash of its wording, so it changes whenever the wording does. What
|
|
4
|
+
stays put between runs is the code it rests on. Its anchors are the set of (path, quote with
|
|
5
|
+
whitespace squashed) over its evidence, and ids are carried from the previous run by how much
|
|
6
|
+
two requirements' anchors overlap. Capability ids are never used: they can be renamed.
|
|
7
|
+
|
|
8
|
+
Pure logic, no I/O.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Iterable, Literal, Sequence
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, Field
|
|
16
|
+
|
|
17
|
+
from backspec.kernel.text import squash
|
|
18
|
+
|
|
19
|
+
MATCH_THRESHOLD = 0.5
|
|
20
|
+
|
|
21
|
+
Anchors = frozenset # frozenset[tuple[str, str]]
|
|
22
|
+
Decision = Literal["confirm", "edit", "reject"]
|
|
23
|
+
Outcome = Literal["confirmed", "edited", "rejected", "needs_review"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ReviewFileError(ValueError):
|
|
27
|
+
"""The review file exists but cannot be read as review entries."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class UnknownRequirement(LookupError):
|
|
31
|
+
"""A requirement id that is not in the result it was looked up in."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ReviewEntry(BaseModel):
|
|
35
|
+
id: str
|
|
36
|
+
decision: Decision
|
|
37
|
+
statement: str | None = None # for edit; absent keeps the statement
|
|
38
|
+
conditions: list[str] | None = None # for edit; absent keeps the conditions
|
|
39
|
+
reviewer: str
|
|
40
|
+
date: str
|
|
41
|
+
note: str | None = None
|
|
42
|
+
anchors: list[tuple[str, str]] = Field(default_factory=list) # written by the tool
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ReviewSummary(BaseModel):
|
|
46
|
+
carried: int = 0
|
|
47
|
+
new: int = 0
|
|
48
|
+
confirmed: int = 0
|
|
49
|
+
edited: int = 0
|
|
50
|
+
rejected_hidden: int = 0
|
|
51
|
+
needs_review: int = 0
|
|
52
|
+
stale_entries: list[str] = Field(default_factory=list)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class Known:
|
|
57
|
+
"""A requirement from before: its id, its anchors, and whether a person had looked at it."""
|
|
58
|
+
id: str
|
|
59
|
+
anchors: frozenset
|
|
60
|
+
reviewed: bool
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class Carry:
|
|
65
|
+
id: str # the id this requirement ends with, unique among the run
|
|
66
|
+
carried: bool # it took a previous id
|
|
67
|
+
ambiguous_reviewed: bool # the match was ambiguous and a candidate had been reviewed
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def anchors_of(evidence: Iterable[tuple[str, str]]) -> frozenset:
|
|
71
|
+
return frozenset((path, squash(quote)) for path, quote in evidence)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def overlap(a: frozenset, b: frozenset) -> float:
|
|
75
|
+
union = len(a | b)
|
|
76
|
+
return len(a & b) / union if union else 0.0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _single_best(scores: Sequence[float]) -> int | None:
|
|
80
|
+
"""The index of the one highest score, if it reaches the threshold and nothing ties it."""
|
|
81
|
+
top = max(scores, default=0.0)
|
|
82
|
+
if top < MATCH_THRESHOLD:
|
|
83
|
+
return None
|
|
84
|
+
winners = [index for index, score in enumerate(scores) if score == top]
|
|
85
|
+
return winners[0] if len(winners) == 1 else None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def carry_ids(new: Sequence[tuple[str, frozenset]], previous: Sequence[Known]) -> list[Carry]:
|
|
89
|
+
"""For each new requirement (its own id, its anchors), the id it ends with.
|
|
90
|
+
|
|
91
|
+
It takes a previous id only when that previous requirement is its single best match and it
|
|
92
|
+
is that one's single best match too. Otherwise it keeps its own id, flagged when the match
|
|
93
|
+
was ambiguous and any candidate had been reviewed. Ids in the result are unique."""
|
|
94
|
+
scores = [[overlap(anchors, old.anchors) for old in previous] for _, anchors in new]
|
|
95
|
+
chosen: list[int | None] = []
|
|
96
|
+
flagged: list[bool] = []
|
|
97
|
+
for row in scores:
|
|
98
|
+
best = _single_best(row)
|
|
99
|
+
mutual = best is not None and _single_best([other[best] for other in scores]) == len(chosen)
|
|
100
|
+
chosen.append(best if mutual else None)
|
|
101
|
+
candidates = [k for k, score in enumerate(row) if score >= MATCH_THRESHOLD]
|
|
102
|
+
flagged.append(not mutual and any(previous[k].reviewed for k in candidates))
|
|
103
|
+
used: set[str] = set()
|
|
104
|
+
ids: list[str | None] = [None] * len(new)
|
|
105
|
+
for index, match in enumerate(chosen): # carried ids claim their place first
|
|
106
|
+
if match is not None:
|
|
107
|
+
ids[index] = _free(previous[match].id, used)
|
|
108
|
+
for index, (own, _) in enumerate(new):
|
|
109
|
+
if ids[index] is None:
|
|
110
|
+
ids[index] = _free(own, used)
|
|
111
|
+
return [Carry(id=ids[i], carried=chosen[i] is not None, ambiguous_reviewed=flagged[i]) # type: ignore[arg-type]
|
|
112
|
+
for i in range(len(new))]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _free(wanted: str, used: set[str]) -> str:
|
|
116
|
+
candidate, number = wanted, 1
|
|
117
|
+
while candidate in used:
|
|
118
|
+
number += 1
|
|
119
|
+
candidate = f"{wanted}-{number}"
|
|
120
|
+
used.add(candidate)
|
|
121
|
+
return candidate
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def decide(entry: ReviewEntry, current: frozenset) -> Outcome:
|
|
125
|
+
"""What an entry does to a requirement. If the evidence is not what the person saw, the
|
|
126
|
+
requirement stays as it is and needs another look, whatever they decided."""
|
|
127
|
+
if frozenset(map(tuple, entry.anchors)) != current:
|
|
128
|
+
return "needs_review"
|
|
129
|
+
return {"confirm": "confirmed", "edit": "edited", "reject": "rejected"}[entry.decision] # type: ignore[return-value]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Review, as the rest of backspec sees it."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from backspec.modules.requirements.facade import Requirement
|
|
5
|
+
from backspec.modules.review.domain import (Decision, ReviewEntry, ReviewFileError, ReviewSummary,
|
|
6
|
+
UnknownRequirement)
|
|
7
|
+
from backspec.modules.review.ports import ReviewStorePort
|
|
8
|
+
from backspec.modules.review.use_cases import ReviewOutcome, apply_review, record
|
|
9
|
+
|
|
10
|
+
__all__ = ["Decision", "ReviewEntry", "ReviewFacade", "ReviewFileError", "ReviewOutcome", "ReviewStorePort",
|
|
11
|
+
"ReviewSummary", "UnknownRequirement"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ReviewFacade:
|
|
15
|
+
def __init__(self, store: ReviewStorePort | None = None):
|
|
16
|
+
self._store = store
|
|
17
|
+
|
|
18
|
+
def applies(self, previous: list[Requirement] | None) -> bool:
|
|
19
|
+
"""The step runs only when there is a previous result or a review file to work from."""
|
|
20
|
+
return previous is not None or self._store is not None
|
|
21
|
+
|
|
22
|
+
def apply(self, requirements: list[Requirement], previous: list[Requirement] | None) -> ReviewOutcome:
|
|
23
|
+
entries = self._store.load() if self._store else []
|
|
24
|
+
return apply_review(requirements, previous or [], entries)
|
|
25
|
+
|
|
26
|
+
def record(self, requirements: list[Requirement], requirement_id: str, decision: Decision, *,
|
|
27
|
+
statement: str | None = None, conditions: list[str] | None = None, reviewer: str, date: str,
|
|
28
|
+
note: str | None = None) -> ReviewEntry:
|
|
29
|
+
if self._store is None:
|
|
30
|
+
raise ValueError("no review file to write to")
|
|
31
|
+
return record(self._store, requirements, requirement_id, decision, statement=statement,
|
|
32
|
+
conditions=conditions, reviewer=reviewer, date=date, note=note)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""What review needs from the outside world."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
from backspec.modules.review.domain import ReviewEntry
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ReviewStorePort(Protocol):
|
|
10
|
+
def load(self) -> list[ReviewEntry]:
|
|
11
|
+
"""Every entry, in file order. No file means no entries."""
|
|
12
|
+
...
|
|
13
|
+
|
|
14
|
+
def save(self, entries: list[ReviewEntry]) -> None:
|
|
15
|
+
...
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Carry ids forward, apply a person's decisions, and record new ones."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
|
|
6
|
+
from backspec.modules.requirements.facade import Requirement
|
|
7
|
+
from backspec.modules.review.domain import (Decision, Known, ReviewEntry, ReviewSummary, UnknownRequirement,
|
|
8
|
+
anchors_of, carry_ids, decide)
|
|
9
|
+
from backspec.modules.review.ports import ReviewStorePort
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class ReviewOutcome:
|
|
14
|
+
requirements: list[Requirement]
|
|
15
|
+
summary: ReviewSummary
|
|
16
|
+
hidden: list[Requirement] = field(default_factory=list) # rejected by a person; kept, not shown
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def requirement_anchors(requirement: Requirement) -> frozenset:
|
|
20
|
+
return anchors_of((e.path, e.quote) for e in requirement.evidence)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _known(previous: list[Requirement], entries: list[ReviewEntry]) -> list[Known]:
|
|
24
|
+
"""What the last run knew. A rejected requirement is not in the last result, but its entry
|
|
25
|
+
holds its anchors, so it can still be recognised."""
|
|
26
|
+
by_id = {e.id: e for e in entries}
|
|
27
|
+
known = [Known(r.id, requirement_anchors(r), r.review != "unreviewed" or r.id in by_id) for r in previous]
|
|
28
|
+
seen = {k.id for k in known}
|
|
29
|
+
return known + [Known(e.id, frozenset(map(tuple, e.anchors)), True) for e in entries if e.id not in seen]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def apply_review(requirements: list[Requirement], previous: list[Requirement],
|
|
33
|
+
entries: list[ReviewEntry]) -> ReviewOutcome:
|
|
34
|
+
carries = carry_ids([(r.id, requirement_anchors(r)) for r in requirements], _known(previous, entries))
|
|
35
|
+
by_id = {e.id: e for e in entries} # a later entry for the same id wins
|
|
36
|
+
summary = ReviewSummary(carried=sum(c.carried for c in carries))
|
|
37
|
+
summary.new = len(requirements) - summary.carried
|
|
38
|
+
matched: set[str] = set()
|
|
39
|
+
out: list[Requirement] = []
|
|
40
|
+
hidden: list[Requirement] = []
|
|
41
|
+
for requirement, carry in zip(requirements, carries):
|
|
42
|
+
requirement = requirement.model_copy(update={"id": carry.id})
|
|
43
|
+
entry = by_id.get(carry.id)
|
|
44
|
+
if entry is not None:
|
|
45
|
+
matched.add(entry.id)
|
|
46
|
+
if carry.ambiguous_reviewed:
|
|
47
|
+
out.append(requirement.model_copy(update={"review": "needs_review"}))
|
|
48
|
+
elif entry is None:
|
|
49
|
+
out.append(requirement)
|
|
50
|
+
else:
|
|
51
|
+
outcome = decide(entry, requirement_anchors(requirement))
|
|
52
|
+
if outcome == "rejected":
|
|
53
|
+
summary.rejected_hidden += 1
|
|
54
|
+
hidden.append(requirement)
|
|
55
|
+
elif outcome == "needs_review":
|
|
56
|
+
out.append(requirement.model_copy(update={"review": "needs_review"}))
|
|
57
|
+
elif outcome == "confirmed":
|
|
58
|
+
out.append(requirement.model_copy(update={"review": "confirmed"}))
|
|
59
|
+
else:
|
|
60
|
+
update: dict = {"review": "edited"}
|
|
61
|
+
if entry.statement is not None:
|
|
62
|
+
update["statement"] = entry.statement
|
|
63
|
+
if entry.conditions is not None:
|
|
64
|
+
update["conditions"] = list(entry.conditions)
|
|
65
|
+
out.append(requirement.model_copy(update=update))
|
|
66
|
+
summary.confirmed = sum(r.review == "confirmed" for r in out)
|
|
67
|
+
summary.edited = sum(r.review == "edited" for r in out)
|
|
68
|
+
summary.needs_review = sum(r.review == "needs_review" for r in out)
|
|
69
|
+
summary.stale_entries = list(dict.fromkeys(e.id for e in entries if e.id not in matched))
|
|
70
|
+
return ReviewOutcome(out, summary, hidden)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def record(store: ReviewStorePort, requirements: list[Requirement], requirement_id: str, decision: Decision, *,
|
|
74
|
+
statement: str | None, conditions: list[str] | None, reviewer: str, date: str,
|
|
75
|
+
note: str | None) -> ReviewEntry:
|
|
76
|
+
"""Write or replace the entry for a requirement, copying its anchors from `requirements`."""
|
|
77
|
+
found = next((r for r in requirements if r.id == requirement_id), None)
|
|
78
|
+
if found is None:
|
|
79
|
+
raise UnknownRequirement(requirement_id)
|
|
80
|
+
entry = ReviewEntry(id=requirement_id, decision=decision, statement=statement, conditions=conditions,
|
|
81
|
+
reviewer=reviewer, date=date, note=note,
|
|
82
|
+
anchors=sorted(requirement_anchors(found)))
|
|
83
|
+
entries = store.load()
|
|
84
|
+
if any(e.id == requirement_id for e in entries):
|
|
85
|
+
entries = [entry if e.id == requirement_id else e for e in entries]
|
|
86
|
+
else:
|
|
87
|
+
entries.append(entry)
|
|
88
|
+
store.save(entries)
|
|
89
|
+
return entry
|
|
File without changes
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Staleness: whether the files a result rests on are still what was read.
|
|
2
|
+
|
|
3
|
+
A file is `unchanged`, `changed` or `missing`. A requirement is `current` when every file it
|
|
4
|
+
cites is unchanged, `source_missing` when any is missing, and otherwise `stale`. No model is
|
|
5
|
+
involved: it is a comparison of hashes.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, Field
|
|
12
|
+
|
|
13
|
+
from backspec.kernel.text import sha256
|
|
14
|
+
|
|
15
|
+
FileState = Literal["unchanged", "changed", "missing"]
|
|
16
|
+
RequirementState = Literal["current", "stale", "source_missing"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def hash_of(data: bytes) -> str | None:
|
|
20
|
+
"""The hash of a file as discovery takes it: of the decoded text. None when the file can no
|
|
21
|
+
longer be read as text."""
|
|
22
|
+
if b"\x00" in data[:8192]:
|
|
23
|
+
return None
|
|
24
|
+
try:
|
|
25
|
+
return sha256(data.decode("utf-8"))
|
|
26
|
+
except UnicodeDecodeError:
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def classify_file(recorded: str | None, current: str | None) -> FileState:
|
|
31
|
+
if current is None:
|
|
32
|
+
return "missing"
|
|
33
|
+
return "unchanged" if recorded == current else "changed"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def classify_requirement(states: dict[str, FileState]) -> RequirementState:
|
|
37
|
+
values = set(states.values())
|
|
38
|
+
if "missing" in values:
|
|
39
|
+
return "source_missing"
|
|
40
|
+
return "stale" if "changed" in values else "current"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class RequirementStatus(BaseModel):
|
|
44
|
+
id: str
|
|
45
|
+
capability: str
|
|
46
|
+
statement: str
|
|
47
|
+
status: RequirementState
|
|
48
|
+
changed: list[str] = Field(default_factory=list)
|
|
49
|
+
missing: list[str] = Field(default_factory=list)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class StatusReport(BaseModel):
|
|
53
|
+
counts: dict[str, int]
|
|
54
|
+
changed_files: list[str] = Field(default_factory=list)
|
|
55
|
+
missing_files: list[str] = Field(default_factory=list)
|
|
56
|
+
requirements: list[RequirementStatus] = Field(default_factory=list)
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def all_current(self) -> bool:
|
|
60
|
+
return not self.changed_files and not self.missing_files and \
|
|
61
|
+
all(r.status == "current" for r in self.requirements)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def summary(report: StatusReport) -> str:
|
|
65
|
+
"""The short human form: a line of counts, then each requirement that is not current."""
|
|
66
|
+
counts = report.counts
|
|
67
|
+
lines = [f"{counts.get('current', 0)} current, {counts.get('stale', 0)} stale, "
|
|
68
|
+
f"{counts.get('source_missing', 0)} source_missing"]
|
|
69
|
+
for item in report.requirements:
|
|
70
|
+
if item.status == "current":
|
|
71
|
+
continue
|
|
72
|
+
lines.append(f"{item.status} {item.id} {item.statement}")
|
|
73
|
+
lines += [f" changed {path}" for path in item.changed]
|
|
74
|
+
lines += [f" missing {path}" for path in item.missing]
|
|
75
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Staleness, as the rest of backspec sees it."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from backspec.modules.report.facade import ExtractionResult
|
|
5
|
+
from backspec.modules.staleness.domain import RequirementStatus, StatusReport, summary
|
|
6
|
+
from backspec.modules.staleness.ports import RepoPort
|
|
7
|
+
from backspec.modules.staleness.use_cases import check
|
|
8
|
+
|
|
9
|
+
__all__ = ["RepoPort", "RequirementStatus", "StalenessFacade", "StatusReport", "summary"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class StalenessFacade:
|
|
13
|
+
def __init__(self, repo: RepoPort):
|
|
14
|
+
self._repo = repo
|
|
15
|
+
|
|
16
|
+
def check(self, result: ExtractionResult) -> StatusReport:
|
|
17
|
+
return check(result, self._repo)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Compare a saved result with the repository as it is now."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from backspec.modules.report.facade import ExtractionResult
|
|
5
|
+
from backspec.modules.staleness.domain import (FileState, RequirementStatus, StatusReport, classify_file,
|
|
6
|
+
classify_requirement, hash_of)
|
|
7
|
+
from backspec.modules.staleness.ports import RepoPort
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _current_hash(repo: RepoPort, path: str) -> str | None:
|
|
11
|
+
try:
|
|
12
|
+
return hash_of(repo.read(path))
|
|
13
|
+
except (OSError, ValueError):
|
|
14
|
+
return None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def check(result: ExtractionResult, repo: RepoPort) -> StatusReport:
|
|
18
|
+
recorded: dict[str, str] = {}
|
|
19
|
+
for observation in sorted(result.observations, key=lambda o: (o.path, o.id)):
|
|
20
|
+
recorded.setdefault(observation.path, observation.source_hash)
|
|
21
|
+
states: dict[str, FileState] = {
|
|
22
|
+
path: classify_file(recorded[path], _current_hash(repo, path)) for path in sorted(recorded)}
|
|
23
|
+
|
|
24
|
+
items: list[RequirementStatus] = []
|
|
25
|
+
for requirement in result.requirements:
|
|
26
|
+
cited = sorted({e.path for e in requirement.evidence})
|
|
27
|
+
# evidence that names a path no observation recorded cannot be confirmed: it counts as changed
|
|
28
|
+
mine = {path: states.get(path, "changed") for path in cited}
|
|
29
|
+
items.append(RequirementStatus(
|
|
30
|
+
id=requirement.id, capability=requirement.capability, statement=requirement.statement,
|
|
31
|
+
status=classify_requirement(mine),
|
|
32
|
+
changed=[p for p, s in mine.items() if s == "changed"],
|
|
33
|
+
missing=[p for p, s in mine.items() if s == "missing"]))
|
|
34
|
+
items.sort(key=lambda r: (r.capability, r.id))
|
|
35
|
+
counts = {name: sum(1 for r in items if r.status == name) for name in ("current", "stale", "source_missing")}
|
|
36
|
+
return StatusReport(counts=counts,
|
|
37
|
+
changed_files=[p for p, s in states.items() if s == "changed"],
|
|
38
|
+
missing_files=[p for p, s in states.items() if s == "missing"],
|
|
39
|
+
requirements=items)
|
|
File without changes
|