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,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,7 @@
1
+ """What staleness needs from the outside world: reading a file by relative path. This is
2
+ discovery's `RepoPort`; only its `read` is used."""
3
+ from __future__ import annotations
4
+
5
+ from backspec.modules.discovery.facade import RepoPort
6
+
7
+ __all__ = ["RepoPort"]
@@ -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