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,276 @@
1
+ """Observations: single facts a file establishes, each tied to lines that are checked in code.
2
+
3
+ An observation is evidence. It is not a requirement. A citation is accepted only when its quote
4
+ is really in the file: inside the cited lines (exact), or in exactly one other place, to which
5
+ the range is then moved (repaired). Anything else is rejected and recorded.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import difflib
10
+ import hashlib
11
+ import re
12
+ from dataclasses import dataclass
13
+ from typing import Literal
14
+
15
+ from pydantic import BaseModel, Field
16
+
17
+ from backspec.kernel.text import numbered, short_hash, squash
18
+
19
+ Kind = Literal["entry_point", "permission", "validation", "state_change", "rule", "display",
20
+ "error_path", "test_case", "doc_claim", "other"]
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class Symbol:
25
+ """A named definition in a file. Lines are 1-based and inclusive. `name` is qualified by the
26
+ symbols around it (`Class.method`)."""
27
+ name: str
28
+ kind: Literal["function", "method", "class", "other"]
29
+ first_line: int
30
+ last_line: int
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class Chunk:
35
+ """A run of lines from one file, with the real number of its first line."""
36
+ path: str
37
+ first: int
38
+ lines: tuple[str, ...]
39
+
40
+ @property
41
+ def last(self) -> int:
42
+ return self.first + len(self.lines) - 1
43
+
44
+
45
+ class RawObservation(BaseModel):
46
+ """An observation as the model gave it, before any check."""
47
+ path: str = ""
48
+ lines: list[int] = Field(default_factory=list)
49
+ quote: str = ""
50
+ kind: str = "other"
51
+ statement: str = ""
52
+
53
+
54
+ class NothingFound(BaseModel):
55
+ path: str
56
+ reason: str = ""
57
+
58
+
59
+ class ObserveReply(BaseModel):
60
+ observations: list[RawObservation] = Field(default_factory=list)
61
+ none: list[NothingFound] = Field(default_factory=list)
62
+
63
+
64
+ class Observation(BaseModel):
65
+ id: str
66
+ statement: str
67
+ kind: str
68
+ path: str
69
+ lines: tuple[int, int]
70
+ quote: str
71
+ source_hash: str
72
+ verified: Literal["exact", "repaired"]
73
+ symbol: str | None = None # innermost symbol holding the whole range; set in code, never by a model
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class Earlier:
78
+ """What an earlier run established about one file: the file as it was read then, and the
79
+ observations that verified. An unchanged file need not be read again."""
80
+ source_hash: str
81
+ observations: tuple["Observation", ...]
82
+ nothing_reason: str | None = None
83
+ line_digest: str = "" # the file line by line, to find what changed since; empty if not recorded
84
+
85
+
86
+ class Rejected(BaseModel):
87
+ path: str
88
+ lines: list[int]
89
+ quote: str
90
+ statement: str
91
+ reason: str
92
+
93
+
94
+ def split_file(path: str, lines: list[str], limit: int, symbols: list[Symbol] | None = None) -> list[Chunk]:
95
+ """A file as chunks of at most `limit` lines. With `symbols`, a cut falls between top-level
96
+ symbols, never inside one, whenever that fits within the limit. Otherwise (and for a single
97
+ symbol larger than the limit) it is at a blank line before top-level code where one is near."""
98
+ cuts = _symbol_cuts(lines, symbols) if symbols else []
99
+ chunks, start = [], 0
100
+ while start < len(lines):
101
+ end = min(start + limit, len(lines))
102
+ if end < len(lines):
103
+ fits = [cut for cut in cuts if start < cut <= end]
104
+ if fits:
105
+ end = max(fits)
106
+ else:
107
+ end = _text_cut(lines, start, end, limit)
108
+ chunks.append(Chunk(path, start + 1, tuple(lines[start:end])))
109
+ start = end
110
+ return chunks
111
+
112
+
113
+ def _text_cut(lines: list[str], start: int, end: int, limit: int) -> int:
114
+ for cut in range(end, max(start + limit // 2, end - 150), -1):
115
+ if not lines[cut - 1].strip() and not lines[cut].startswith((" ", "\t")):
116
+ return cut
117
+ return end
118
+
119
+
120
+ def _symbol_cuts(lines: list[str], symbols: list[Symbol]) -> list[int]:
121
+ """Line numbers after which a chunk may end: just after a top-level symbol, or just before one
122
+ (taking the comment lines directly above it along with it)."""
123
+ top: list[Symbol] = []
124
+ for symbol in sorted(symbols, key=lambda s: (s.first_line, -s.last_line)):
125
+ if not top or symbol.first_line > top[-1].last_line:
126
+ top.append(symbol)
127
+ cuts: set[int] = set()
128
+ previous_end = 0
129
+ for symbol in top:
130
+ cut = symbol.first_line - 1
131
+ while cut > previous_end and cut <= len(lines) and lines[cut - 1].strip():
132
+ cut -= 1
133
+ cuts.add(cut)
134
+ cuts.add(symbol.last_line)
135
+ previous_end = symbol.last_line
136
+ return sorted(c for c in cuts if 0 < c < len(lines))
137
+
138
+
139
+ def enclosing_symbol(symbols: list[Symbol] | None, first: int, last: int) -> str | None:
140
+ """The qualified name of the innermost symbol that contains the whole range, or None."""
141
+ best: Symbol | None = None
142
+ for symbol in symbols or []:
143
+ if symbol.first_line <= first and last <= symbol.last_line:
144
+ if best is None or (symbol.last_line - symbol.first_line, -symbol.name.count(".")) < \
145
+ (best.last_line - best.first_line, -best.name.count(".")):
146
+ best = symbol
147
+ return best.name if best else None
148
+
149
+
150
+ def pack(chunks: list[Chunk], limit: int) -> list[list[Chunk]]:
151
+ """Chunks grouped into batches of at most `limit` lines, in order."""
152
+ batches: list[list[Chunk]] = []
153
+ current: list[Chunk] = []
154
+ size = 0
155
+ for chunk in chunks:
156
+ if current and size + len(chunk.lines) > limit:
157
+ batches.append(current)
158
+ current, size = [], 0
159
+ current.append(chunk)
160
+ size += len(chunk.lines)
161
+ if current:
162
+ batches.append(current)
163
+ return batches
164
+
165
+
166
+ def render(batch: list[Chunk]) -> str:
167
+ """A batch as the model sees it: a header per file, then every line with its real number."""
168
+ out: list[str] = []
169
+ for chunk in batch:
170
+ out.append(f"=== FILE: {chunk.path} (lines {chunk.first}-{chunk.last}) ===")
171
+ out += numbered(list(chunk.lines), chunk.first)
172
+ out.append("")
173
+ return "\n".join(out)
174
+
175
+
176
+ DIGEST_WIDTH = 6 # characters of digest kept for each line
177
+ CONTEXT_AROUND_CHANGE = 30
178
+
179
+
180
+ def observation_id(path: str, cited_lines: list[str], statement: str) -> str:
181
+ """An id that depends on the cited text, not on where it sits. Moving the code keeps the id;
182
+ changing the cited lines changes it."""
183
+ return "OBS-" + short_hash(path, squash("\n".join(cited_lines)), statement.strip())
184
+
185
+
186
+ def line_digest(lines: list[str]) -> str:
187
+ """The file as one short digest per line, enough to tell later which lines changed."""
188
+ return "".join(hashlib.sha1(squash(line).encode()).hexdigest()[:DIGEST_WIDTH] for line in lines)
189
+
190
+
191
+ @dataclass(frozen=True)
192
+ class Change:
193
+ """How a file differs from the one an earlier run read. `kept` are runs of lines that are the
194
+ same, as (old first line, new first line, length). `changed` are the new or altered line
195
+ ranges in the file as it is now."""
196
+ kept: tuple[tuple[int, int, int], ...]
197
+ changed: tuple[tuple[int, int], ...]
198
+
199
+ def moved(self, first: int, last: int) -> tuple[int, int] | None:
200
+ """Where an old line range is now, if every line of it is unchanged."""
201
+ for old, new, length in self.kept:
202
+ if old <= first and last < old + length:
203
+ return first - old + new, last - old + new
204
+ return None
205
+
206
+ def touches(self, first: int, last: int) -> bool:
207
+ return any(a <= last and first <= b for a, b in self.changed)
208
+
209
+
210
+ def compare(old_digest: str, new_lines: list[str]) -> Change:
211
+ old = [old_digest[i:i + DIGEST_WIDTH] for i in range(0, len(old_digest), DIGEST_WIDTH)]
212
+ digest = line_digest(new_lines)
213
+ new = [digest[i:i + DIGEST_WIDTH] for i in range(0, len(digest), DIGEST_WIDTH)]
214
+ kept, changed = [], []
215
+ for tag, i1, i2, j1, j2 in difflib.SequenceMatcher(a=old, b=new, autojunk=False).get_opcodes():
216
+ if tag == "equal":
217
+ kept.append((i1 + 1, j1 + 1, i2 - i1))
218
+ elif j2 > j1:
219
+ changed.append((j1 + 1, j2))
220
+ return Change(tuple(kept), tuple(changed))
221
+
222
+
223
+ def windows(changed: tuple[tuple[int, int], ...], total: int, context: int = CONTEXT_AROUND_CHANGE) -> list[tuple[int, int]]:
224
+ """The line ranges to show a model: each change with context either side, overlaps merged."""
225
+ out: list[list[int]] = []
226
+ for first, last in changed:
227
+ a, b = max(1, first - context), min(total, last + context)
228
+ if out and a <= out[-1][1] + 1:
229
+ out[-1][1] = max(out[-1][1], b)
230
+ else:
231
+ out.append([a, b])
232
+ return [(a, b) for a, b in out]
233
+
234
+
235
+ def carried(observation: "Observation", change: Change, source_hash: str) -> "Observation | None":
236
+ """An earlier observation as it stands in the changed file, or None if its lines changed."""
237
+ where = change.moved(*observation.lines)
238
+ if where is None:
239
+ return None
240
+ return observation.model_copy(update={"lines": where, "source_hash": source_hash})
241
+
242
+
243
+ def _clean(quote: str) -> str:
244
+ """A quote without the ` 123| ` prefix a model sometimes copies along with the line."""
245
+ return squash(re.sub(r"^\s*\d+\|\s?", "", quote))
246
+
247
+
248
+ def verify(raw: RawObservation, shown: set[str], file_lines: dict[str, list[str]],
249
+ source_hashes: dict[str, str]) -> Observation | Rejected:
250
+ """Check one observation against the files the model was shown."""
251
+ def reject(reason: str) -> Rejected:
252
+ return Rejected(path=raw.path, lines=raw.lines, quote=raw.quote, statement=raw.statement, reason=reason)
253
+
254
+ if raw.path not in shown:
255
+ return reject("path was not shown to the model")
256
+ if not raw.statement.strip():
257
+ return reject("no statement")
258
+ quote = _clean(raw.quote)
259
+ if len(quote) < 4:
260
+ return reject("no usable quote")
261
+ lines = file_lines[raw.path]
262
+ span = raw.lines if len(raw.lines) == 2 and 1 <= raw.lines[0] <= raw.lines[1] <= len(lines) else None
263
+ verdict: Literal["exact", "repaired"]
264
+ if span and quote in squash("\n".join(lines[span[0] - 1:span[1]])):
265
+ verdict, where = "exact", (span[0], span[1])
266
+ else:
267
+ hits = [number for number, line in enumerate(lines, 1) if quote in squash(line)]
268
+ if len(hits) != 1:
269
+ return reject("quote not found" if not hits else "quote found more than once")
270
+ width = min(span[1] - span[0], 30) if span else 0
271
+ first = max(1, hits[0] - width // 2)
272
+ verdict, where = "repaired", (first, min(len(lines), first + width))
273
+ return Observation(
274
+ id=observation_id(raw.path, lines[where[0] - 1:where[1]], raw.statement),
275
+ statement=raw.statement.strip(), kind=raw.kind, path=raw.path, lines=where,
276
+ quote=_clean(raw.quote), source_hash=source_hashes[raw.path], verified=verdict)
@@ -0,0 +1,24 @@
1
+ """Evidence, as the rest of backspec sees it."""
2
+ from __future__ import annotations
3
+
4
+ from backspec.kernel.llm import LLMPort
5
+ from backspec.modules.discovery.facade import SourceFile
6
+ from backspec.modules.evidence.domain import Earlier, NothingFound, Observation, Rejected, Symbol, line_digest
7
+ from backspec.modules.evidence.ports import SyntaxPort
8
+ from backspec.modules.evidence.use_cases import ObservationSet, fingerprint, observe
9
+
10
+ __all__ = ["Earlier", "EvidenceFacade", "NothingFound", "Observation", "ObservationSet", "Rejected", "Symbol", "SyntaxPort", "line_digest"]
11
+
12
+
13
+ class EvidenceFacade:
14
+ def __init__(self, llm: LLMPort, workers: int = 4, syntax: SyntaxPort | None = None):
15
+ self._llm = llm
16
+ self._syntax = syntax
17
+ self._workers = workers
18
+
19
+ def observe(self, files: list[SourceFile], earlier: dict[str, Earlier] | None = None) -> ObservationSet:
20
+ return observe(files, self._llm, self._workers, earlier=earlier, syntax=self._syntax)
21
+
22
+ def fingerprint(self) -> str:
23
+ """Identifies the way files are observed; earlier observations made another way are not reused."""
24
+ return fingerprint(syntax=self._syntax is not None)
@@ -0,0 +1,12 @@
1
+ """What evidence needs from outside."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Protocol
5
+
6
+ from backspec.modules.evidence.domain import Symbol
7
+
8
+
9
+ class SyntaxPort(Protocol):
10
+ def symbols(self, path: str, text: str) -> list[Symbol] | None:
11
+ """The definitions in a file, nested ones included, or None when the file cannot be parsed."""
12
+ ...
@@ -0,0 +1,128 @@
1
+ """Observe: read every file in numbered batches and keep only what verifies.
2
+
3
+ A model does not give the same observations twice for the same file. So a file that has not
4
+ changed since an earlier run keeps that run's observations, and only new or changed files are
5
+ read. Everything built on observations, requirement ids included, is then stable between runs.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from concurrent.futures import ThreadPoolExecutor
10
+ from dataclasses import dataclass, field
11
+
12
+ from backspec.kernel import prompts
13
+ from backspec.kernel.llm import LLMError, LLMPort, LLMRequest, ask
14
+ from backspec.modules.discovery.facade import SourceFile
15
+ from backspec.kernel.text import short_hash
16
+ from backspec.modules.evidence.domain import (Chunk, Earlier, NothingFound, Observation, ObserveReply, Rejected,
17
+ Symbol, carried, compare, enclosing_symbol, pack, render, split_file,
18
+ verify, windows)
19
+ from backspec.modules.evidence.ports import SyntaxPort
20
+
21
+ BATCH_LINES = 1300
22
+
23
+
24
+ @dataclass
25
+ class ObservationSet:
26
+ observations: list[Observation] = field(default_factory=list)
27
+ rejected: list[Rejected] = field(default_factory=list)
28
+ nothing: list[NothingFound] = field(default_factory=list)
29
+ failed: dict[str, str] = field(default_factory=dict) # path -> why its batch failed
30
+ batches: int = 0
31
+ reused_files: int = 0 # unchanged since the earlier run, so not read again
32
+ partly_read_files: int = 0 # changed since the earlier run: only the changed parts were read
33
+ outside_change: int = 0 # observations a model made of unchanged lines while reading a change; not kept
34
+ analysis: dict[str, str] = field(default_factory=dict) # path -> "syntax" or "text": how the file was analysed
35
+
36
+
37
+ def fingerprint(batch_lines: int = BATCH_LINES, syntax: bool = False) -> str:
38
+ """Changes whenever the way files are observed does, syntax support on or off included.
39
+ Earlier observations made another way are not reused."""
40
+ parts = [prompts.load("observe"), batch_lines, "ids-by-cited-text"]
41
+ return short_hash(*parts, "syntax") if syntax else short_hash(*parts)
42
+
43
+
44
+ def _symbols(syntax: SyntaxPort | None, file: SourceFile) -> list[Symbol] | None:
45
+ """The symbols of a file, or None: no parser, no grammar, or a parser that failed."""
46
+ if syntax is None:
47
+ return None
48
+ try:
49
+ return syntax.symbols(file.path, file.text)
50
+ except Exception:
51
+ return None
52
+
53
+
54
+ def observe(files: list[SourceFile], llm: LLMPort, workers: int = 4, batch_lines: int = BATCH_LINES,
55
+ earlier: dict[str, Earlier] | None = None, syntax: SyntaxPort | None = None) -> ObservationSet:
56
+ symbols = {f.path: _symbols(syntax, f) for f in files}
57
+ file_lines = {f.path: f.lines for f in files}
58
+ hashes = {f.path: f.source_hash for f in files}
59
+ result = ObservationSet(analysis={path: "text" if found is None else "syntax" for path, found in symbols.items()})
60
+ seen: set[str] = set()
61
+ to_read = []
62
+ partial: list[Chunk] = []
63
+ changes = {}
64
+ for f in files:
65
+ known = (earlier or {}).get(f.path)
66
+ if known is None:
67
+ to_read.append(f)
68
+ continue
69
+ if known.source_hash != f.source_hash:
70
+ if not known.line_digest:
71
+ to_read.append(f)
72
+ continue
73
+ # Changed since the earlier run. Observations whose lines are untouched stay, moved to
74
+ # where those lines are now; only the changed parts, with context, are read again.
75
+ change = compare(known.line_digest, f.lines)
76
+ result.partly_read_files += 1
77
+ for observation in known.observations:
78
+ still = carried(observation, change, f.source_hash)
79
+ if still is not None and still.id not in seen:
80
+ seen.add(still.id)
81
+ result.observations.append(still)
82
+ for first, last in windows(change.changed, len(f.lines)):
83
+ partial.append(Chunk(f.path, first, tuple(f.lines[first - 1:last])))
84
+ changes[f.path] = change
85
+ continue
86
+ result.reused_files += 1
87
+ for observation in known.observations:
88
+ if observation.source_hash == f.source_hash and observation.id not in seen:
89
+ seen.add(observation.id)
90
+ result.observations.append(observation)
91
+ if known.nothing_reason is not None and not known.observations:
92
+ result.nothing.append(NothingFound(path=f.path, reason=known.nothing_reason))
93
+ chunks = [chunk for f in to_read for chunk in split_file(f.path, f.lines, batch_lines, symbols[f.path])] + partial
94
+ batches = pack(chunks, batch_lines)
95
+ result.batches = len(batches)
96
+ instructions = prompts.load("observe")
97
+
98
+ def run(batch: list[Chunk]) -> ObserveReply | LLMError:
99
+ try:
100
+ return ask(llm, LLMRequest("observe", f"{instructions}\n\n# The batch\n\n{render(batch)}", "fast"), ObserveReply)
101
+ except LLMError as error:
102
+ return error
103
+
104
+ with ThreadPoolExecutor(max_workers=max(1, workers)) as pool:
105
+ replies = list(pool.map(run, batches))
106
+ for batch, reply in zip(batches, replies):
107
+ shown = {chunk.path for chunk in batch}
108
+ if isinstance(reply, LLMError):
109
+ for path in shown:
110
+ result.failed[path] = str(reply)[:300]
111
+ continue
112
+ result.nothing += [entry for entry in reply.none if entry.path in shown]
113
+ for raw in reply.observations:
114
+ checked = verify(raw, shown, file_lines, hashes)
115
+ if isinstance(checked, Rejected):
116
+ result.rejected.append(checked)
117
+ elif checked.path in changes and not changes[checked.path].touches(*checked.lines):
118
+ # Read only as context for a change. What the earlier run said of these lines stands.
119
+ result.outside_change += 1
120
+ elif checked.id not in seen:
121
+ seen.add(checked.id)
122
+ result.observations.append(checked)
123
+ # Where an observation sits is worked out here, from the file as it is now, for new, carried and
124
+ # reused observations alike: code may have moved between symbols since an earlier run.
125
+ result.observations = [
126
+ o.model_copy(update={"symbol": symbol}) if (symbol := enclosing_symbol(symbols.get(o.path), *o.lines)) != o.symbol else o
127
+ for o in result.observations]
128
+ return result
File without changes
@@ -0,0 +1,6 @@
1
+ """Extraction, as the rest of backspec sees it."""
2
+ from __future__ import annotations
3
+
4
+ from backspec.modules.extraction.use_cases import Progress, Settings, orient_only, run
5
+
6
+ __all__ = ["Progress", "Settings", "orient_only", "run"]