claims-cli 0.6.0__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.
- claims/__init__.py +132 -0
- claims/__main__.py +5 -0
- claims/adapters/__init__.py +6 -0
- claims/adapters/array.py +212 -0
- claims/adapters/base.py +75 -0
- claims/adapters/cells.py +94 -0
- claims/adapters/columnar.py +188 -0
- claims/adapters/prose.py +261 -0
- claims/adapters/reads.py +47 -0
- claims/adapters/rows.py +116 -0
- claims/adapters/sheet.py +149 -0
- claims/adapters/sqlite.py +73 -0
- claims/adapters/table.py +135 -0
- claims/adapters/tree.py +163 -0
- claims/checks.py +511 -0
- claims/cli.py +1103 -0
- claims/core/__init__.py +9 -0
- claims/core/artifacts.py +121 -0
- claims/core/checks.py +132 -0
- claims/core/claims.py +100 -0
- claims/core/evidence.py +708 -0
- claims/core/manifest.py +349 -0
- claims/core/numbers.py +146 -0
- claims/core/outcomes.py +410 -0
- claims/core/report.py +503 -0
- claims/corpus.py +344 -0
- claims/exceptions.py +106 -0
- claims/export/__init__.py +23 -0
- claims/export/rocrate.py +787 -0
- claims/export/sarif.py +401 -0
- claims/manifest.py +59 -0
- claims/models.py +222 -0
- claims/numbers.py +453 -0
- claims/origins.py +303 -0
- claims/policy.py +369 -0
- claims/provenance.py +42 -0
- claims/record.py +1099 -0
- claims/regression.py +171 -0
- claims/renderers/__init__.py +10 -0
- claims/renderers/sarif.py +294 -0
- claims/reruns.py +111 -0
- claims/resolve.py +116 -0
- claims/stale.py +333 -0
- claims/starter.py +432 -0
- claims/toolchain.py +120 -0
- claims/verify.py +1371 -0
- claims/why.py +398 -0
- claims_cli-0.6.0.dist-info/METADATA +139 -0
- claims_cli-0.6.0.dist-info/RECORD +51 -0
- claims_cli-0.6.0.dist-info/WHEEL +4 -0
- claims_cli-0.6.0.dist-info/entry_points.txt +2 -0
claims/checks.py
ADDED
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
"""A record of what each assertion said when it was checked.
|
|
2
|
+
|
|
3
|
+
`repro verify` reports on a manifest as it is now. After a check does not verify, the
|
|
4
|
+
`reported` value, the address or the claim's text can be edited until it does, and the report
|
|
5
|
+
of the last run shows none of that. Every `repro verify` therefore appends, for each assertion
|
|
6
|
+
whose declaration, artifact digest or outcome differs from the last entry recorded for it, what
|
|
7
|
+
it found.
|
|
8
|
+
|
|
9
|
+
**Two records, one format.** The local record is `repro/checks.jsonl` in the repository's git
|
|
10
|
+
directory, the one `git rev-parse --absolute-git-dir` names, so a linked worktree keeps its
|
|
11
|
+
own. It is written by every check, belongs to this clone and is never committed, as git's
|
|
12
|
+
reflog is, and the working tree is not touched. The tracked record is `.repro/checks.jsonl` in
|
|
13
|
+
the working tree, written where `repro verify --record` asks, for a project whose history
|
|
14
|
+
should travel. The tracked record is a copy of the local one, line for line, up to the last
|
|
15
|
+
time it was asked for; a clone that finds a tracked record longer than its local one takes
|
|
16
|
+
the entries it lacks. Outside a git repository there is no local record, and `--record`
|
|
17
|
+
appends to the tracked one directly.
|
|
18
|
+
|
|
19
|
+
**A chain.** Each entry carries its position, `seq`, and the SHA-256 of the entry before it as
|
|
20
|
+
stored, `prev_hash`, and a head file beside the record, `ledger.head`, holds the number of
|
|
21
|
+
entries and the hash of the last. These are the `results` ledger's fields and its writer:
|
|
22
|
+
`provenance_core.chain.append_event` appends under a lock held across reading the last entry and
|
|
23
|
+
writing the next, so two checks running at once write two entries in a row and not two
|
|
24
|
+
claiming one position, and it refuses to append to a chain that does not verify.
|
|
25
|
+
|
|
26
|
+
Reading is done here and not by `provenance_core.chain.verify`, which takes a lock by creating a file
|
|
27
|
+
beside the record: `repro verify` and `repro why` must leave the working tree as it is.
|
|
28
|
+
|
|
29
|
+
**What the chain shows, and what it does not.** An entry edited, removed from the middle or
|
|
30
|
+
moved is found, with its number, and the entries from there on are shown as the record holds
|
|
31
|
+
them and marked unverifiable. Entries cut from the end are found while the head file is as it
|
|
32
|
+
was. A record and its head file cut back together, or deleted together and grown again, look
|
|
33
|
+
like a shorter or a new record: nothing inside the two files can show it. The local record has
|
|
34
|
+
no witness outside its directory. The tracked record's head file is committed with it, so the
|
|
35
|
+
repository's history holds every earlier head, and that is what makes a truncation visible to
|
|
36
|
+
someone who has the history. Nothing here is signed or timestamped by anyone else, and whoever
|
|
37
|
+
can write both files and rewrite the history can rewrite the record.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import collections
|
|
43
|
+
import datetime
|
|
44
|
+
import json
|
|
45
|
+
import os
|
|
46
|
+
import pathlib
|
|
47
|
+
from typing import Literal
|
|
48
|
+
|
|
49
|
+
from provenance_core import ZERO, exclusive_lock, sha256_of_text, try_run
|
|
50
|
+
from provenance_core.chain import ANCHOR, ChainStatus, append_event, read_anchor, write_anchor
|
|
51
|
+
from pydantic import ValidationError
|
|
52
|
+
|
|
53
|
+
from claims.exceptions import ReproError
|
|
54
|
+
from claims.models import (
|
|
55
|
+
AggregateEvidence,
|
|
56
|
+
CheckedAssertion,
|
|
57
|
+
CheckRecord,
|
|
58
|
+
Claim,
|
|
59
|
+
CorrespondenceEvidence,
|
|
60
|
+
Decision,
|
|
61
|
+
Evidence,
|
|
62
|
+
Locator,
|
|
63
|
+
Manifest,
|
|
64
|
+
QuoteEvidence,
|
|
65
|
+
RecordSetAside,
|
|
66
|
+
RecordState,
|
|
67
|
+
TableCellEvidence,
|
|
68
|
+
TreeLocator,
|
|
69
|
+
VerificationReport,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
#: The tracked record, beside the manifest.
|
|
73
|
+
RECORD = pathlib.Path(".repro") / "checks.jsonl"
|
|
74
|
+
|
|
75
|
+
#: The local record, inside the git directory.
|
|
76
|
+
LOCAL = pathlib.Path("repro") / "checks.jsonl"
|
|
77
|
+
|
|
78
|
+
#: Why no local record is kept, where none is.
|
|
79
|
+
NOT_A_REPOSITORY = "not in a git repository"
|
|
80
|
+
|
|
81
|
+
#: The flag that sets a damaged record aside. Named in the line that reports one.
|
|
82
|
+
SET_ASIDE = "--set-aside-record"
|
|
83
|
+
|
|
84
|
+
#: Which assertion an entry is about: the claim's id, the kind, the name and the position.
|
|
85
|
+
Identity = tuple[str, str, str, int]
|
|
86
|
+
|
|
87
|
+
#: Which of the two records.
|
|
88
|
+
Kind = Literal["local", "tracked"]
|
|
89
|
+
|
|
90
|
+
#: The states of a chain whose every entry follows from the one before it.
|
|
91
|
+
_SOUND = (ChainStatus.INTACT, ChainStatus.EXTENDED, ChainStatus.NO_ANCHOR)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class RecordDivergedError(ReproError):
|
|
95
|
+
"""The tracked record is not the beginning of the local one, so it cannot be brought up
|
|
96
|
+
to it by appending."""
|
|
97
|
+
|
|
98
|
+
def __init__(self, path: pathlib.Path, entry: int) -> None:
|
|
99
|
+
self.path = path
|
|
100
|
+
self.entry = entry
|
|
101
|
+
super().__init__(f"{path}: diverges from the local record at entry {entry}")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class RecordBrokenError(ReproError):
|
|
105
|
+
"""A record's chain does not verify, so nothing is appended to it."""
|
|
106
|
+
|
|
107
|
+
def __init__(self, state: RecordState, program: str = "repro") -> None:
|
|
108
|
+
self.state = state
|
|
109
|
+
super().__init__(
|
|
110
|
+
f"{state.path}: refusing to append to a chain reported as {state.status}: "
|
|
111
|
+
f"{'; '.join(state.problems)}; `{program} verify {SET_ASIDE}` keeps it and starts anew"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class RecordIntactError(ReproError):
|
|
116
|
+
"""Nothing was set aside: every record there is verifies, and the two agree."""
|
|
117
|
+
|
|
118
|
+
def __init__(self, root: pathlib.Path) -> None:
|
|
119
|
+
self.root = root
|
|
120
|
+
super().__init__(f"{root}: no record here is damaged, so none is set aside")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def address(locator: Locator) -> str:
|
|
124
|
+
"""A locator as a reader would write it: a JSON Pointer bare, any other by its fields."""
|
|
125
|
+
if isinstance(locator, TreeLocator):
|
|
126
|
+
return locator.pointer
|
|
127
|
+
fields = locator.model_dump(mode="json", exclude={"contract", "kind"}, exclude_defaults=True)
|
|
128
|
+
return ", ".join(
|
|
129
|
+
f"{key} {json.dumps(value, ensure_ascii=False)}" for key, value in fields.items()
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _address(evidence: Evidence) -> str:
|
|
134
|
+
if isinstance(evidence, QuoteEvidence):
|
|
135
|
+
return ""
|
|
136
|
+
if isinstance(evidence, CorrespondenceEvidence):
|
|
137
|
+
return "; ".join(
|
|
138
|
+
f"{side.name}: {side.artifact} at {address(side.locator)}" for side in evidence.sides
|
|
139
|
+
)
|
|
140
|
+
if isinstance(evidence, AggregateEvidence):
|
|
141
|
+
return "; ".join(
|
|
142
|
+
f"{member.artifact} at {address(member.locator)}" for member in evidence.members
|
|
143
|
+
)
|
|
144
|
+
if isinstance(evidence, TableCellEvidence) and not evidence.addresses_one_row:
|
|
145
|
+
# Neither `row` nor `where`, or both: there is no one address to record.
|
|
146
|
+
return ""
|
|
147
|
+
return address(evidence.locator)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _conversion(evidence: Evidence) -> str:
|
|
151
|
+
if isinstance(evidence, CorrespondenceEvidence):
|
|
152
|
+
return "; ".join(
|
|
153
|
+
f"{side.name}: {side.conversion.value}" for side in evidence.sides if side.conversion
|
|
154
|
+
)
|
|
155
|
+
declared = getattr(evidence, "conversion", None)
|
|
156
|
+
return declared.value if declared else ""
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def checked(
|
|
160
|
+
claim: Claim, decisions: tuple[Decision, ...], at: str = ""
|
|
161
|
+
) -> tuple[CheckedAssertion, ...]:
|
|
162
|
+
"""Each assertion of one claim as a check found it, in the order declared."""
|
|
163
|
+
seen: collections.Counter[tuple[str, str]] = collections.Counter()
|
|
164
|
+
found = []
|
|
165
|
+
for evidence, decision in zip(claim.evidence, decisions, strict=True):
|
|
166
|
+
name = getattr(evidence, "name", "")
|
|
167
|
+
found.append(
|
|
168
|
+
CheckedAssertion(
|
|
169
|
+
claim_id=claim.id,
|
|
170
|
+
kind=evidence.kind,
|
|
171
|
+
name=name,
|
|
172
|
+
position=seen[evidence.kind, name],
|
|
173
|
+
claim_digest=claim.digest.value,
|
|
174
|
+
printed=evidence.text
|
|
175
|
+
if isinstance(evidence, QuoteEvidence)
|
|
176
|
+
else getattr(evidence, "reported", None),
|
|
177
|
+
address=_address(evidence),
|
|
178
|
+
conversion=_conversion(evidence),
|
|
179
|
+
digests={side.artifact_id: side.artifact_digest for side in decision.sides}
|
|
180
|
+
or {member.artifact_id: member.artifact_digest for member in decision.members}
|
|
181
|
+
or dict.fromkeys(evidence.artifacts, decision.artifact_digest),
|
|
182
|
+
outcome=decision.outcome,
|
|
183
|
+
reason=decision.reason,
|
|
184
|
+
checked_at=at,
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
seen[evidence.kind, name] += 1
|
|
188
|
+
return tuple(found)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def git_dir(root: pathlib.Path) -> pathlib.Path | None:
|
|
192
|
+
"""The git directory of the repository `root` is in, as git resolves it: a linked
|
|
193
|
+
worktree's own, and a submodule's. None outside a repository."""
|
|
194
|
+
found = try_run("rev-parse", "--absolute-git-dir", cwd=root)
|
|
195
|
+
return pathlib.Path(found) if found else None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _raw(path: pathlib.Path) -> list[str]:
|
|
199
|
+
"""A record's entries as stored, one per line."""
|
|
200
|
+
if not path.is_file():
|
|
201
|
+
return []
|
|
202
|
+
return [line.strip() for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _parsed(raw: str) -> CheckRecord | None:
|
|
206
|
+
try:
|
|
207
|
+
return CheckRecord.model_validate_json(raw)
|
|
208
|
+
except ValidationError:
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _chain(path: pathlib.Path, kind: Kind, lines: list[str]) -> RecordState:
|
|
213
|
+
"""Whether each entry follows from the one before it, and whether the head file agrees.
|
|
214
|
+
|
|
215
|
+
The status is the first fault's, as in the `results` ledger: a chain edited and then cut
|
|
216
|
+
is edited.
|
|
217
|
+
"""
|
|
218
|
+
status, problems, first = ChainStatus.INTACT, [], None
|
|
219
|
+
|
|
220
|
+
def fault(found: ChainStatus, entry: int, said: str) -> None:
|
|
221
|
+
nonlocal status, first
|
|
222
|
+
problems.append(said)
|
|
223
|
+
if status is ChainStatus.INTACT:
|
|
224
|
+
status = found
|
|
225
|
+
first = entry if first is None else min(first, entry)
|
|
226
|
+
|
|
227
|
+
prev = ZERO
|
|
228
|
+
for number, raw in enumerate(lines, start=1):
|
|
229
|
+
entry = _parsed(raw)
|
|
230
|
+
if entry is None:
|
|
231
|
+
fault(ChainStatus.CORRUPT, number, f"entry {number} is not one `repro verify` wrote")
|
|
232
|
+
else:
|
|
233
|
+
if entry.prev_hash != prev:
|
|
234
|
+
fault(
|
|
235
|
+
ChainStatus.EDITED,
|
|
236
|
+
max(number - 1, 1),
|
|
237
|
+
f"entry {number - 1} does not hash to what entry {number} recorded for the "
|
|
238
|
+
"entry before it"
|
|
239
|
+
if number > 1
|
|
240
|
+
else "entry 1 records an entry before it",
|
|
241
|
+
)
|
|
242
|
+
if entry.seq != number - 1:
|
|
243
|
+
fault(
|
|
244
|
+
ChainStatus.REORDERED,
|
|
245
|
+
number,
|
|
246
|
+
f"entry {number} is numbered {entry.seq + 1} of the chain",
|
|
247
|
+
)
|
|
248
|
+
prev = sha256_of_text(raw)
|
|
249
|
+
|
|
250
|
+
anchor = read_anchor(path)
|
|
251
|
+
if anchor is None:
|
|
252
|
+
if status is ChainStatus.INTACT:
|
|
253
|
+
status = ChainStatus.NO_ANCHOR
|
|
254
|
+
problems.append("no head file, so entries cut from the end would not be found")
|
|
255
|
+
elif anchor.get("count") != len(lines):
|
|
256
|
+
longer = len(lines) > anchor.get("count", 0)
|
|
257
|
+
if status is ChainStatus.INTACT:
|
|
258
|
+
status = ChainStatus.EXTENDED if longer else ChainStatus.TRUNCATED
|
|
259
|
+
problems.append(
|
|
260
|
+
f"the head file records {anchor.get('count')} entries, the record holds {len(lines)}"
|
|
261
|
+
)
|
|
262
|
+
elif anchor.get("head") != prev:
|
|
263
|
+
fault(
|
|
264
|
+
ChainStatus.EDITED,
|
|
265
|
+
max(len(lines), 1),
|
|
266
|
+
f"entry {len(lines)} does not hash to what the head file records",
|
|
267
|
+
)
|
|
268
|
+
return RecordState(
|
|
269
|
+
kind=kind,
|
|
270
|
+
path=str(path),
|
|
271
|
+
entries=len(lines),
|
|
272
|
+
status=status.value,
|
|
273
|
+
problems=tuple(problems),
|
|
274
|
+
unverifiable_from=first,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _prefix(one: list[str], other: list[str]) -> int:
|
|
279
|
+
"""How many entries two records share from the start."""
|
|
280
|
+
shared = 0
|
|
281
|
+
while shared < min(len(one), len(other)) and one[shared] == other[shared]:
|
|
282
|
+
shared += 1
|
|
283
|
+
return shared
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
class Records:
|
|
287
|
+
"""The project's records of checks, read once: each one's state, and per assertion each
|
|
288
|
+
way an entry holds it, oldest first."""
|
|
289
|
+
|
|
290
|
+
def __init__(self, root: pathlib.Path, local: bool = True) -> None:
|
|
291
|
+
at = git_dir(root) if local else None
|
|
292
|
+
self.no_local_record = NOT_A_REPOSITORY if local and at is None else ""
|
|
293
|
+
self.local = at / LOCAL if at is not None else None
|
|
294
|
+
self.tracked = root / RECORD
|
|
295
|
+
here = _raw(self.local) if self.local is not None else []
|
|
296
|
+
there = _raw(self.tracked)
|
|
297
|
+
shared = _prefix(here, there)
|
|
298
|
+
|
|
299
|
+
states = []
|
|
300
|
+
vouched: dict[str, bool] = {}
|
|
301
|
+
for kind, path, lines in (("local", self.local, here), ("tracked", self.tracked, there)):
|
|
302
|
+
if path is None or not (lines or read_anchor(path)):
|
|
303
|
+
continue
|
|
304
|
+
state = _chain(path, kind, lines)
|
|
305
|
+
if kind == "tracked" and here and shared < min(len(here), len(there)):
|
|
306
|
+
state = state.model_copy(update={"diverges_at": shared + 1})
|
|
307
|
+
states.append(state)
|
|
308
|
+
for number, raw in enumerate(lines, start=1):
|
|
309
|
+
sound = state.unverifiable_from is None or number < state.unverifiable_from
|
|
310
|
+
vouched[raw] = vouched.get(raw, False) or sound
|
|
311
|
+
self.states = tuple(states)
|
|
312
|
+
self.lines = {"local": here, "tracked": there}
|
|
313
|
+
|
|
314
|
+
# What the two share, then what each holds beyond it, in the order appended.
|
|
315
|
+
# An entry that does not parse is counted as a fault above and holds nothing to show.
|
|
316
|
+
read = {raw: entry for raw in (*here, *there) if (entry := _parsed(raw)) is not None}
|
|
317
|
+
beyond = sorted(
|
|
318
|
+
(raw for raw in dict.fromkeys(here[shared:] + there[shared:]) if raw in read),
|
|
319
|
+
key=lambda raw: read[raw].timestamp,
|
|
320
|
+
)
|
|
321
|
+
found: dict[Identity, list[CheckedAssertion]] = collections.defaultdict(list)
|
|
322
|
+
for raw in (*(raw for raw in here[:shared] if raw in read), *beyond):
|
|
323
|
+
entry = read[raw]
|
|
324
|
+
for assertion in entry.assertions:
|
|
325
|
+
found[assertion.identity].append(
|
|
326
|
+
assertion.model_copy(
|
|
327
|
+
update={"checked_at": entry.timestamp, "verifiable": vouched[raw]}
|
|
328
|
+
)
|
|
329
|
+
)
|
|
330
|
+
self.history = {identity: tuple(states) for identity, states in found.items()}
|
|
331
|
+
self.set_aside = tuple(
|
|
332
|
+
note.model_copy(update={"at": read[raw].timestamp})
|
|
333
|
+
for raw in (*(raw for raw in here[:shared] if raw in read), *beyond)
|
|
334
|
+
if (note := read[raw].set_aside) is not None
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
def state(self, kind: Kind) -> RecordState | None:
|
|
338
|
+
return next((state for state in self.states if state.kind == kind), None)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _take(behind: pathlib.Path, lines: list[str], held: int) -> None:
|
|
342
|
+
"""Append to one record the entries another holds beyond it, as stored, and move its head
|
|
343
|
+
file."""
|
|
344
|
+
behind.parent.mkdir(parents=True, exist_ok=True)
|
|
345
|
+
with behind.open("a", encoding="utf-8") as handle:
|
|
346
|
+
handle.write("".join(f"{line}\n" for line in lines[held:]))
|
|
347
|
+
handle.flush()
|
|
348
|
+
os.fsync(handle.fileno())
|
|
349
|
+
write_anchor(behind, len(lines), sha256_of_text(lines[-1]))
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def record(
|
|
353
|
+
manifest: Manifest,
|
|
354
|
+
report: VerificationReport,
|
|
355
|
+
local: bool = True,
|
|
356
|
+
tracked: bool = False,
|
|
357
|
+
program: str = "repro",
|
|
358
|
+
) -> None:
|
|
359
|
+
"""Append what one `repro verify` found to the project's records of checks.
|
|
360
|
+
|
|
361
|
+
To the local record where `local` and the project is in a git repository, and to the
|
|
362
|
+
tracked one as well where `tracked`. Nothing is written where every assertion is as it
|
|
363
|
+
was last recorded and the tracked record, if asked for, already holds every local entry.
|
|
364
|
+
|
|
365
|
+
Raises `RecordBrokenError` where the chain to append to does not verify,
|
|
366
|
+
`RecordDivergedError` where the tracked record is not the beginning of the local one, and
|
|
367
|
+
`OSError` where a record cannot be written.
|
|
368
|
+
"""
|
|
369
|
+
root = manifest.path.parent if manifest.path else pathlib.Path.cwd()
|
|
370
|
+
at = git_dir(root) if local else None
|
|
371
|
+
kept = at / LOCAL if at is not None else None
|
|
372
|
+
carried = root / RECORD
|
|
373
|
+
written = kept or (carried if tracked else None)
|
|
374
|
+
if written is None:
|
|
375
|
+
return
|
|
376
|
+
|
|
377
|
+
# One lock across reading the last entries and writing the next, so two checks at once
|
|
378
|
+
# append one after the other and the second finds what the first wrote.
|
|
379
|
+
with exclusive_lock(written):
|
|
380
|
+
found = Records(root, local)
|
|
381
|
+
here, there = found.lines["local"], found.lines["tracked"]
|
|
382
|
+
carried_state = found.state("tracked")
|
|
383
|
+
if (
|
|
384
|
+
kept is not None
|
|
385
|
+
and _prefix(here, there) == len(here) < len(there)
|
|
386
|
+
and carried_state is not None
|
|
387
|
+
and carried_state.unverifiable_from is None
|
|
388
|
+
):
|
|
389
|
+
# A clone, or a pull: the tracked record holds entries this clone has not seen.
|
|
390
|
+
_take(kept, there, len(here))
|
|
391
|
+
found = Records(root, local)
|
|
392
|
+
state = found.state("local" if kept is not None else "tracked")
|
|
393
|
+
if state is not None and ChainStatus(state.status) not in _SOUND:
|
|
394
|
+
raise RecordBrokenError(state, program)
|
|
395
|
+
changed = [
|
|
396
|
+
assertion
|
|
397
|
+
for claim, assessed in zip(manifest.claims, report.claims, strict=True)
|
|
398
|
+
for assertion in checked(claim, assessed.decisions)
|
|
399
|
+
if assertion.identity not in found.history
|
|
400
|
+
or not assertion.same_as(found.history[assertion.identity][-1])
|
|
401
|
+
]
|
|
402
|
+
if changed:
|
|
403
|
+
append_event(
|
|
404
|
+
written,
|
|
405
|
+
{
|
|
406
|
+
"schema_version": CheckRecord.model_fields["schema_version"].default,
|
|
407
|
+
"assertions": [
|
|
408
|
+
assertion.model_dump(mode="json", exclude={"checked_at", "verifiable"})
|
|
409
|
+
for assertion in changed
|
|
410
|
+
],
|
|
411
|
+
},
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
if tracked and kept is not None:
|
|
415
|
+
with exclusive_lock(carried):
|
|
416
|
+
here, there = _raw(kept), _raw(carried)
|
|
417
|
+
shared = _prefix(here, there)
|
|
418
|
+
if shared < len(there):
|
|
419
|
+
raise RecordDivergedError(carried, shared + 1)
|
|
420
|
+
state = _chain(carried, "tracked", there)
|
|
421
|
+
if there and ChainStatus(state.status) not in _SOUND:
|
|
422
|
+
raise RecordBrokenError(state, program)
|
|
423
|
+
if len(here) > len(there):
|
|
424
|
+
_take(carried, here, len(there))
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _move_aside(path: pathlib.Path, state: RecordState, lines: list[str]) -> RecordSetAside:
|
|
428
|
+
"""Rename a record and its head file to names that say when they were set aside, and say
|
|
429
|
+
what was found in it. Nothing is deleted, and a name already taken is not written over."""
|
|
430
|
+
stamp = datetime.datetime.now(datetime.UTC).strftime("%Y%m%dT%H%M%SZ")
|
|
431
|
+
vouched = len(lines) if state.unverifiable_from is None else state.unverifiable_from - 1
|
|
432
|
+
for name in (path.name, ANCHOR):
|
|
433
|
+
source, target = path.with_name(name), path.with_name(f"{name}.set-aside-{stamp}")
|
|
434
|
+
if target.exists():
|
|
435
|
+
raise FileExistsError(17, "File exists", str(target))
|
|
436
|
+
if source.exists():
|
|
437
|
+
os.replace(source, target)
|
|
438
|
+
problems = state.problems + (
|
|
439
|
+
(f"diverges from the local record at entry {state.diverges_at}",)
|
|
440
|
+
if state.diverges_at is not None
|
|
441
|
+
else ()
|
|
442
|
+
)
|
|
443
|
+
return RecordSetAside(
|
|
444
|
+
kind=state.kind,
|
|
445
|
+
kept_as=f"{path.name}.set-aside-{stamp}",
|
|
446
|
+
entries=len(lines),
|
|
447
|
+
head=sha256_of_text(lines[vouched - 1]) if vouched else ZERO,
|
|
448
|
+
status=state.status,
|
|
449
|
+
problems=problems,
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def set_aside(
|
|
454
|
+
manifest: Manifest, local: bool = True, tracked: bool = False
|
|
455
|
+
) -> tuple[RecordSetAside, ...]:
|
|
456
|
+
"""Move a damaged record of checks aside and say so in the chain that goes on.
|
|
457
|
+
|
|
458
|
+
The record `repro verify` appends to, where its chain does not verify: the next entry is
|
|
459
|
+
the first of a new chain and names the old one, the hash of its last entry that verified
|
|
460
|
+
and what was found. With `tracked`, the tracked record as well, where it does not verify or
|
|
461
|
+
is not the beginning of the local one; it is then copied afresh by the check that follows.
|
|
462
|
+
The files are renamed and kept.
|
|
463
|
+
|
|
464
|
+
Raises `RecordIntactError` where no record qualifies. Never called except by name.
|
|
465
|
+
"""
|
|
466
|
+
root = manifest.path.parent if manifest.path else pathlib.Path.cwd()
|
|
467
|
+
at = git_dir(root) if local else None
|
|
468
|
+
kept = at / LOCAL if at is not None else None
|
|
469
|
+
carried = root / RECORD
|
|
470
|
+
written = kept or carried
|
|
471
|
+
with exclusive_lock(written):
|
|
472
|
+
found = Records(root, local)
|
|
473
|
+
moved = []
|
|
474
|
+
for kind, path in (("local", kept), ("tracked", carried)):
|
|
475
|
+
state = found.state(kind)
|
|
476
|
+
if path is None or state is None or (path != written and not tracked):
|
|
477
|
+
continue
|
|
478
|
+
broken = ChainStatus(state.status) not in _SOUND
|
|
479
|
+
# A tracked record cannot follow a local one that starts again.
|
|
480
|
+
orphaned = path != written and (state.diverges_at is not None or bool(moved))
|
|
481
|
+
if broken or orphaned:
|
|
482
|
+
moved.append(_move_aside(path, state, found.lines[kind]))
|
|
483
|
+
if not moved:
|
|
484
|
+
raise RecordIntactError(root)
|
|
485
|
+
for note in moved:
|
|
486
|
+
append_event(
|
|
487
|
+
written,
|
|
488
|
+
{
|
|
489
|
+
"schema_version": CheckRecord.model_fields["schema_version"].default,
|
|
490
|
+
"assertions": [],
|
|
491
|
+
"set_aside": note.model_dump(mode="json", exclude={"at"}),
|
|
492
|
+
},
|
|
493
|
+
)
|
|
494
|
+
return tuple(moved)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
__all__ = [
|
|
498
|
+
"LOCAL",
|
|
499
|
+
"NOT_A_REPOSITORY",
|
|
500
|
+
"RECORD",
|
|
501
|
+
"Identity",
|
|
502
|
+
"RecordBrokenError",
|
|
503
|
+
"RecordDivergedError",
|
|
504
|
+
"RecordIntactError",
|
|
505
|
+
"Records",
|
|
506
|
+
"address",
|
|
507
|
+
"checked",
|
|
508
|
+
"git_dir",
|
|
509
|
+
"record",
|
|
510
|
+
"set_aside",
|
|
511
|
+
]
|