@ccoalm/ccl-skills 0.17.0 → 0.18.0
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.
- package/dist/assets/marketplace/plugins/ccl-skills/agent-context/session-start.md +8 -7
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/hooks.json +22 -0
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/remind-review-covers-head.sh +128 -0
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/remind-untracked-background.sh +53 -0
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_remind_review_covers_head.sh +104 -0
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_remind_untracked_background.sh +75 -0
- package/dist/assets/marketplace/plugins/ccl-skills/packages/opencode-plugin/ccl-skills.ts +8 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/references/development-completion.md +13 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/references/staged-review-contract.md +5 -3
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/review_gate.py +401 -21
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_review_client_compat.py +8 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_review_gate.sh +230 -7
- package/dist/assets/marketplace/plugins/ccl-skills/skills/multi-perspective-research/SKILL.md +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/SKILL.md +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/SKILL.md +6 -6
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/dual-track-review-gate.md +48 -119
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/extraction-quickstart.md +9 -9
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/source-register.md +8 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/validation-and-landing.md +2 -2
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/check_review_evidence_present.py +122 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/contract-anchors.tsv +0 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/extraction_review_gate.sh +37 -8
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/register-firing-path-resolution.rb +102 -20
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_ai_coding_implementation_gates.sh +16 -27
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_regressions.sh +3 -5
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_review_evidence_present.sh +81 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_extraction_review_gate.sh +130 -310
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_register_firing_path_wiring.sh +49 -3
- package/dist/assets/release.json +55 -45
- package/package.json +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/review_ledger_binding.py +0 -1242
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_review_ledger_binding.sh +0 -978
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_validate_extraction_review_state.sh +0 -1477
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/validate_extraction_review_state.py +0 -1183
|
@@ -1,1242 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Require the landing candidate to be the candidate an external round reviewed.
|
|
3
|
-
|
|
4
|
-
The dual-track ledger is caller-built and caller-run. Nothing at merge time read
|
|
5
|
-
it, so which candidate the rounds actually inspected was unchecked: a chain could
|
|
6
|
-
close out on the pre-fix candidate while a different tree merged. This gate closes
|
|
7
|
-
that gap from the merge side.
|
|
8
|
-
|
|
9
|
-
The reviewed identity is the packet the controller froze, so this recomputes that
|
|
10
|
-
packet with the controller's own `freeze_packet` rather than a second
|
|
11
|
-
implementation of the same bytes -- two implementations of one hash drift, and the
|
|
12
|
-
drift would read as a forged ledger. The bound set is every tracked path, minus
|
|
13
|
-
exactly what this round adds under a round's evidence directory. It is a default
|
|
14
|
-
of everything rather than a whitelist because a whitelist binds only the paths
|
|
15
|
-
some round happened to review: a pull request could carry a ledger valid for its
|
|
16
|
-
skill changes while also landing a root build script, a release script, or any
|
|
17
|
-
other executable path, and because that content does not move the candidate the
|
|
18
|
-
existing ledger still passed and the unreviewed content merged. Binding
|
|
19
|
-
everything makes the default fail-closed -- a new top-level path is bound the day
|
|
20
|
-
it appears rather than the day somebody remembers to add it. The cost is stated
|
|
21
|
-
rather than hidden: a change confined to documentation now needs a ledger too,
|
|
22
|
-
which is the direction this repository has already chosen for shared gates, where
|
|
23
|
-
a false positive is cheaper than a false negative.
|
|
24
|
-
|
|
25
|
-
The exclusion is computed per run (`added_evidence_paths`) rather than written
|
|
26
|
-
down as a subtree, and the difference is load-bearing. Something must be outside
|
|
27
|
-
the candidate or no ledger could ever be committed: a receipt inside the bound set
|
|
28
|
-
would move the very hash it records. But excluding all of `specs/` would exclude
|
|
29
|
-
far more than that -- a pull request could delete or rewrite an earlier round's
|
|
30
|
-
plan and receipts, the committed review history itself, and none of it would reach
|
|
31
|
-
the candidate, so the gate would pass while that history was corrupted. Only the
|
|
32
|
-
paths this round ADDS under a round's own `<round>/evidence/` directory are excluded. Every
|
|
33
|
-
modification and deletion under `specs/`, and every added path outside an evidence
|
|
34
|
-
directory, is bound like any other file. What remains outside is narrow and worth
|
|
35
|
-
naming: a file added under an EARLIER round's evidence directory is excluded too,
|
|
36
|
-
because the rule is structural rather than round-aware.
|
|
37
|
-
|
|
38
|
-
The candidate must also be committed (`require_committed_tree`). The frozen packet
|
|
39
|
-
is built from the working tree and includes untracked files, so a scratch file or
|
|
40
|
-
an unstaged edit inside the bound paths would silently produce a hash no clean
|
|
41
|
-
checkout recomputes -- the author records it in the ledger, and the merge-side run
|
|
42
|
-
then reports that nothing binds the landing candidate. Refusing out loud costs a
|
|
43
|
-
commit; the alternative costs a review round nobody can reproduce. The workflow
|
|
44
|
-
directory stays bound, as it was before: with only `skills/` bound, deleting the CI
|
|
45
|
-
step that runs this gate would not move the candidate the evidence has to match.
|
|
46
|
-
|
|
47
|
-
The candidate's Git identity and the reviewer's input are two different sizes.
|
|
48
|
-
A landing candidate is base..HEAD and has no natural byte limit; a review packet
|
|
49
|
-
is what one reviewer can read whole, and the controller caps it at
|
|
50
|
-
`MAX_PACKET_BYTES`. Binding the landing candidate to ONE packet hash therefore
|
|
51
|
-
made the reviewer's ceiling the pull request's ceiling: a candidate larger than
|
|
52
|
-
one packet could not be frozen, no ledger could ever bind it, and authors split
|
|
53
|
-
the pull request instead of the review -- eight merges for one release. The
|
|
54
|
-
review side already allowed splitting a large candidate by path into partitions
|
|
55
|
-
(the `code-review` skill's packet rule); what was missing was the merge side
|
|
56
|
-
consuming them. A committed landing partition manifest closes that: it names
|
|
57
|
-
path partitions whose changed files together equal the candidate's changed files
|
|
58
|
-
exactly once, and each partition's `candidate_sha256`, which is what
|
|
59
|
-
`--print-candidate --paths <partition>` already answers. This gate recomputes
|
|
60
|
-
every partition with the same `freeze_packet`, requires a validator-accepted
|
|
61
|
-
ledger per partition, and refuses any manifest whose parts do not add up to the
|
|
62
|
-
whole: an uncovered file, an overlapping file, a partition that no longer
|
|
63
|
-
reproduces, a base other than the fork point, or an aggregate hash that does not
|
|
64
|
-
reproduce its own partitions. The manifest carries a top-level 64-hex
|
|
65
|
-
`candidate_sha256` -- the aggregate identity -- so it satisfies the existing
|
|
66
|
-
receipt predicate and committing it moves no partition; that is the load-bearing
|
|
67
|
-
reason for the field, and the exclusion predicate itself is unchanged. What the
|
|
68
|
-
manifest proves is the same narrow thing the single ledger proves, taken per
|
|
69
|
-
part: every byte that lands is a byte some external round froze and inspected.
|
|
70
|
-
|
|
71
|
-
A third aggregate is an integration branch that accumulated several reviewed
|
|
72
|
-
rounds and is then promoted as one pull request. No single ledger binds that
|
|
73
|
-
candidate, and a path partition cannot either: two rounds that append to the
|
|
74
|
-
same file (a register, a changelog) leave that file's promotion diff equal to
|
|
75
|
-
the sum of both appends, which no round ever froze. What every such round DID
|
|
76
|
-
leave behind is a first-parent merge whose second parent is the reviewed head
|
|
77
|
-
and whose own pull request this gate already bound at the round's own base. So
|
|
78
|
-
the gate walks HEAD's first-parent chain down to the first commit already on
|
|
79
|
-
the target branch and classifies each step: a merge whose second parent is on
|
|
80
|
-
the target is a sync merge and owes no evidence; any other merge is a round,
|
|
81
|
-
rebound by running this same gate in a detached checkout of its second parent
|
|
82
|
-
against its first parent; a non-merge commit is refused, because nothing
|
|
83
|
-
reviewed binds what was pushed straight to the branch. Every step's tree must
|
|
84
|
-
equal `git merge-tree --write-tree` of its parents, so a merge commit that
|
|
85
|
-
carries a hand resolution or any other content beyond the automatic merge is
|
|
86
|
-
refused as unreviewed. A round is rebound with THIS checkout's controller and
|
|
87
|
-
validator, never the round's own: a round could carry a hollowed validator in
|
|
88
|
-
its own branch and a later round could restore the real one, so judging history
|
|
89
|
-
with history's tools would let that round's forged ledger stand forever. Using
|
|
90
|
-
the landing tree's tools means a controller or validator change between a round
|
|
91
|
-
and the promotion can stop an old round reproducing, and that reads as a refusal
|
|
92
|
-
rather than a pass. A round's evidence is likewise read from the landing tree,
|
|
93
|
-
not from the round's own checkout: a ledger is evidence because the validator
|
|
94
|
-
accepts it and its candidate hash equals the round's packet, not because of
|
|
95
|
-
where it was committed, so a round that merged without its ledger is bound by a
|
|
96
|
-
later review of the same bytes committed on the integration branch -- and by
|
|
97
|
-
nothing less, since a ledger for any other bytes does not match. A round is
|
|
98
|
-
never itself a chain, so the walk is one level deep by construction. The detached checkout is released with `git worktree
|
|
99
|
-
remove` and its removal verified against the worktree list; a checkout that
|
|
100
|
-
cannot be released is an error, never a pass, and nothing prunes registrations
|
|
101
|
-
this run did not create. The chain is
|
|
102
|
-
consulted only for the default path set: a narrowed `--paths` has no round-level
|
|
103
|
-
ledger to bind. Merge-queue aggregation of several still-unmerged pull requests
|
|
104
|
-
into one HEAD is yet another aggregate and remains unsolved: those requests have
|
|
105
|
-
no first-parent merges of their own for a chain to bind.
|
|
106
|
-
|
|
107
|
-
Boundaries this gate does NOT close, stated because a gate that lives inside the
|
|
108
|
-
candidate cannot authenticate itself: it cannot prove the caller retained every
|
|
109
|
-
earlier chain; it runs the candidate's own validator, so a candidate that also
|
|
110
|
-
rewrites that validator is outside what any in-repo check can settle; and a pull
|
|
111
|
-
request may edit the workflow step that runs it. The fail-closed branch is pinned
|
|
112
|
-
as a contract anchor so hollowing it also edits a registry another required check
|
|
113
|
-
verifies; the workflow step itself is NOT pinned, because that registry addresses
|
|
114
|
-
skill files and a cross-tree row breaks the checker against its own fixtures. The terminal authority is the platform's
|
|
115
|
-
required-check configuration plus human review of changes to this gate itself,
|
|
116
|
-
both of which live outside the candidate. What this proves is narrow and worth
|
|
117
|
-
stating plainly: that what merges is the candidate an external round inspected --
|
|
118
|
-
not that the round was honest, and not that it found nothing. Any terminal state
|
|
119
|
-
the validator accepts satisfies this gate, including one that carries unresolved
|
|
120
|
-
findings forward for a human: this binds identity, and the verdict on the findings
|
|
121
|
-
stays with the human who merges.
|
|
122
|
-
"""
|
|
123
|
-
|
|
124
|
-
from __future__ import annotations
|
|
125
|
-
|
|
126
|
-
import sys
|
|
127
|
-
|
|
128
|
-
# Importing the controller must not perturb the tree this gate hashes: a written
|
|
129
|
-
# __pycache__ lands as an untracked binary file inside the reviewed paths and the
|
|
130
|
-
# packet freeze then fails on it. Set before any import that can write bytecode.
|
|
131
|
-
sys.dont_write_bytecode = True
|
|
132
|
-
|
|
133
|
-
import argparse
|
|
134
|
-
import contextlib
|
|
135
|
-
import hashlib
|
|
136
|
-
import importlib.util
|
|
137
|
-
import json
|
|
138
|
-
import os
|
|
139
|
-
import re
|
|
140
|
-
import shutil
|
|
141
|
-
import subprocess
|
|
142
|
-
import tempfile
|
|
143
|
-
import time
|
|
144
|
-
import types
|
|
145
|
-
from pathlib import Path
|
|
146
|
-
|
|
147
|
-
VALIDATOR = "validate_extraction_review_state.py"
|
|
148
|
-
CONTROLLER = Path("skills") / "code-review" / "scripts" / "review_gate.py"
|
|
149
|
-
# Every tracked path. See the module docstring: the inversion is what stops an
|
|
150
|
-
# unreviewed path from riding along on a valid ledger. The only exclusion is
|
|
151
|
-
# computed per run by `added_evidence_paths` -- the receipts this round adds --
|
|
152
|
-
# because a written-down subtree would also hide edits to committed history.
|
|
153
|
-
EVIDENCE_ROOT = "specs"
|
|
154
|
-
EVIDENCE_MEMBER = re.compile(r"^specs/[^/]+/evidence/")
|
|
155
|
-
# A receipt is small; anything larger is not one, and reading it is not free.
|
|
156
|
-
MAX_RECEIPT_BYTES = 4_000_000
|
|
157
|
-
DEFAULT_PATHS = (".",)
|
|
158
|
-
# A landing partition manifest splits one candidate by path into packets a
|
|
159
|
-
# reviewer can read whole. Its shape is closed: exactly these keys, this kind,
|
|
160
|
-
# and a bounded partition count, so a manifest cannot double as a ledger or
|
|
161
|
-
# smuggle fields the gate does not read.
|
|
162
|
-
MANIFEST_KIND = "landing_partition_manifest"
|
|
163
|
-
MANIFEST_SCHEMA_VERSION = 1
|
|
164
|
-
MANIFEST_KEYS = {"schema_version", "kind", "base", "partitions", "candidate_sha256"}
|
|
165
|
-
PARTITION_KEYS = {"paths", "candidate_sha256"}
|
|
166
|
-
MAX_PARTITIONS = 64
|
|
167
|
-
HEX40 = re.compile(r"^[0-9a-f]{40}$")
|
|
168
|
-
HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
|
169
|
-
# A first-parent chain longer than this is not an integration branch's round
|
|
170
|
-
# history; refuse rather than walk an unbounded history.
|
|
171
|
-
MAX_CHAIN_STEPS = 64
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
class ManifestError(Exception):
|
|
175
|
-
"""A manifest that does not describe this candidate; the message is the reason."""
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
class ChainError(Exception):
|
|
179
|
-
"""A first-parent step that is not a bound round; the message names the step."""
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
class Binding:
|
|
183
|
-
"""What one evaluation of a checkout against a base concluded.
|
|
184
|
-
|
|
185
|
-
`ok` with `summary` is the accept line (without its token prefix) plus any
|
|
186
|
-
per-part proof lines; otherwise `failure` carries the diagnostics in the
|
|
187
|
-
order they are printed. `changed` is the candidate's changed-file set, which
|
|
188
|
-
the chain path uses to check that rounds cover the promotion.
|
|
189
|
-
"""
|
|
190
|
-
|
|
191
|
-
def __init__(self) -> None:
|
|
192
|
-
self.ok = False
|
|
193
|
-
self.summary = ""
|
|
194
|
-
self.proofs: list[str] = []
|
|
195
|
-
self.failure: list[str] = []
|
|
196
|
-
self.changed: list[str] = []
|
|
197
|
-
self.fork = ""
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
def emit(message: str) -> None:
|
|
201
|
-
print(message, file=sys.stderr)
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
def load_controller(repo_root: Path) -> types.ModuleType:
|
|
205
|
-
controller_path = repo_root / CONTROLLER
|
|
206
|
-
if not controller_path.is_file():
|
|
207
|
-
raise SystemExit(
|
|
208
|
-
f"review_ledger_binding_error: controller not found at {controller_path}"
|
|
209
|
-
)
|
|
210
|
-
spec = importlib.util.spec_from_file_location(
|
|
211
|
-
"ccl_review_gate_for_binding", controller_path
|
|
212
|
-
)
|
|
213
|
-
if spec is None or spec.loader is None:
|
|
214
|
-
raise SystemExit("review_ledger_binding_error: controller is not importable")
|
|
215
|
-
module = importlib.util.module_from_spec(spec)
|
|
216
|
-
spec.loader.exec_module(module)
|
|
217
|
-
return module
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
def resolve_base(repo_root: Path, base: str) -> str:
|
|
221
|
-
"""Resolve the caller's base to a commit id before it reaches any git command.
|
|
222
|
-
|
|
223
|
-
An option-shaped base (``--quiet``) is read by git as an option rather than a
|
|
224
|
-
revision, and ``git diff`` with no revision compares the index to the working
|
|
225
|
-
tree: in a clean checkout that reports no paths at all, so the gate would pass
|
|
226
|
-
having compared nothing.
|
|
227
|
-
"""
|
|
228
|
-
result = subprocess.run(
|
|
229
|
-
[
|
|
230
|
-
"git",
|
|
231
|
-
"-C",
|
|
232
|
-
str(repo_root),
|
|
233
|
-
"rev-parse",
|
|
234
|
-
"--verify",
|
|
235
|
-
"--quiet",
|
|
236
|
-
"--end-of-options",
|
|
237
|
-
f"{base}^{{commit}}",
|
|
238
|
-
],
|
|
239
|
-
stdout=subprocess.PIPE,
|
|
240
|
-
stderr=subprocess.PIPE,
|
|
241
|
-
text=True,
|
|
242
|
-
check=False,
|
|
243
|
-
)
|
|
244
|
-
resolved = result.stdout.strip()
|
|
245
|
-
if result.returncode != 0 or len(resolved) != 40 or not all(
|
|
246
|
-
character in "0123456789abcdef" for character in resolved
|
|
247
|
-
):
|
|
248
|
-
raise SystemExit(
|
|
249
|
-
f"review_ledger_binding_error: base does not resolve to a commit: {base}"
|
|
250
|
-
)
|
|
251
|
-
return resolved
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
def fork_point(repo_root: Path, base: str) -> str:
|
|
255
|
-
"""Compare against where this branch left the base, not the base's tip.
|
|
256
|
-
|
|
257
|
-
A candidate measured against the tip absorbs every unrelated change the base
|
|
258
|
-
branch gained meanwhile, so an advance on the target branch silently restates
|
|
259
|
-
what this branch is and voids evidence that is still correct. The fork point
|
|
260
|
-
is what the branch actually adds, and it does not move when someone else
|
|
261
|
-
merges.
|
|
262
|
-
"""
|
|
263
|
-
result = subprocess.run(
|
|
264
|
-
["git", "-C", str(repo_root), "merge-base", base, "HEAD"],
|
|
265
|
-
stdout=subprocess.PIPE,
|
|
266
|
-
stderr=subprocess.PIPE,
|
|
267
|
-
text=True,
|
|
268
|
-
check=False,
|
|
269
|
-
)
|
|
270
|
-
resolved = result.stdout.strip()
|
|
271
|
-
if result.returncode != 0 or len(resolved) != 40:
|
|
272
|
-
raise SystemExit(
|
|
273
|
-
f"review_ledger_binding_error: no fork point between HEAD and {base}"
|
|
274
|
-
)
|
|
275
|
-
return resolved
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
def added_evidence_paths(repo_root: Path, base: str) -> list[str]:
|
|
279
|
-
"""Paths this round ADDS under a round's evidence directory.
|
|
280
|
-
|
|
281
|
-
These are the only paths the candidate may exclude, and the predicate is what
|
|
282
|
-
the file IS, not where it sits. Two earlier shapes of this exclusion were
|
|
283
|
-
each broken by an adversarial round, and both failures were the same one: the
|
|
284
|
-
rule named a location and the location stood in for "this is a receipt".
|
|
285
|
-
Excluding all of `specs/` let a pull request delete or rewrite an earlier
|
|
286
|
-
round's plan and receipts -- the committed review history itself -- with no
|
|
287
|
-
evidence required. Narrowing that to added paths under an evidence directory
|
|
288
|
-
then let an arbitrary added file there, a script included, ride through
|
|
289
|
-
unreviewed for exactly the same reason.
|
|
290
|
-
|
|
291
|
-
So the third shape stops using the path as a proxy. A file is excluded only
|
|
292
|
-
when it is what the exclusion exists for: a committed JSON object carrying
|
|
293
|
-
the 64-hex `candidate_sha256` that makes it a receipt about some candidate.
|
|
294
|
-
Its directory still has to be a round's evidence directory, because that is
|
|
295
|
-
where receipts belong, but the directory alone no longer buys exclusion.
|
|
296
|
-
Anything else added there -- a script, a fixture, a data file, a JSON file
|
|
297
|
-
with no candidate binding -- is bound like any other path, as is every
|
|
298
|
-
modification and deletion under `specs/`.
|
|
299
|
-
"""
|
|
300
|
-
result = subprocess.run(
|
|
301
|
-
[
|
|
302
|
-
"git", "-C", str(repo_root), "diff", "--name-only", "-z",
|
|
303
|
-
"--diff-filter=A", base, "HEAD", "--", EVIDENCE_ROOT,
|
|
304
|
-
],
|
|
305
|
-
stdout=subprocess.PIPE,
|
|
306
|
-
stderr=subprocess.PIPE,
|
|
307
|
-
# A pathname is bytes, and -z hands them over raw. Strict decoding would
|
|
308
|
-
# turn one undecodable filename into a crash inside a gate whose job is
|
|
309
|
-
# to fail cleanly, so undecodable bytes survive as surrogates and simply
|
|
310
|
-
# do not match the evidence pattern.
|
|
311
|
-
text=True,
|
|
312
|
-
errors="surrogateescape",
|
|
313
|
-
check=False,
|
|
314
|
-
)
|
|
315
|
-
if result.returncode != 0:
|
|
316
|
-
raise SystemExit(
|
|
317
|
-
"review_ledger_binding_error: cannot enumerate added evidence: "
|
|
318
|
-
f"{result.stderr.strip()}"
|
|
319
|
-
)
|
|
320
|
-
excluded: list[str] = []
|
|
321
|
-
# -z output is NUL-separated and never C-quoted, so a path carrying a
|
|
322
|
-
# non-ASCII byte is enumerated as itself rather than as an escaped literal
|
|
323
|
-
# that no pattern here would match.
|
|
324
|
-
for line in result.stdout.split("\0"):
|
|
325
|
-
if not line or not EVIDENCE_MEMBER.match(line):
|
|
326
|
-
continue
|
|
327
|
-
if is_candidate_receipt(repo_root, line):
|
|
328
|
-
excluded.append(line)
|
|
329
|
-
return excluded
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
def bound_evidence_paths(repo_root: Path, base: str) -> list[str]:
|
|
333
|
-
"""Evidence this round ADDS that stays inside the candidate.
|
|
334
|
-
|
|
335
|
-
The complement of the exclusion, reported when nothing binds. Committing one
|
|
336
|
-
of these after the review rounds moves the candidate out from under their
|
|
337
|
-
receipts, and the failure that surfaces -- nothing binds -- names neither the
|
|
338
|
-
file nor the ordering. This class has now been observed three times; the
|
|
339
|
-
diagnosis belongs where the failure appears, not in a document the round has
|
|
340
|
-
to know to open.
|
|
341
|
-
"""
|
|
342
|
-
result = subprocess.run(
|
|
343
|
-
[
|
|
344
|
-
"git", "-C", str(repo_root), "diff", "--name-only", "-z",
|
|
345
|
-
"--diff-filter=A", base, "HEAD", "--", EVIDENCE_ROOT,
|
|
346
|
-
],
|
|
347
|
-
stdout=subprocess.PIPE,
|
|
348
|
-
stderr=subprocess.PIPE,
|
|
349
|
-
text=True,
|
|
350
|
-
errors="surrogateescape",
|
|
351
|
-
check=False,
|
|
352
|
-
)
|
|
353
|
-
if result.returncode != 0:
|
|
354
|
-
return []
|
|
355
|
-
return [
|
|
356
|
-
line
|
|
357
|
-
for line in result.stdout.split("\0")
|
|
358
|
-
if line and EVIDENCE_MEMBER.match(line) and not is_candidate_receipt(repo_root, line)
|
|
359
|
-
]
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
def is_candidate_receipt(repo_root: Path, path_value: str) -> bool:
|
|
363
|
-
"""Whether the committed blob at this path is a receipt about a candidate.
|
|
364
|
-
|
|
365
|
-
Read from the object store rather than the working tree: the exclusion has to
|
|
366
|
-
describe what merges. A blob that is not JSON, is not an object, or carries no
|
|
367
|
-
64-hex `candidate_sha256` is not a receipt, whatever it is named or wherever
|
|
368
|
-
it sits, and it stays inside the candidate.
|
|
369
|
-
"""
|
|
370
|
-
result = subprocess.run(
|
|
371
|
-
["git", "-C", str(repo_root), "cat-file", "blob", f"HEAD:{path_value}"],
|
|
372
|
-
stdout=subprocess.PIPE,
|
|
373
|
-
stderr=subprocess.PIPE,
|
|
374
|
-
check=False,
|
|
375
|
-
)
|
|
376
|
-
if result.returncode != 0 or len(result.stdout) > MAX_RECEIPT_BYTES:
|
|
377
|
-
return False
|
|
378
|
-
try:
|
|
379
|
-
payload = json.loads(result.stdout.decode("utf-8"))
|
|
380
|
-
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
381
|
-
return False
|
|
382
|
-
if not isinstance(payload, dict):
|
|
383
|
-
return False
|
|
384
|
-
binding = payload.get("candidate_sha256")
|
|
385
|
-
return (
|
|
386
|
-
isinstance(binding, str)
|
|
387
|
-
and len(binding) == 64
|
|
388
|
-
and all(character in "0123456789abcdef" for character in binding)
|
|
389
|
-
)
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
def require_committed_tree(repo_root: Path, paths: tuple[str, ...]) -> None:
|
|
393
|
-
"""Refuse a candidate the merge cannot reproduce.
|
|
394
|
-
|
|
395
|
-
The frozen packet is built from the working tree and includes untracked
|
|
396
|
-
files, so a scratch file or an unstaged edit inside the bound paths silently
|
|
397
|
-
produces a hash no clean checkout will ever recompute: the author records it
|
|
398
|
-
in the ledger and the merge-side run then reports that no evidence binds the
|
|
399
|
-
landing candidate. Refusing out loud costs a commit; the alternative costs a
|
|
400
|
-
review round nobody can reproduce. This is the same stance the evidence tree
|
|
401
|
-
already takes -- what merges is the committed tree.
|
|
402
|
-
"""
|
|
403
|
-
result = subprocess.run(
|
|
404
|
-
["git", "-C", str(repo_root), "status", "--porcelain", "--", *paths],
|
|
405
|
-
stdout=subprocess.PIPE,
|
|
406
|
-
stderr=subprocess.PIPE,
|
|
407
|
-
text=True,
|
|
408
|
-
check=False,
|
|
409
|
-
)
|
|
410
|
-
if result.returncode != 0:
|
|
411
|
-
raise SystemExit(
|
|
412
|
-
f"review_ledger_binding_error: cannot read tree state: {result.stderr.strip()}"
|
|
413
|
-
)
|
|
414
|
-
dirty = [line for line in result.stdout.splitlines() if line]
|
|
415
|
-
if dirty:
|
|
416
|
-
raise SystemExit(
|
|
417
|
-
"review_ledger_binding_error: the candidate tree carries uncommitted "
|
|
418
|
-
"changes, so its hash is not the one a clean checkout recomputes; "
|
|
419
|
-
"commit them first: " + ", ".join(entry[3:] for entry in dirty[:5])
|
|
420
|
-
)
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
def changed_skill_paths(repo_root: Path, base: str, paths: tuple[str, ...]) -> list[str]:
|
|
424
|
-
result = subprocess.run(
|
|
425
|
-
["git", "-C", str(repo_root), "diff", "--name-only", base, "--", *paths],
|
|
426
|
-
stdout=subprocess.PIPE,
|
|
427
|
-
stderr=subprocess.PIPE,
|
|
428
|
-
text=True,
|
|
429
|
-
check=False,
|
|
430
|
-
)
|
|
431
|
-
if result.returncode != 0:
|
|
432
|
-
raise SystemExit(
|
|
433
|
-
f"review_ledger_binding_error: cannot diff against {base}: {result.stderr.strip()}"
|
|
434
|
-
)
|
|
435
|
-
return [line for line in result.stdout.splitlines() if line]
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
def candidate_hash(module: types.ModuleType, repo_root: Path, base: str, paths: tuple[str, ...]) -> str:
|
|
439
|
-
args = argparse.Namespace(
|
|
440
|
-
cwd=str(repo_root),
|
|
441
|
-
diff_file=None,
|
|
442
|
-
base=base,
|
|
443
|
-
paths=list(paths),
|
|
444
|
-
wording_only_proof_file=None,
|
|
445
|
-
)
|
|
446
|
-
packet_path, _packet_sha256, candidate_sha256, _bytes, _paths, _secrets = module.freeze_packet(
|
|
447
|
-
args, time.monotonic() + 120
|
|
448
|
-
)
|
|
449
|
-
try:
|
|
450
|
-
Path(packet_path).unlink(missing_ok=True)
|
|
451
|
-
except OSError:
|
|
452
|
-
pass
|
|
453
|
-
return candidate_sha256
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
def canonical_digest(value: object) -> str:
|
|
457
|
-
canonical = json.dumps(
|
|
458
|
-
value, sort_keys=True, separators=(",", ":"), ensure_ascii=False
|
|
459
|
-
).encode("utf-8")
|
|
460
|
-
return hashlib.sha256(canonical).hexdigest()
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
def manifest_aggregate(base: str, partitions: list[dict]) -> str:
|
|
464
|
-
"""The aggregate identity a manifest carries as its own `candidate_sha256`.
|
|
465
|
-
|
|
466
|
-
Computed here and nowhere else: the renderer writes it and the gate rechecks
|
|
467
|
-
it, so two implementations of one hash cannot drift into a forged manifest.
|
|
468
|
-
"""
|
|
469
|
-
return canonical_digest(
|
|
470
|
-
{
|
|
471
|
-
"schema_version": MANIFEST_SCHEMA_VERSION,
|
|
472
|
-
"kind": MANIFEST_KIND,
|
|
473
|
-
"base": base,
|
|
474
|
-
"partitions": partitions,
|
|
475
|
-
}
|
|
476
|
-
)
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
def validate_partition_path(value: object) -> str:
|
|
480
|
-
"""A partition path is a plain relative path, never a pathspec.
|
|
481
|
-
|
|
482
|
-
The gate appends its own exclusions; a manifest that could name `:(exclude)`
|
|
483
|
-
or a glob would choose what its partition does not cover, and a leading `-`
|
|
484
|
-
would reach git as an option. Reject the shape here rather than trust git to
|
|
485
|
-
interpret it the way the manifest author hoped.
|
|
486
|
-
"""
|
|
487
|
-
if not isinstance(value, str) or not value:
|
|
488
|
-
raise ManifestError("partition path must be a non-empty string")
|
|
489
|
-
if value.startswith((":", "-", "/")) or any(ord(ch) < 32 for ch in value):
|
|
490
|
-
raise ManifestError(f"partition path is not a plain relative path: {value!r}")
|
|
491
|
-
# Git reads `*`, `?`, `[` and `\` inside a pathspec as glob/escape syntax even
|
|
492
|
-
# without magic; a wildcard partition would let the manifest choose its own
|
|
493
|
-
# coverage, so it is refused as a path rather than handed to git.
|
|
494
|
-
if any(ch in value for ch in "*?[]\\"):
|
|
495
|
-
raise ManifestError(f"partition path contains a pathspec wildcard: {value!r}")
|
|
496
|
-
if ".." in value.split("/"):
|
|
497
|
-
raise ManifestError(f"partition path escapes the repository: {value!r}")
|
|
498
|
-
return value
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
def parse_manifest(payload: dict, fork: str) -> tuple[list[list[str]], list[str]]:
|
|
502
|
-
"""Return (partition path lists, partition hashes) or raise ManifestError.
|
|
503
|
-
|
|
504
|
-
Order of checks is cheapest first and each failure names one reason, so an
|
|
505
|
-
author reads which part failed to add up rather than a generic refusal.
|
|
506
|
-
"""
|
|
507
|
-
if set(payload) != MANIFEST_KEYS:
|
|
508
|
-
raise ManifestError("manifest does not carry exactly the manifest keys")
|
|
509
|
-
if payload["schema_version"] != MANIFEST_SCHEMA_VERSION or type(payload["schema_version"]) is not int:
|
|
510
|
-
raise ManifestError(f"manifest schema_version must be {MANIFEST_SCHEMA_VERSION}")
|
|
511
|
-
base = payload["base"]
|
|
512
|
-
if not isinstance(base, str) or not HEX40.match(base):
|
|
513
|
-
raise ManifestError("manifest base must be a 40-hex commit id")
|
|
514
|
-
if base != fork:
|
|
515
|
-
raise ManifestError(
|
|
516
|
-
f"manifest base {base[:12]} is not this candidate's fork point {fork[:12]}"
|
|
517
|
-
)
|
|
518
|
-
partitions = payload["partitions"]
|
|
519
|
-
if not isinstance(partitions, list) or not 1 <= len(partitions) <= MAX_PARTITIONS:
|
|
520
|
-
raise ManifestError(f"manifest must list between 1 and {MAX_PARTITIONS} partitions")
|
|
521
|
-
path_lists: list[list[str]] = []
|
|
522
|
-
digests: list[str] = []
|
|
523
|
-
seen: set[str] = set()
|
|
524
|
-
for index, partition in enumerate(partitions, start=1):
|
|
525
|
-
if not isinstance(partition, dict) or set(partition) != PARTITION_KEYS:
|
|
526
|
-
raise ManifestError(f"partition {index} does not carry exactly paths and candidate_sha256")
|
|
527
|
-
paths = partition["paths"]
|
|
528
|
-
if not isinstance(paths, list) or not paths:
|
|
529
|
-
raise ManifestError(f"partition {index} names no paths")
|
|
530
|
-
validated = [validate_partition_path(value) for value in paths]
|
|
531
|
-
for value in validated:
|
|
532
|
-
if value in seen:
|
|
533
|
-
raise ManifestError(f"partition path is listed twice: {value}")
|
|
534
|
-
seen.add(value)
|
|
535
|
-
digest = partition["candidate_sha256"]
|
|
536
|
-
if not isinstance(digest, str) or not HEX64.match(digest):
|
|
537
|
-
raise ManifestError(f"partition {index} candidate_sha256 must be 64-hex")
|
|
538
|
-
path_lists.append(validated)
|
|
539
|
-
digests.append(digest)
|
|
540
|
-
aggregate = payload["candidate_sha256"]
|
|
541
|
-
if aggregate != manifest_aggregate(base, partitions):
|
|
542
|
-
raise ManifestError("manifest aggregate candidate_sha256 does not reproduce its partitions")
|
|
543
|
-
return path_lists, digests
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
def partition_coverage(
|
|
547
|
-
repo_root: Path,
|
|
548
|
-
base: str,
|
|
549
|
-
path_lists: list[list[str]],
|
|
550
|
-
excludes: tuple[str, ...],
|
|
551
|
-
changed_all: list[str],
|
|
552
|
-
) -> list[list[str]]:
|
|
553
|
-
"""Each partition's changed files; refuse unless they tile the candidate.
|
|
554
|
-
|
|
555
|
-
Name-only diffs are cheap, so every way the parts can fail to add up is found
|
|
556
|
-
before any packet is frozen: an empty partition, a changed file in no
|
|
557
|
-
partition, or a changed file in two. Overlap is refused rather than tolerated
|
|
558
|
-
because two verdicts over one file leave undefined which one covers it.
|
|
559
|
-
"""
|
|
560
|
-
per_partition: list[list[str]] = []
|
|
561
|
-
owner: dict[str, int] = {}
|
|
562
|
-
overlaps: list[str] = []
|
|
563
|
-
for index, paths in enumerate(path_lists, start=1):
|
|
564
|
-
changed = changed_skill_paths(repo_root, base, tuple(paths) + excludes)
|
|
565
|
-
if not changed:
|
|
566
|
-
raise ManifestError(f"partition {index} ({' '.join(paths)}) covers no changed path")
|
|
567
|
-
for value in changed:
|
|
568
|
-
if value in owner:
|
|
569
|
-
overlaps.append(value)
|
|
570
|
-
owner[value] = index
|
|
571
|
-
per_partition.append(changed)
|
|
572
|
-
if overlaps:
|
|
573
|
-
raise ManifestError(
|
|
574
|
-
"partitions overlap on changed paths: " + ", ".join(sorted(set(overlaps))[:5])
|
|
575
|
-
)
|
|
576
|
-
uncovered = sorted(set(changed_all) - set(owner))
|
|
577
|
-
if uncovered:
|
|
578
|
-
raise ManifestError(
|
|
579
|
-
"changed paths uncovered by every partition: " + ", ".join(uncovered[:5])
|
|
580
|
-
)
|
|
581
|
-
# Equality, not containment: under a narrowed --paths scope a partition can
|
|
582
|
-
# reach changed files outside the reviewed set, and a union larger than the
|
|
583
|
-
# candidate is as wrong as one smaller than it.
|
|
584
|
-
outside = sorted(set(owner) - set(changed_all))
|
|
585
|
-
if outside:
|
|
586
|
-
raise ManifestError(
|
|
587
|
-
"partitions cover changed paths outside the reviewed scope: " + ", ".join(outside[:5])
|
|
588
|
-
)
|
|
589
|
-
return per_partition
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
def render_manifest(
|
|
593
|
-
module: types.ModuleType,
|
|
594
|
-
repo_root: Path,
|
|
595
|
-
base: str,
|
|
596
|
-
path_lists: list[list[str]],
|
|
597
|
-
excludes: tuple[str, ...],
|
|
598
|
-
changed_all: list[str],
|
|
599
|
-
) -> dict:
|
|
600
|
-
"""Build the manifest an author commits, with every hash computed by this gate."""
|
|
601
|
-
validated = [[validate_partition_path(value) for value in paths] for paths in path_lists]
|
|
602
|
-
seen: set[str] = set()
|
|
603
|
-
for paths in validated:
|
|
604
|
-
for value in paths:
|
|
605
|
-
if value in seen:
|
|
606
|
-
raise ManifestError(f"partition path is listed twice: {value}")
|
|
607
|
-
seen.add(value)
|
|
608
|
-
partition_coverage(repo_root, base, validated, excludes, changed_all)
|
|
609
|
-
partitions = [
|
|
610
|
-
{
|
|
611
|
-
"paths": paths,
|
|
612
|
-
"candidate_sha256": candidate_hash(module, repo_root, base, tuple(paths) + excludes),
|
|
613
|
-
}
|
|
614
|
-
for paths in validated
|
|
615
|
-
]
|
|
616
|
-
return {
|
|
617
|
-
"schema_version": MANIFEST_SCHEMA_VERSION,
|
|
618
|
-
"kind": MANIFEST_KIND,
|
|
619
|
-
"base": base,
|
|
620
|
-
"partitions": partitions,
|
|
621
|
-
"candidate_sha256": manifest_aggregate(base, partitions),
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
def accepted_ledger_for(
|
|
626
|
-
evidence: list[tuple[Path, dict]],
|
|
627
|
-
evidence_home: Path,
|
|
628
|
-
validator: Path,
|
|
629
|
-
digest: str,
|
|
630
|
-
rejected: list[str],
|
|
631
|
-
) -> str | None:
|
|
632
|
-
"""The first validator-accepted closeout ledger bound to `digest`, if any.
|
|
633
|
-
|
|
634
|
-
The same criterion the single-candidate path uses: a receipt-shaped file is
|
|
635
|
-
not evidence, only a ledger the validator accepts, because this gate cannot
|
|
636
|
-
authenticate that a controller minted what it reads. `evidence_home` is the
|
|
637
|
-
tree the evidence was enumerated from, used only to name the ledger.
|
|
638
|
-
"""
|
|
639
|
-
for path, payload in evidence:
|
|
640
|
-
if payload.get("candidate_sha256") != digest:
|
|
641
|
-
continue
|
|
642
|
-
if "closeout_state" not in payload or "controller_receipts" not in payload:
|
|
643
|
-
continue
|
|
644
|
-
relative = str(path.relative_to(evidence_home))
|
|
645
|
-
accepted, output = validator_accepts(validator, path)
|
|
646
|
-
if accepted:
|
|
647
|
-
return f"{relative} -- {output}"
|
|
648
|
-
rejected.append(f"{relative}: {output}")
|
|
649
|
-
return None
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
def bind_manifest(
|
|
653
|
-
module: types.ModuleType,
|
|
654
|
-
repo_root: Path,
|
|
655
|
-
base: str,
|
|
656
|
-
payload: dict,
|
|
657
|
-
excludes: tuple[str, ...],
|
|
658
|
-
changed_all: list[str],
|
|
659
|
-
evidence: list[tuple[Path, dict]],
|
|
660
|
-
evidence_home: Path,
|
|
661
|
-
validator: Path,
|
|
662
|
-
rejected_ledgers: list[str],
|
|
663
|
-
) -> list[str]:
|
|
664
|
-
"""Bind the candidate through one manifest; return per-partition proof lines.
|
|
665
|
-
|
|
666
|
-
Raises ManifestError naming the first part that does not add up.
|
|
667
|
-
"""
|
|
668
|
-
path_lists, digests = parse_manifest(payload, base)
|
|
669
|
-
partition_coverage(repo_root, base, path_lists, excludes, changed_all)
|
|
670
|
-
proofs: list[str] = []
|
|
671
|
-
for index, (paths, recorded) in enumerate(zip(path_lists, digests), start=1):
|
|
672
|
-
label = f"partition {index} ({' '.join(paths)})"
|
|
673
|
-
actual = candidate_hash(module, repo_root, base, tuple(paths) + excludes)
|
|
674
|
-
if actual != recorded:
|
|
675
|
-
raise ManifestError(
|
|
676
|
-
f"{label} recorded {recorded[:12]}... but does not reproduce: the candidate now hashes to {actual[:12]}..."
|
|
677
|
-
)
|
|
678
|
-
proof = accepted_ledger_for(evidence, evidence_home, validator, actual, rejected_ledgers)
|
|
679
|
-
if proof is None:
|
|
680
|
-
raise ManifestError(f"no accepted ledger binds {label} {actual}")
|
|
681
|
-
proofs.append(f" {label} {actual[:12]}... <- {proof}")
|
|
682
|
-
return proofs
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
def validator_accepts(validator: Path, ledger_path: Path) -> tuple[bool, str]:
|
|
686
|
-
result = subprocess.run(
|
|
687
|
-
[sys.executable, str(validator), str(ledger_path)],
|
|
688
|
-
stdout=subprocess.PIPE,
|
|
689
|
-
stderr=subprocess.STDOUT,
|
|
690
|
-
text=True,
|
|
691
|
-
check=False,
|
|
692
|
-
timeout=60,
|
|
693
|
-
)
|
|
694
|
-
return result.returncode == 0, result.stdout.strip()
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
def scan(repo_root: Path, evidence_root: str) -> list[tuple[Path, dict]]:
|
|
698
|
-
"""Enumerate committed evidence only.
|
|
699
|
-
|
|
700
|
-
What merges is the committed tree, so evidence that is untracked or modified
|
|
701
|
-
in the working tree is not evidence about the landing candidate -- and a gate
|
|
702
|
-
that reads it would accept a ledger nobody can find after the merge. The
|
|
703
|
-
enumeration comes from HEAD, and a dirty evidence tree is refused outright
|
|
704
|
-
rather than silently read from disk.
|
|
705
|
-
"""
|
|
706
|
-
listing = subprocess.run(
|
|
707
|
-
["git", "-C", str(repo_root), "ls-tree", "-r", "--name-only", "HEAD", "--", evidence_root],
|
|
708
|
-
stdout=subprocess.PIPE,
|
|
709
|
-
stderr=subprocess.PIPE,
|
|
710
|
-
text=True,
|
|
711
|
-
check=False,
|
|
712
|
-
)
|
|
713
|
-
if listing.returncode != 0:
|
|
714
|
-
return []
|
|
715
|
-
dirty = subprocess.run(
|
|
716
|
-
["git", "-C", str(repo_root), "status", "--porcelain", "--", evidence_root],
|
|
717
|
-
stdout=subprocess.PIPE,
|
|
718
|
-
stderr=subprocess.PIPE,
|
|
719
|
-
text=True,
|
|
720
|
-
check=False,
|
|
721
|
-
)
|
|
722
|
-
if dirty.returncode != 0 or dirty.stdout.strip():
|
|
723
|
-
raise SystemExit(
|
|
724
|
-
"review_ledger_binding_error: the evidence tree has uncommitted changes; "
|
|
725
|
-
"commit the ledger and its receipts before this gate can read them"
|
|
726
|
-
)
|
|
727
|
-
found: list[tuple[Path, dict]] = []
|
|
728
|
-
for relative in sorted(line for line in listing.stdout.splitlines() if line.endswith(".json")):
|
|
729
|
-
path = repo_root / relative
|
|
730
|
-
try:
|
|
731
|
-
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
732
|
-
except (OSError, UnicodeError, json.JSONDecodeError):
|
|
733
|
-
continue
|
|
734
|
-
if isinstance(payload, dict):
|
|
735
|
-
found.append((path, payload))
|
|
736
|
-
return found
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
def git_read(repo_root: Path, arguments: list[str], failure: str) -> str:
|
|
740
|
-
"""Run a read-only git query whose failure is an environment error, not a verdict."""
|
|
741
|
-
result = subprocess.run(
|
|
742
|
-
["git", "-C", str(repo_root), *arguments],
|
|
743
|
-
stdout=subprocess.PIPE,
|
|
744
|
-
stderr=subprocess.PIPE,
|
|
745
|
-
text=True,
|
|
746
|
-
check=False,
|
|
747
|
-
)
|
|
748
|
-
if result.returncode != 0:
|
|
749
|
-
raise SystemExit(f"review_ledger_binding_error: {failure}: {result.stderr.strip()}")
|
|
750
|
-
return result.stdout.strip()
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
def is_ancestor(repo_root: Path, commit: str, tip: str) -> bool:
|
|
754
|
-
result = subprocess.run(
|
|
755
|
-
["git", "-C", str(repo_root), "merge-base", "--is-ancestor", commit, tip],
|
|
756
|
-
stdout=subprocess.PIPE,
|
|
757
|
-
stderr=subprocess.PIPE,
|
|
758
|
-
text=True,
|
|
759
|
-
check=False,
|
|
760
|
-
)
|
|
761
|
-
if result.returncode == 0:
|
|
762
|
-
return True
|
|
763
|
-
if result.returncode == 1:
|
|
764
|
-
return False
|
|
765
|
-
raise SystemExit(
|
|
766
|
-
f"review_ledger_binding_error: cannot test ancestry of {commit[:12]}: {result.stderr.strip()}"
|
|
767
|
-
)
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
def automatic_merge_tree(repo_root: Path, first: str, second: str) -> str | None:
|
|
771
|
-
"""The tree git itself produces merging `second` into `first`, or None on conflict.
|
|
772
|
-
|
|
773
|
-
A merge commit whose tree is anything else carries content beyond its two
|
|
774
|
-
parents -- a hand resolution, an extra file, a post-merge edit -- and that
|
|
775
|
-
content was reviewed by nobody. Conflicts are refused for the same reason:
|
|
776
|
-
whatever resolved them is unreviewed. An old git that lacks `--write-tree`
|
|
777
|
-
is an environment error rather than a verdict either way.
|
|
778
|
-
"""
|
|
779
|
-
result = subprocess.run(
|
|
780
|
-
["git", "-C", str(repo_root), "merge-tree", "--write-tree", first, second],
|
|
781
|
-
stdout=subprocess.PIPE,
|
|
782
|
-
stderr=subprocess.PIPE,
|
|
783
|
-
text=True,
|
|
784
|
-
check=False,
|
|
785
|
-
)
|
|
786
|
-
if result.returncode == 0:
|
|
787
|
-
tree = result.stdout.splitlines()[0].strip() if result.stdout else ""
|
|
788
|
-
if not HEX40.match(tree):
|
|
789
|
-
raise SystemExit("review_ledger_binding_error: git merge-tree --write-tree printed no tree")
|
|
790
|
-
return tree
|
|
791
|
-
if result.returncode == 1:
|
|
792
|
-
return None
|
|
793
|
-
raise SystemExit(
|
|
794
|
-
"review_ledger_binding_error: git merge-tree --write-tree is unavailable "
|
|
795
|
-
f"(git 2.38 or newer is required): {result.stderr.strip()}"
|
|
796
|
-
)
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
def walk_first_parent_chain(repo_root: Path, base_tip: str) -> list[tuple[str, str, str, str]]:
|
|
800
|
-
"""Classify each first-parent step from HEAD down to the target branch.
|
|
801
|
-
|
|
802
|
-
Returns (kind, merge, first_parent, second_parent) per step, newest first,
|
|
803
|
-
where kind is `sync` (second parent already on the target) or `round`.
|
|
804
|
-
Raises ChainError at the first step that is not a merge, is not the
|
|
805
|
-
automatic merge of its parents, or when the walk does not reach the target.
|
|
806
|
-
"""
|
|
807
|
-
steps: list[tuple[str, str, str, str]] = []
|
|
808
|
-
commit = git_read(repo_root, ["rev-parse", "--verify", "HEAD^{commit}"], "cannot resolve HEAD")
|
|
809
|
-
while True:
|
|
810
|
-
if is_ancestor(repo_root, commit, base_tip):
|
|
811
|
-
return steps
|
|
812
|
-
if len(steps) >= MAX_CHAIN_STEPS:
|
|
813
|
-
break
|
|
814
|
-
parents = git_read(
|
|
815
|
-
repo_root, ["rev-list", "--parents", "-n", "1", commit], f"cannot read parents of {commit[:12]}"
|
|
816
|
-
).split()[1:]
|
|
817
|
-
if len(parents) != 2:
|
|
818
|
-
shape = "an octopus merge" if len(parents) > 2 else "not a merge commit"
|
|
819
|
-
raise ChainError(
|
|
820
|
-
f"{commit[:12]} is {shape}; nothing reviewed binds its content"
|
|
821
|
-
if len(parents) < 2
|
|
822
|
-
else f"{commit[:12]} is {shape}; only two-parent merges are bound"
|
|
823
|
-
)
|
|
824
|
-
first, second = parents
|
|
825
|
-
own_tree = git_read(repo_root, ["rev-parse", f"{commit}^{{tree}}"], f"cannot read tree of {commit[:12]}")
|
|
826
|
-
if automatic_merge_tree(repo_root, first, second) != own_tree:
|
|
827
|
-
raise ChainError(
|
|
828
|
-
f"{commit[:12]} tree differs from the automatic merge of its parents "
|
|
829
|
-
f"({first[:12]} + {second[:12]}); whatever else it carries was reviewed by nobody"
|
|
830
|
-
)
|
|
831
|
-
kind = "sync" if is_ancestor(repo_root, second, base_tip) else "round"
|
|
832
|
-
steps.append((kind, commit, first, second))
|
|
833
|
-
commit = first
|
|
834
|
-
raise ChainError(
|
|
835
|
-
f"the first-parent chain runs more than {MAX_CHAIN_STEPS} steps without reaching the base"
|
|
836
|
-
)
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
@contextlib.contextmanager
|
|
840
|
-
def detached_checkout(repo_root: Path, commit: str):
|
|
841
|
-
"""A throwaway worktree at `commit`, outside the repository, removed on exit.
|
|
842
|
-
|
|
843
|
-
The controller freezes the working tree, so a round can only be rebound from
|
|
844
|
-
a checkout of its head. The checkout lives outside the repository root so it
|
|
845
|
-
is never an untracked path inside the bound set, and it is removed whether
|
|
846
|
-
the round binds or not.
|
|
847
|
-
"""
|
|
848
|
-
path = Path(tempfile.mkdtemp(prefix="review-ledger-binding-round-"))
|
|
849
|
-
added = subprocess.run(
|
|
850
|
-
["git", "-C", str(repo_root), "worktree", "add", "--detach", "--quiet", str(path), commit],
|
|
851
|
-
stdout=subprocess.PIPE,
|
|
852
|
-
stderr=subprocess.PIPE,
|
|
853
|
-
text=True,
|
|
854
|
-
check=False,
|
|
855
|
-
)
|
|
856
|
-
resolved = path.resolve()
|
|
857
|
-
if added.returncode != 0:
|
|
858
|
-
# A failed add can still have registered the checkout or populated the
|
|
859
|
-
# directory; release it through the same verified path as a success, so
|
|
860
|
-
# a failed run leaves no repository worktree state behind either.
|
|
861
|
-
problem = release_checkout(repo_root, path, resolved)
|
|
862
|
-
if problem:
|
|
863
|
-
problem = f" (and the partial checkout could not be released: {problem})"
|
|
864
|
-
raise SystemExit(
|
|
865
|
-
f"review_ledger_binding_error: cannot check out round head {commit[:12]}: "
|
|
866
|
-
f"{added.stderr.strip()}{problem or ''}"
|
|
867
|
-
)
|
|
868
|
-
try:
|
|
869
|
-
yield resolved
|
|
870
|
-
finally:
|
|
871
|
-
problem = release_checkout(repo_root, path, resolved)
|
|
872
|
-
if problem:
|
|
873
|
-
emit(f"review_ledger_binding_error: cannot release the detached checkout {path}: {problem}")
|
|
874
|
-
# Reached only when the body did not raise: a verdict that would have been
|
|
875
|
-
# `ok` must not stand on a checkout this run failed to release. When the body
|
|
876
|
-
# raised, the outcome is already a refusal or an error and the problem was
|
|
877
|
-
# emitted above.
|
|
878
|
-
if problem:
|
|
879
|
-
raise SystemExit(
|
|
880
|
-
f"review_ledger_binding_error: cannot release the detached checkout {path}: {problem}"
|
|
881
|
-
)
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
def release_checkout(repo_root: Path, path: Path, resolved: Path) -> str | None:
|
|
885
|
-
"""Remove the detached checkout and verify git no longer registers it.
|
|
886
|
-
|
|
887
|
-
The removal's result is checked, not discarded, and the registry is read
|
|
888
|
-
back: a stale registration left behind would make the next run's worktree
|
|
889
|
-
state a lie. No `worktree prune` runs here, because prune is repository-wide
|
|
890
|
-
and would also drop registrations this run did not create.
|
|
891
|
-
"""
|
|
892
|
-
removed = subprocess.run(
|
|
893
|
-
["git", "-C", str(repo_root), "worktree", "remove", "--force", str(path)],
|
|
894
|
-
stdout=subprocess.PIPE,
|
|
895
|
-
stderr=subprocess.PIPE,
|
|
896
|
-
text=True,
|
|
897
|
-
check=False,
|
|
898
|
-
)
|
|
899
|
-
# NUL-terminated output: the line-oriented porcelain quotes a path that
|
|
900
|
-
# carries a tab, newline, or other unusual byte, and a quoted line would
|
|
901
|
-
# match neither spelling below, reading a surviving registration as gone.
|
|
902
|
-
listing = subprocess.run(
|
|
903
|
-
["git", "-C", str(repo_root), "worktree", "list", "--porcelain", "-z"],
|
|
904
|
-
stdout=subprocess.PIPE,
|
|
905
|
-
stderr=subprocess.PIPE,
|
|
906
|
-
check=False,
|
|
907
|
-
)
|
|
908
|
-
if listing.returncode != 0:
|
|
909
|
-
return f"cannot read the worktree list: {listing.stderr.decode('utf-8', 'replace').strip()}"
|
|
910
|
-
registered = {
|
|
911
|
-
field.decode("utf-8", "surrogateescape")
|
|
912
|
-
for field in listing.stdout.split(b"\0")
|
|
913
|
-
if field.startswith(b"worktree ")
|
|
914
|
-
}
|
|
915
|
-
if f"worktree {path}" in registered or f"worktree {resolved}" in registered:
|
|
916
|
-
detail = f": {removed.stderr.strip()}" if removed.returncode != 0 else ""
|
|
917
|
-
return f"the checkout is still registered after removal{detail}"
|
|
918
|
-
if path.exists():
|
|
919
|
-
# Unregistered but present: the directory this run created before git
|
|
920
|
-
# registered anything, or files git left without a registration. It is
|
|
921
|
-
# this run's own temporary directory, so deleting it touches no
|
|
922
|
-
# repository state; a directory that survives that is reported.
|
|
923
|
-
shutil.rmtree(path, ignore_errors=True)
|
|
924
|
-
if path.exists():
|
|
925
|
-
return "the checkout directory still exists after removal"
|
|
926
|
-
return None
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
def bind_chain(
|
|
930
|
-
repo_root: Path, base_tip: str, changed_all: list[str], evidence_root: str
|
|
931
|
-
) -> tuple[int, list[str]]:
|
|
932
|
-
"""Bind the candidate round by round along HEAD's first-parent chain.
|
|
933
|
-
|
|
934
|
-
Returns (round count, proof lines) or raises ChainError naming the first
|
|
935
|
-
step that does not add up. Each round is rebound by this same gate in a
|
|
936
|
-
detached checkout of its head against its first parent, with THIS tree's
|
|
937
|
-
controller, validator and committed evidence (never the round's own tools;
|
|
938
|
-
the round's own evidence is part of this tree's history and is found there),
|
|
939
|
-
and never as a chain of its own.
|
|
940
|
-
"""
|
|
941
|
-
steps = walk_first_parent_chain(repo_root, base_tip)
|
|
942
|
-
proofs: list[str] = []
|
|
943
|
-
covered: set[str] = set()
|
|
944
|
-
rounds = 0
|
|
945
|
-
for kind, merge, first, second in steps:
|
|
946
|
-
if kind == "sync":
|
|
947
|
-
proofs.append(f" sync {merge[:12]} (already on the base)")
|
|
948
|
-
continue
|
|
949
|
-
with detached_checkout(repo_root, second) as round_root:
|
|
950
|
-
binding = bind_candidate(
|
|
951
|
-
round_root,
|
|
952
|
-
first,
|
|
953
|
-
DEFAULT_PATHS,
|
|
954
|
-
evidence_root,
|
|
955
|
-
allow_chain=False,
|
|
956
|
-
tools_root=repo_root,
|
|
957
|
-
evidence_tree=repo_root,
|
|
958
|
-
)
|
|
959
|
-
subject = git_read(repo_root, ["log", "-1", "--format=%s", merge], f"cannot read {merge[:12]}")
|
|
960
|
-
if not binding.ok:
|
|
961
|
-
reason = binding.failure[0] if binding.failure else "no accepted review evidence"
|
|
962
|
-
raise ChainError(
|
|
963
|
-
f"round {merge[:12]} ({subject}) does not bind at its own base {first[:12]}: {reason}"
|
|
964
|
-
)
|
|
965
|
-
rounds += 1
|
|
966
|
-
covered.update(binding.changed)
|
|
967
|
-
proofs.append(f" round {merge[:12]} <- {binding.summary}")
|
|
968
|
-
proofs.extend(" " + line for line in binding.proofs)
|
|
969
|
-
if rounds == 0:
|
|
970
|
-
raise ChainError("no round merge on the chain binds the changed paths")
|
|
971
|
-
# Under the automatic-merge invariant every promoted change reached HEAD
|
|
972
|
-
# through some round; this name-level check is the second net under that
|
|
973
|
-
# invariant, not the invariant itself.
|
|
974
|
-
outside = sorted(set(changed_all) - covered)
|
|
975
|
-
if outside:
|
|
976
|
-
raise ChainError("changed paths outside every bound round: " + ", ".join(outside[:5]))
|
|
977
|
-
return rounds, proofs
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
def candidate_scope(
|
|
981
|
-
repo_root: Path, base_tip: str, user_paths: tuple[str, ...]
|
|
982
|
-
) -> tuple[str, tuple[str, ...], tuple[str, ...], list[str]]:
|
|
983
|
-
"""Resolve (fork point, exclusions, bound paths, changed files) for one checkout."""
|
|
984
|
-
fork = fork_point(repo_root, base_tip)
|
|
985
|
-
# The exclusion is derived from this round's own diff, not written down as a
|
|
986
|
-
# subtree, so edits to committed history stay inside the candidate.
|
|
987
|
-
# These names come from the candidate's own tree and are handed back to git as
|
|
988
|
-
# pathspecs, so `literal` stops git reading a filename as a pattern. A review
|
|
989
|
-
# round called this a total bypass -- a receipt named `*` excluding everything
|
|
990
|
-
# -- and that did not reproduce: the exclusion carries the full path, so a glob
|
|
991
|
-
# in the filename expands only within that one evidence directory, whose other
|
|
992
|
-
# members are receipts anyway. The claim is recorded as narrowed rather than
|
|
993
|
-
# confirmed, and no test asserts a bypass this gate does not have. `literal`
|
|
994
|
-
# stays because interpreting these names as patterns is a capability the gate
|
|
995
|
-
# never needed, and removing it costs nothing.
|
|
996
|
-
excludes = tuple(f":(exclude,literal){path}" for path in added_evidence_paths(repo_root, fork))
|
|
997
|
-
paths = tuple(user_paths) + excludes
|
|
998
|
-
require_committed_tree(repo_root, paths)
|
|
999
|
-
changed = changed_skill_paths(repo_root, fork, paths)
|
|
1000
|
-
return fork, excludes, paths, changed
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
def bind_candidate(
|
|
1004
|
-
repo_root: Path,
|
|
1005
|
-
base_tip: str,
|
|
1006
|
-
user_paths: tuple[str, ...],
|
|
1007
|
-
evidence_root: str,
|
|
1008
|
-
allow_chain: bool,
|
|
1009
|
-
tools_root: Path | None = None,
|
|
1010
|
-
evidence_tree: Path | None = None,
|
|
1011
|
-
) -> Binding:
|
|
1012
|
-
"""Evaluate one checkout against one base: single ledger, then manifest, then chain.
|
|
1013
|
-
|
|
1014
|
-
`tools_root` names the tree whose controller and validator judge the
|
|
1015
|
-
candidate; it defaults to the checkout itself and is the landing tree when a
|
|
1016
|
-
historical round is rebound, so a round never judges itself with its own tools.
|
|
1017
|
-
`evidence_tree` names the tree whose committed evidence is consulted; it too
|
|
1018
|
-
defaults to the checkout and is the landing tree when a historical round is
|
|
1019
|
-
rebound. Evidence is a validator-accepted closeout bound to the round's own
|
|
1020
|
-
candidate hash wherever it was committed: a round that merged without its
|
|
1021
|
-
ledger is bound by a later review of the same bytes, committed on the
|
|
1022
|
-
integration branch, and by nothing less -- the candidate hash and the
|
|
1023
|
-
validator, not the file's location, are what make a ledger evidence.
|
|
1024
|
-
"""
|
|
1025
|
-
binding = Binding()
|
|
1026
|
-
tools = tools_root if tools_root is not None else repo_root
|
|
1027
|
-
evidence_home = evidence_tree if evidence_tree is not None else repo_root
|
|
1028
|
-
fork, excludes, paths, changed = candidate_scope(repo_root, base_tip, user_paths)
|
|
1029
|
-
binding.fork = fork
|
|
1030
|
-
binding.changed = changed
|
|
1031
|
-
if not changed:
|
|
1032
|
-
# No reviewed path moved, so there is no candidate to freeze and nothing to
|
|
1033
|
-
# bind. Say which it is rather than letting an empty packet surface as a
|
|
1034
|
-
# freeze error, which reads like a broken gate.
|
|
1035
|
-
binding.ok = True
|
|
1036
|
-
binding.summary = f"no reviewed-path change against {fork}"
|
|
1037
|
-
return binding
|
|
1038
|
-
|
|
1039
|
-
module = load_controller(tools)
|
|
1040
|
-
# The whole candidate may be larger than one packet. That is no longer a
|
|
1041
|
-
# terminal error: record why the single freeze failed and let a committed
|
|
1042
|
-
# partition manifest bind the candidate part by part.
|
|
1043
|
-
expected: str | None = None
|
|
1044
|
-
whole_error: str | None = None
|
|
1045
|
-
try:
|
|
1046
|
-
expected = candidate_hash(module, repo_root, fork, paths)
|
|
1047
|
-
except Exception as exc: # noqa: BLE001 - surface the controller's own message
|
|
1048
|
-
whole_error = str(exc)
|
|
1049
|
-
|
|
1050
|
-
validator = tools / "skills" / "skill-extraction-workflow" / "scripts" / VALIDATOR
|
|
1051
|
-
evidence = scan(evidence_home, evidence_root)
|
|
1052
|
-
ledgers: list[str] = []
|
|
1053
|
-
if expected is not None:
|
|
1054
|
-
# Only a validator-accepted ledger counts. A receipt-shaped file proves
|
|
1055
|
-
# nothing on its own: this gate cannot authenticate that a controller
|
|
1056
|
-
# minted it, so any branch keyed on a self-declared field is a bypass a
|
|
1057
|
-
# contributor can hand-write.
|
|
1058
|
-
proof = accepted_ledger_for(evidence, evidence_home, validator, expected, ledgers)
|
|
1059
|
-
if proof is not None:
|
|
1060
|
-
binding.ok = True
|
|
1061
|
-
binding.summary = (
|
|
1062
|
-
f"{proof.split(' -- ', 1)[0]} binds the landing candidate "
|
|
1063
|
-
f"({expected[:12]}...) -- {proof.split(' -- ', 1)[1]}"
|
|
1064
|
-
)
|
|
1065
|
-
return binding
|
|
1066
|
-
|
|
1067
|
-
manifests: list[str] = []
|
|
1068
|
-
for path, payload in evidence:
|
|
1069
|
-
if payload.get("kind") != MANIFEST_KIND:
|
|
1070
|
-
continue
|
|
1071
|
-
relative = str(path.relative_to(evidence_home))
|
|
1072
|
-
try:
|
|
1073
|
-
proofs = bind_manifest(
|
|
1074
|
-
module, repo_root, fork, payload, excludes, changed, evidence, evidence_home, validator, ledgers
|
|
1075
|
-
)
|
|
1076
|
-
except ManifestError as exc:
|
|
1077
|
-
manifests.append(f"{relative}: {exc}")
|
|
1078
|
-
continue
|
|
1079
|
-
except Exception as exc: # noqa: BLE001 - surface the controller's own message
|
|
1080
|
-
manifests.append(f"{relative}: cannot freeze a partition packet: {exc}")
|
|
1081
|
-
continue
|
|
1082
|
-
binding.ok = True
|
|
1083
|
-
binding.summary = (
|
|
1084
|
-
f"{relative} binds the landing candidate as {len(proofs)} partitions "
|
|
1085
|
-
f"(aggregate {payload['candidate_sha256'][:12]}...)"
|
|
1086
|
-
)
|
|
1087
|
-
binding.proofs = proofs
|
|
1088
|
-
return binding
|
|
1089
|
-
|
|
1090
|
-
chain_rows: list[str] = []
|
|
1091
|
-
if allow_chain and tuple(user_paths) == DEFAULT_PATHS:
|
|
1092
|
-
try:
|
|
1093
|
-
rounds, proofs = bind_chain(repo_root, base_tip, changed, evidence_root)
|
|
1094
|
-
except ChainError as exc:
|
|
1095
|
-
chain_rows.append(str(exc))
|
|
1096
|
-
else:
|
|
1097
|
-
binding.ok = True
|
|
1098
|
-
binding.summary = (
|
|
1099
|
-
f"the first-parent chain binds the landing candidate as {rounds} rounds "
|
|
1100
|
-
f"({fork[:12]}..HEAD)"
|
|
1101
|
-
)
|
|
1102
|
-
binding.proofs = proofs
|
|
1103
|
-
return binding
|
|
1104
|
-
elif allow_chain:
|
|
1105
|
-
chain_rows.append(
|
|
1106
|
-
"first-parent chain binding is evaluated only over the default path set "
|
|
1107
|
-
"(--paths .); a narrowed scope has no round-level ledger to bind"
|
|
1108
|
-
)
|
|
1109
|
-
|
|
1110
|
-
if expected is None:
|
|
1111
|
-
binding.failure.append(
|
|
1112
|
-
"review_ledger_binding_failed: the whole candidate cannot be frozen as one "
|
|
1113
|
-
f"packet ({whole_error}) and no committed landing partition manifest binds it"
|
|
1114
|
-
)
|
|
1115
|
-
binding.failure.append(
|
|
1116
|
-
" split the candidate by path: --print-manifest --partition <paths> "
|
|
1117
|
-
"[--partition <paths> ...] renders the manifest; commit it with one "
|
|
1118
|
-
"validated ledger per partition"
|
|
1119
|
-
)
|
|
1120
|
-
else:
|
|
1121
|
-
binding.failure.append(
|
|
1122
|
-
"review_ledger_binding_failed: no accepted review evidence binds the landing "
|
|
1123
|
-
f"candidate {expected}"
|
|
1124
|
-
)
|
|
1125
|
-
binding.failure.append(f" reviewed paths: {' '.join(paths)} against {fork}")
|
|
1126
|
-
binding.failure.append(f" changed files: {len(changed)}")
|
|
1127
|
-
binding.failure.extend(f" rejected ledger -> {row}" for row in ledgers)
|
|
1128
|
-
binding.failure.extend(f" rejected manifest -> {row}" for row in manifests)
|
|
1129
|
-
binding.failure.extend(f" rejected chain -> {row}" for row in chain_rows)
|
|
1130
|
-
if not ledgers and not manifests:
|
|
1131
|
-
binding.failure.append(
|
|
1132
|
-
" no committed ledger records this candidate; run the extraction review "
|
|
1133
|
-
"lane against the final, committed tree"
|
|
1134
|
-
)
|
|
1135
|
-
inside = bound_evidence_paths(repo_root, fork)
|
|
1136
|
-
if inside:
|
|
1137
|
-
binding.failure.append(
|
|
1138
|
-
" this round added evidence that stays inside the candidate: "
|
|
1139
|
-
+ ", ".join(inside[:5])
|
|
1140
|
-
+ ("" if len(inside) <= 5 else f", and {len(inside) - 5} more")
|
|
1141
|
-
)
|
|
1142
|
-
binding.failure.append(
|
|
1143
|
-
" only added JSON carrying a candidate_sha256 is excluded, so bound "
|
|
1144
|
-
"evidence such as base attestations and excerpts must be committed "
|
|
1145
|
-
"BEFORE the review rounds; committing it after moves the candidate out "
|
|
1146
|
-
"from under their receipts"
|
|
1147
|
-
)
|
|
1148
|
-
return binding
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
def main() -> int:
|
|
1152
|
-
parser = argparse.ArgumentParser(description=__doc__)
|
|
1153
|
-
parser.add_argument("--repo-root", default=".")
|
|
1154
|
-
parser.add_argument("--base", default=None)
|
|
1155
|
-
parser.add_argument(
|
|
1156
|
-
"--allow-unevaluated",
|
|
1157
|
-
action="store_true",
|
|
1158
|
-
help="permit a run with no resolvable base to exit 0, for events that have none",
|
|
1159
|
-
)
|
|
1160
|
-
parser.add_argument("--evidence-root", default="specs")
|
|
1161
|
-
parser.add_argument("--paths", nargs="*", default=list(DEFAULT_PATHS))
|
|
1162
|
-
parser.add_argument(
|
|
1163
|
-
"--print-candidate",
|
|
1164
|
-
action="store_true",
|
|
1165
|
-
help="print the candidate hash the evidence must bind, then exit",
|
|
1166
|
-
)
|
|
1167
|
-
parser.add_argument(
|
|
1168
|
-
"--print-manifest",
|
|
1169
|
-
action="store_true",
|
|
1170
|
-
help=(
|
|
1171
|
-
"render a landing partition manifest for the --partition groups given, "
|
|
1172
|
-
"with every hash computed by this gate, then exit"
|
|
1173
|
-
),
|
|
1174
|
-
)
|
|
1175
|
-
parser.add_argument(
|
|
1176
|
-
"--partition",
|
|
1177
|
-
action="append",
|
|
1178
|
-
nargs="+",
|
|
1179
|
-
metavar="PATH",
|
|
1180
|
-
default=[],
|
|
1181
|
-
help="one partition's paths; repeat per partition (only with --print-manifest)",
|
|
1182
|
-
)
|
|
1183
|
-
args = parser.parse_args()
|
|
1184
|
-
if args.print_manifest and not args.partition:
|
|
1185
|
-
parser.error("--print-manifest needs at least one --partition")
|
|
1186
|
-
if args.partition and not args.print_manifest:
|
|
1187
|
-
parser.error("--partition is only meaningful with --print-manifest")
|
|
1188
|
-
|
|
1189
|
-
repo_root = Path(args.repo_root).resolve()
|
|
1190
|
-
base = args.base or os.environ.get("CCL_SKILL_BASE_REF", "")
|
|
1191
|
-
if not base:
|
|
1192
|
-
# The gate is base-relative by construction, so a run with no base has
|
|
1193
|
-
# checked nothing. Exiting 0 there is how a base-relative gate becomes
|
|
1194
|
-
# decorative: a base-wiring mistake would read as a passing required
|
|
1195
|
-
# check. Fail closed; a caller whose event genuinely has no base must
|
|
1196
|
-
# say so out loud with --allow-unevaluated.
|
|
1197
|
-
emit(
|
|
1198
|
-
"review_ledger_binding_unevaluated: no base ref supplied "
|
|
1199
|
-
"(pass --base or set CCL_SKILL_BASE_REF); nothing was checked"
|
|
1200
|
-
)
|
|
1201
|
-
return 0 if args.allow_unevaluated else 2
|
|
1202
|
-
|
|
1203
|
-
base_tip = resolve_base(repo_root, base)
|
|
1204
|
-
user_paths = tuple(args.paths)
|
|
1205
|
-
|
|
1206
|
-
if args.print_candidate or args.print_manifest:
|
|
1207
|
-
fork, excludes, paths, changed = candidate_scope(repo_root, base_tip, user_paths)
|
|
1208
|
-
if not changed:
|
|
1209
|
-
emit(f"review_ledger_binding_no_change: no reviewed-path change against {fork}")
|
|
1210
|
-
return 0
|
|
1211
|
-
module = load_controller(repo_root)
|
|
1212
|
-
if args.print_manifest:
|
|
1213
|
-
try:
|
|
1214
|
-
manifest = render_manifest(module, repo_root, fork, args.partition, excludes, changed)
|
|
1215
|
-
except ManifestError as exc:
|
|
1216
|
-
emit(f"review_ledger_binding_error: cannot render a landing partition manifest: {exc}")
|
|
1217
|
-
return 1
|
|
1218
|
-
except Exception as exc: # noqa: BLE001 - surface the controller's own message
|
|
1219
|
-
emit(f"review_ledger_binding_error: cannot freeze a partition packet: {exc}")
|
|
1220
|
-
return 1
|
|
1221
|
-
print(json.dumps(manifest, indent=2, ensure_ascii=False))
|
|
1222
|
-
return 0
|
|
1223
|
-
try:
|
|
1224
|
-
print(candidate_hash(module, repo_root, fork, paths))
|
|
1225
|
-
except Exception as exc: # noqa: BLE001 - surface the controller's own message
|
|
1226
|
-
emit(f"review_ledger_binding_error: cannot freeze the candidate packet: {exc}")
|
|
1227
|
-
return 1
|
|
1228
|
-
return 0
|
|
1229
|
-
|
|
1230
|
-
binding = bind_candidate(repo_root, base_tip, user_paths, args.evidence_root, allow_chain=True)
|
|
1231
|
-
if binding.ok:
|
|
1232
|
-
print(f"review_ledger_binding_ok: {binding.summary}")
|
|
1233
|
-
for line in binding.proofs:
|
|
1234
|
-
print(line)
|
|
1235
|
-
return 0
|
|
1236
|
-
for line in binding.failure:
|
|
1237
|
-
emit(line)
|
|
1238
|
-
return 1
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
if __name__ == "__main__":
|
|
1242
|
-
raise SystemExit(main())
|