@ccoalm/ccl-skills 0.13.0 → 0.15.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/skills/code-review/SKILL.md +19 -24
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/references/client-routing.md +32 -32
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/references/manual-invocation-and-prompts.md +16 -14
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/references/staged-review-contract.md +24 -26
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/AGENTS.md +11 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/claude_review.sh +60 -209
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/init_policy_matrix.py +114 -367
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/parse_probe_result.py +52 -672
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/review_gate.py +10 -2
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/runtime-surface-verification-design.md +4 -2
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_claude_review_probe.sh +77 -444
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_init_policy_matrix.sh +33 -98
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_parse_probe_result.sh +57 -173
- package/dist/assets/marketplace/plugins/ccl-skills/skills/defect-diagnosis/SKILL.md +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/grill-me/SKILL.md +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/miniapp-product-dev/SKILL.md +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-observability/SKILL.md +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-release-engineering/SKILL.md +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/SKILL.md +2 -2
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/rd-standards-doc-family-checklist.md +2 -2
- package/dist/assets/marketplace/plugins/ccl-skills/skills/requirement-baseline/SKILL.md +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/requirement-doc-writer/SKILL.md +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/requirement-scope/SKILL.md +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/SKILL.md +14 -41
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/attention-budget-ratchet.md +11 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/correction-routing-map.md +22 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/coverage-exhaustion-traps.md +7 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/description-authoring.md +26 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/dual-track-review-gate.md +2 -2
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/eval-routing.md +8 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/external-practice-controls.md +7 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/extraction-quickstart.md +4 -2
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/harness-patterns-and-eval.md +8 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/incident-postmortem-extraction.md +8 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/rule-consolidation.md +1 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/source-register.md +73 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/uiux-judgment-extraction.md +11 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/validation-and-landing.md +11 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/entrypoint_form_census.py +169 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/eval-routing-bank.rb +62 -3
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/impact-chain-gate.rb +114 -10
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/reference-access-census.sh +157 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/review_ledger_binding.py +483 -109
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_impact_chain_refscripts.sh +188 -14
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_regressions.sh +10 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_entrypoint_form_census.sh +174 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_eval_routing_bank_resolution.sh +253 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_impact_chain_gate_verdict_differential.sh +49 -25
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_reference_access_census.sh +209 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_review_ledger_binding.sh +394 -5
- package/dist/assets/release.json +77 -47
- package/package.json +1 -1
|
@@ -67,8 +67,42 @@ receipt predicate and committing it moves no partition; that is the load-bearing
|
|
|
67
67
|
reason for the field, and the exclusion predicate itself is unchanged. What the
|
|
68
68
|
manifest proves is the same narrow thing the single ledger proves, taken per
|
|
69
69
|
part: every byte that lands is a byte some external round froze and inspected.
|
|
70
|
-
|
|
71
|
-
aggregate
|
|
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.
|
|
72
106
|
|
|
73
107
|
Boundaries this gate does NOT close, stated because a gate that lives inside the
|
|
74
108
|
candidate cannot authenticate itself: it cannot prove the caller retained every
|
|
@@ -97,12 +131,15 @@ import sys
|
|
|
97
131
|
sys.dont_write_bytecode = True
|
|
98
132
|
|
|
99
133
|
import argparse
|
|
134
|
+
import contextlib
|
|
100
135
|
import hashlib
|
|
101
136
|
import importlib.util
|
|
102
137
|
import json
|
|
103
138
|
import os
|
|
104
139
|
import re
|
|
140
|
+
import shutil
|
|
105
141
|
import subprocess
|
|
142
|
+
import tempfile
|
|
106
143
|
import time
|
|
107
144
|
import types
|
|
108
145
|
from pathlib import Path
|
|
@@ -129,12 +166,37 @@ PARTITION_KEYS = {"paths", "candidate_sha256"}
|
|
|
129
166
|
MAX_PARTITIONS = 64
|
|
130
167
|
HEX40 = re.compile(r"^[0-9a-f]{40}$")
|
|
131
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
|
|
132
172
|
|
|
133
173
|
|
|
134
174
|
class ManifestError(Exception):
|
|
135
175
|
"""A manifest that does not describe this candidate; the message is the reason."""
|
|
136
176
|
|
|
137
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
|
+
|
|
138
200
|
def emit(message: str) -> None:
|
|
139
201
|
print(message, file=sys.stderr)
|
|
140
202
|
|
|
@@ -524,7 +586,7 @@ def render_manifest(
|
|
|
524
586
|
|
|
525
587
|
def accepted_ledger_for(
|
|
526
588
|
evidence: list[tuple[Path, dict]],
|
|
527
|
-
|
|
589
|
+
evidence_home: Path,
|
|
528
590
|
validator: Path,
|
|
529
591
|
digest: str,
|
|
530
592
|
rejected: list[str],
|
|
@@ -533,14 +595,15 @@ def accepted_ledger_for(
|
|
|
533
595
|
|
|
534
596
|
The same criterion the single-candidate path uses: a receipt-shaped file is
|
|
535
597
|
not evidence, only a ledger the validator accepts, because this gate cannot
|
|
536
|
-
authenticate that a controller minted what it reads.
|
|
598
|
+
authenticate that a controller minted what it reads. `evidence_home` is the
|
|
599
|
+
tree the evidence was enumerated from, used only to name the ledger.
|
|
537
600
|
"""
|
|
538
601
|
for path, payload in evidence:
|
|
539
602
|
if payload.get("candidate_sha256") != digest:
|
|
540
603
|
continue
|
|
541
604
|
if "closeout_state" not in payload or "controller_receipts" not in payload:
|
|
542
605
|
continue
|
|
543
|
-
relative = str(path.relative_to(
|
|
606
|
+
relative = str(path.relative_to(evidence_home))
|
|
544
607
|
accepted, output = validator_accepts(validator, path)
|
|
545
608
|
if accepted:
|
|
546
609
|
return f"{relative} -- {output}"
|
|
@@ -556,6 +619,7 @@ def bind_manifest(
|
|
|
556
619
|
excludes: tuple[str, ...],
|
|
557
620
|
changed_all: list[str],
|
|
558
621
|
evidence: list[tuple[Path, dict]],
|
|
622
|
+
evidence_home: Path,
|
|
559
623
|
validator: Path,
|
|
560
624
|
rejected_ledgers: list[str],
|
|
561
625
|
) -> list[str]:
|
|
@@ -573,7 +637,7 @@ def bind_manifest(
|
|
|
573
637
|
raise ManifestError(
|
|
574
638
|
f"{label} recorded {recorded[:12]}... but does not reproduce: the candidate now hashes to {actual[:12]}..."
|
|
575
639
|
)
|
|
576
|
-
proof = accepted_ledger_for(evidence,
|
|
640
|
+
proof = accepted_ledger_for(evidence, evidence_home, validator, actual, rejected_ledgers)
|
|
577
641
|
if proof is None:
|
|
578
642
|
raise ManifestError(f"no accepted ledger binds {label} {actual}")
|
|
579
643
|
proofs.append(f" {label} {actual[:12]}... <- {proof}")
|
|
@@ -634,59 +698,252 @@ def scan(repo_root: Path, evidence_root: str) -> list[tuple[Path, dict]]:
|
|
|
634
698
|
return found
|
|
635
699
|
|
|
636
700
|
|
|
637
|
-
def
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
701
|
+
def git_read(repo_root: Path, arguments: list[str], failure: str) -> str:
|
|
702
|
+
"""Run a read-only git query whose failure is an environment error, not a verdict."""
|
|
703
|
+
result = subprocess.run(
|
|
704
|
+
["git", "-C", str(repo_root), *arguments],
|
|
705
|
+
stdout=subprocess.PIPE,
|
|
706
|
+
stderr=subprocess.PIPE,
|
|
707
|
+
text=True,
|
|
708
|
+
check=False,
|
|
645
709
|
)
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
710
|
+
if result.returncode != 0:
|
|
711
|
+
raise SystemExit(f"review_ledger_binding_error: {failure}: {result.stderr.strip()}")
|
|
712
|
+
return result.stdout.strip()
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def is_ancestor(repo_root: Path, commit: str, tip: str) -> bool:
|
|
716
|
+
result = subprocess.run(
|
|
717
|
+
["git", "-C", str(repo_root), "merge-base", "--is-ancestor", commit, tip],
|
|
718
|
+
stdout=subprocess.PIPE,
|
|
719
|
+
stderr=subprocess.PIPE,
|
|
720
|
+
text=True,
|
|
721
|
+
check=False,
|
|
652
722
|
)
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
),
|
|
723
|
+
if result.returncode == 0:
|
|
724
|
+
return True
|
|
725
|
+
if result.returncode == 1:
|
|
726
|
+
return False
|
|
727
|
+
raise SystemExit(
|
|
728
|
+
f"review_ledger_binding_error: cannot test ancestry of {commit[:12]}: {result.stderr.strip()}"
|
|
660
729
|
)
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def automatic_merge_tree(repo_root: Path, first: str, second: str) -> str | None:
|
|
733
|
+
"""The tree git itself produces merging `second` into `first`, or None on conflict.
|
|
734
|
+
|
|
735
|
+
A merge commit whose tree is anything else carries content beyond its two
|
|
736
|
+
parents -- a hand resolution, an extra file, a post-merge edit -- and that
|
|
737
|
+
content was reviewed by nobody. Conflicts are refused for the same reason:
|
|
738
|
+
whatever resolved them is unreviewed. An old git that lacks `--write-tree`
|
|
739
|
+
is an environment error rather than a verdict either way.
|
|
740
|
+
"""
|
|
741
|
+
result = subprocess.run(
|
|
742
|
+
["git", "-C", str(repo_root), "merge-tree", "--write-tree", first, second],
|
|
743
|
+
stdout=subprocess.PIPE,
|
|
744
|
+
stderr=subprocess.PIPE,
|
|
745
|
+
text=True,
|
|
746
|
+
check=False,
|
|
747
|
+
)
|
|
748
|
+
if result.returncode == 0:
|
|
749
|
+
tree = result.stdout.splitlines()[0].strip() if result.stdout else ""
|
|
750
|
+
if not HEX40.match(tree):
|
|
751
|
+
raise SystemExit("review_ledger_binding_error: git merge-tree --write-tree printed no tree")
|
|
752
|
+
return tree
|
|
753
|
+
if result.returncode == 1:
|
|
754
|
+
return None
|
|
755
|
+
raise SystemExit(
|
|
756
|
+
"review_ledger_binding_error: git merge-tree --write-tree is unavailable "
|
|
757
|
+
f"(git 2.38 or newer is required): {result.stderr.strip()}"
|
|
668
758
|
)
|
|
669
|
-
args = parser.parse_args()
|
|
670
|
-
if args.print_manifest and not args.partition:
|
|
671
|
-
parser.error("--print-manifest needs at least one --partition")
|
|
672
|
-
if args.partition and not args.print_manifest:
|
|
673
|
-
parser.error("--partition is only meaningful with --print-manifest")
|
|
674
759
|
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
760
|
+
|
|
761
|
+
def walk_first_parent_chain(repo_root: Path, base_tip: str) -> list[tuple[str, str, str, str]]:
|
|
762
|
+
"""Classify each first-parent step from HEAD down to the target branch.
|
|
763
|
+
|
|
764
|
+
Returns (kind, merge, first_parent, second_parent) per step, newest first,
|
|
765
|
+
where kind is `sync` (second parent already on the target) or `round`.
|
|
766
|
+
Raises ChainError at the first step that is not a merge, is not the
|
|
767
|
+
automatic merge of its parents, or when the walk does not reach the target.
|
|
768
|
+
"""
|
|
769
|
+
steps: list[tuple[str, str, str, str]] = []
|
|
770
|
+
commit = git_read(repo_root, ["rev-parse", "--verify", "HEAD^{commit}"], "cannot resolve HEAD")
|
|
771
|
+
while True:
|
|
772
|
+
if is_ancestor(repo_root, commit, base_tip):
|
|
773
|
+
return steps
|
|
774
|
+
if len(steps) >= MAX_CHAIN_STEPS:
|
|
775
|
+
break
|
|
776
|
+
parents = git_read(
|
|
777
|
+
repo_root, ["rev-list", "--parents", "-n", "1", commit], f"cannot read parents of {commit[:12]}"
|
|
778
|
+
).split()[1:]
|
|
779
|
+
if len(parents) != 2:
|
|
780
|
+
shape = "an octopus merge" if len(parents) > 2 else "not a merge commit"
|
|
781
|
+
raise ChainError(
|
|
782
|
+
f"{commit[:12]} is {shape}; nothing reviewed binds its content"
|
|
783
|
+
if len(parents) < 2
|
|
784
|
+
else f"{commit[:12]} is {shape}; only two-parent merges are bound"
|
|
785
|
+
)
|
|
786
|
+
first, second = parents
|
|
787
|
+
own_tree = git_read(repo_root, ["rev-parse", f"{commit}^{{tree}}"], f"cannot read tree of {commit[:12]}")
|
|
788
|
+
if automatic_merge_tree(repo_root, first, second) != own_tree:
|
|
789
|
+
raise ChainError(
|
|
790
|
+
f"{commit[:12]} tree differs from the automatic merge of its parents "
|
|
791
|
+
f"({first[:12]} + {second[:12]}); whatever else it carries was reviewed by nobody"
|
|
792
|
+
)
|
|
793
|
+
kind = "sync" if is_ancestor(repo_root, second, base_tip) else "round"
|
|
794
|
+
steps.append((kind, commit, first, second))
|
|
795
|
+
commit = first
|
|
796
|
+
raise ChainError(
|
|
797
|
+
f"the first-parent chain runs more than {MAX_CHAIN_STEPS} steps without reaching the base"
|
|
798
|
+
)
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
@contextlib.contextmanager
|
|
802
|
+
def detached_checkout(repo_root: Path, commit: str):
|
|
803
|
+
"""A throwaway worktree at `commit`, outside the repository, removed on exit.
|
|
804
|
+
|
|
805
|
+
The controller freezes the working tree, so a round can only be rebound from
|
|
806
|
+
a checkout of its head. The checkout lives outside the repository root so it
|
|
807
|
+
is never an untracked path inside the bound set, and it is removed whether
|
|
808
|
+
the round binds or not.
|
|
809
|
+
"""
|
|
810
|
+
path = Path(tempfile.mkdtemp(prefix="review-ledger-binding-round-"))
|
|
811
|
+
added = subprocess.run(
|
|
812
|
+
["git", "-C", str(repo_root), "worktree", "add", "--detach", "--quiet", str(path), commit],
|
|
813
|
+
stdout=subprocess.PIPE,
|
|
814
|
+
stderr=subprocess.PIPE,
|
|
815
|
+
text=True,
|
|
816
|
+
check=False,
|
|
817
|
+
)
|
|
818
|
+
resolved = path.resolve()
|
|
819
|
+
if added.returncode != 0:
|
|
820
|
+
# A failed add can still have registered the checkout or populated the
|
|
821
|
+
# directory; release it through the same verified path as a success, so
|
|
822
|
+
# a failed run leaves no repository worktree state behind either.
|
|
823
|
+
problem = release_checkout(repo_root, path, resolved)
|
|
824
|
+
if problem:
|
|
825
|
+
problem = f" (and the partial checkout could not be released: {problem})"
|
|
826
|
+
raise SystemExit(
|
|
827
|
+
f"review_ledger_binding_error: cannot check out round head {commit[:12]}: "
|
|
828
|
+
f"{added.stderr.strip()}{problem or ''}"
|
|
829
|
+
)
|
|
830
|
+
try:
|
|
831
|
+
yield resolved
|
|
832
|
+
finally:
|
|
833
|
+
problem = release_checkout(repo_root, path, resolved)
|
|
834
|
+
if problem:
|
|
835
|
+
emit(f"review_ledger_binding_error: cannot release the detached checkout {path}: {problem}")
|
|
836
|
+
# Reached only when the body did not raise: a verdict that would have been
|
|
837
|
+
# `ok` must not stand on a checkout this run failed to release. When the body
|
|
838
|
+
# raised, the outcome is already a refusal or an error and the problem was
|
|
839
|
+
# emitted above.
|
|
840
|
+
if problem:
|
|
841
|
+
raise SystemExit(
|
|
842
|
+
f"review_ledger_binding_error: cannot release the detached checkout {path}: {problem}"
|
|
686
843
|
)
|
|
687
|
-
return 0 if args.allow_unevaluated else 2
|
|
688
844
|
|
|
689
|
-
|
|
845
|
+
|
|
846
|
+
def release_checkout(repo_root: Path, path: Path, resolved: Path) -> str | None:
|
|
847
|
+
"""Remove the detached checkout and verify git no longer registers it.
|
|
848
|
+
|
|
849
|
+
The removal's result is checked, not discarded, and the registry is read
|
|
850
|
+
back: a stale registration left behind would make the next run's worktree
|
|
851
|
+
state a lie. No `worktree prune` runs here, because prune is repository-wide
|
|
852
|
+
and would also drop registrations this run did not create.
|
|
853
|
+
"""
|
|
854
|
+
removed = subprocess.run(
|
|
855
|
+
["git", "-C", str(repo_root), "worktree", "remove", "--force", str(path)],
|
|
856
|
+
stdout=subprocess.PIPE,
|
|
857
|
+
stderr=subprocess.PIPE,
|
|
858
|
+
text=True,
|
|
859
|
+
check=False,
|
|
860
|
+
)
|
|
861
|
+
# NUL-terminated output: the line-oriented porcelain quotes a path that
|
|
862
|
+
# carries a tab, newline, or other unusual byte, and a quoted line would
|
|
863
|
+
# match neither spelling below, reading a surviving registration as gone.
|
|
864
|
+
listing = subprocess.run(
|
|
865
|
+
["git", "-C", str(repo_root), "worktree", "list", "--porcelain", "-z"],
|
|
866
|
+
stdout=subprocess.PIPE,
|
|
867
|
+
stderr=subprocess.PIPE,
|
|
868
|
+
check=False,
|
|
869
|
+
)
|
|
870
|
+
if listing.returncode != 0:
|
|
871
|
+
return f"cannot read the worktree list: {listing.stderr.decode('utf-8', 'replace').strip()}"
|
|
872
|
+
registered = {
|
|
873
|
+
field.decode("utf-8", "surrogateescape")
|
|
874
|
+
for field in listing.stdout.split(b"\0")
|
|
875
|
+
if field.startswith(b"worktree ")
|
|
876
|
+
}
|
|
877
|
+
if f"worktree {path}" in registered or f"worktree {resolved}" in registered:
|
|
878
|
+
detail = f": {removed.stderr.strip()}" if removed.returncode != 0 else ""
|
|
879
|
+
return f"the checkout is still registered after removal{detail}"
|
|
880
|
+
if path.exists():
|
|
881
|
+
# Unregistered but present: the directory this run created before git
|
|
882
|
+
# registered anything, or files git left without a registration. It is
|
|
883
|
+
# this run's own temporary directory, so deleting it touches no
|
|
884
|
+
# repository state; a directory that survives that is reported.
|
|
885
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
886
|
+
if path.exists():
|
|
887
|
+
return "the checkout directory still exists after removal"
|
|
888
|
+
return None
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
def bind_chain(
|
|
892
|
+
repo_root: Path, base_tip: str, changed_all: list[str], evidence_root: str
|
|
893
|
+
) -> tuple[int, list[str]]:
|
|
894
|
+
"""Bind the candidate round by round along HEAD's first-parent chain.
|
|
895
|
+
|
|
896
|
+
Returns (round count, proof lines) or raises ChainError naming the first
|
|
897
|
+
step that does not add up. Each round is rebound by this same gate in a
|
|
898
|
+
detached checkout of its head against its first parent, with THIS tree's
|
|
899
|
+
controller, validator and committed evidence (never the round's own tools;
|
|
900
|
+
the round's own evidence is part of this tree's history and is found there),
|
|
901
|
+
and never as a chain of its own.
|
|
902
|
+
"""
|
|
903
|
+
steps = walk_first_parent_chain(repo_root, base_tip)
|
|
904
|
+
proofs: list[str] = []
|
|
905
|
+
covered: set[str] = set()
|
|
906
|
+
rounds = 0
|
|
907
|
+
for kind, merge, first, second in steps:
|
|
908
|
+
if kind == "sync":
|
|
909
|
+
proofs.append(f" sync {merge[:12]} (already on the base)")
|
|
910
|
+
continue
|
|
911
|
+
with detached_checkout(repo_root, second) as round_root:
|
|
912
|
+
binding = bind_candidate(
|
|
913
|
+
round_root,
|
|
914
|
+
first,
|
|
915
|
+
DEFAULT_PATHS,
|
|
916
|
+
evidence_root,
|
|
917
|
+
allow_chain=False,
|
|
918
|
+
tools_root=repo_root,
|
|
919
|
+
evidence_tree=repo_root,
|
|
920
|
+
)
|
|
921
|
+
subject = git_read(repo_root, ["log", "-1", "--format=%s", merge], f"cannot read {merge[:12]}")
|
|
922
|
+
if not binding.ok:
|
|
923
|
+
reason = binding.failure[0] if binding.failure else "no accepted review evidence"
|
|
924
|
+
raise ChainError(
|
|
925
|
+
f"round {merge[:12]} ({subject}) does not bind at its own base {first[:12]}: {reason}"
|
|
926
|
+
)
|
|
927
|
+
rounds += 1
|
|
928
|
+
covered.update(binding.changed)
|
|
929
|
+
proofs.append(f" round {merge[:12]} <- {binding.summary}")
|
|
930
|
+
proofs.extend(" " + line for line in binding.proofs)
|
|
931
|
+
if rounds == 0:
|
|
932
|
+
raise ChainError("no round merge on the chain binds the changed paths")
|
|
933
|
+
# Under the automatic-merge invariant every promoted change reached HEAD
|
|
934
|
+
# through some round; this name-level check is the second net under that
|
|
935
|
+
# invariant, not the invariant itself.
|
|
936
|
+
outside = sorted(set(changed_all) - covered)
|
|
937
|
+
if outside:
|
|
938
|
+
raise ChainError("changed paths outside every bound round: " + ", ".join(outside[:5]))
|
|
939
|
+
return rounds, proofs
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
def candidate_scope(
|
|
943
|
+
repo_root: Path, base_tip: str, user_paths: tuple[str, ...]
|
|
944
|
+
) -> tuple[str, tuple[str, ...], tuple[str, ...], list[str]]:
|
|
945
|
+
"""Resolve (fork point, exclusions, bound paths, changed files) for one checkout."""
|
|
946
|
+
fork = fork_point(repo_root, base_tip)
|
|
690
947
|
# The exclusion is derived from this round's own diff, not written down as a
|
|
691
948
|
# subtree, so edits to committed history stay inside the candidate.
|
|
692
949
|
# These names come from the candidate's own tree and are handed back to git as
|
|
@@ -698,77 +955,85 @@ def main() -> int:
|
|
|
698
955
|
# confirmed, and no test asserts a bypass this gate does not have. `literal`
|
|
699
956
|
# stays because interpreting these names as patterns is a capability the gate
|
|
700
957
|
# never needed, and removing it costs nothing.
|
|
701
|
-
excludes = tuple(
|
|
702
|
-
|
|
703
|
-
)
|
|
704
|
-
paths = tuple(args.paths) + excludes
|
|
958
|
+
excludes = tuple(f":(exclude,literal){path}" for path in added_evidence_paths(repo_root, fork))
|
|
959
|
+
paths = tuple(user_paths) + excludes
|
|
705
960
|
require_committed_tree(repo_root, paths)
|
|
706
|
-
changed = changed_skill_paths(repo_root,
|
|
961
|
+
changed = changed_skill_paths(repo_root, fork, paths)
|
|
962
|
+
return fork, excludes, paths, changed
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
def bind_candidate(
|
|
966
|
+
repo_root: Path,
|
|
967
|
+
base_tip: str,
|
|
968
|
+
user_paths: tuple[str, ...],
|
|
969
|
+
evidence_root: str,
|
|
970
|
+
allow_chain: bool,
|
|
971
|
+
tools_root: Path | None = None,
|
|
972
|
+
evidence_tree: Path | None = None,
|
|
973
|
+
) -> Binding:
|
|
974
|
+
"""Evaluate one checkout against one base: single ledger, then manifest, then chain.
|
|
975
|
+
|
|
976
|
+
`tools_root` names the tree whose controller and validator judge the
|
|
977
|
+
candidate; it defaults to the checkout itself and is the landing tree when a
|
|
978
|
+
historical round is rebound, so a round never judges itself with its own tools.
|
|
979
|
+
`evidence_tree` names the tree whose committed evidence is consulted; it too
|
|
980
|
+
defaults to the checkout and is the landing tree when a historical round is
|
|
981
|
+
rebound. Evidence is a validator-accepted closeout bound to the round's own
|
|
982
|
+
candidate hash wherever it was committed: a round that merged without its
|
|
983
|
+
ledger is bound by a later review of the same bytes, committed on the
|
|
984
|
+
integration branch, and by nothing less -- the candidate hash and the
|
|
985
|
+
validator, not the file's location, are what make a ledger evidence.
|
|
986
|
+
"""
|
|
987
|
+
binding = Binding()
|
|
988
|
+
tools = tools_root if tools_root is not None else repo_root
|
|
989
|
+
evidence_home = evidence_tree if evidence_tree is not None else repo_root
|
|
990
|
+
fork, excludes, paths, changed = candidate_scope(repo_root, base_tip, user_paths)
|
|
991
|
+
binding.fork = fork
|
|
992
|
+
binding.changed = changed
|
|
707
993
|
if not changed:
|
|
708
994
|
# No reviewed path moved, so there is no candidate to freeze and nothing to
|
|
709
995
|
# bind. Say which it is rather than letting an empty packet surface as a
|
|
710
996
|
# freeze error, which reads like a broken gate.
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
print(f"review_ledger_binding_ok: no reviewed-path change against {base}")
|
|
715
|
-
return 0
|
|
716
|
-
|
|
717
|
-
module = load_controller(repo_root)
|
|
718
|
-
|
|
719
|
-
if args.print_manifest:
|
|
720
|
-
try:
|
|
721
|
-
manifest = render_manifest(module, repo_root, base, args.partition, excludes, changed)
|
|
722
|
-
except ManifestError as exc:
|
|
723
|
-
emit(f"review_ledger_binding_error: cannot render a landing partition manifest: {exc}")
|
|
724
|
-
return 1
|
|
725
|
-
except Exception as exc: # noqa: BLE001 - surface the controller's own message
|
|
726
|
-
emit(f"review_ledger_binding_error: cannot freeze a partition packet: {exc}")
|
|
727
|
-
return 1
|
|
728
|
-
print(json.dumps(manifest, indent=2, ensure_ascii=False))
|
|
729
|
-
return 0
|
|
997
|
+
binding.ok = True
|
|
998
|
+
binding.summary = f"no reviewed-path change against {fork}"
|
|
999
|
+
return binding
|
|
730
1000
|
|
|
1001
|
+
module = load_controller(tools)
|
|
731
1002
|
# The whole candidate may be larger than one packet. That is no longer a
|
|
732
1003
|
# terminal error: record why the single freeze failed and let a committed
|
|
733
1004
|
# partition manifest bind the candidate part by part.
|
|
734
1005
|
expected: str | None = None
|
|
735
1006
|
whole_error: str | None = None
|
|
736
1007
|
try:
|
|
737
|
-
expected = candidate_hash(module, repo_root,
|
|
1008
|
+
expected = candidate_hash(module, repo_root, fork, paths)
|
|
738
1009
|
except Exception as exc: # noqa: BLE001 - surface the controller's own message
|
|
739
1010
|
whole_error = str(exc)
|
|
740
1011
|
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
emit(f"review_ledger_binding_error: cannot freeze the candidate packet: {whole_error}")
|
|
744
|
-
return 1
|
|
745
|
-
print(expected)
|
|
746
|
-
return 0
|
|
747
|
-
|
|
748
|
-
validator = repo_root / "skills" / "skill-extraction-workflow" / "scripts" / VALIDATOR
|
|
749
|
-
evidence = scan(repo_root, args.evidence_root)
|
|
1012
|
+
validator = tools / "skills" / "skill-extraction-workflow" / "scripts" / VALIDATOR
|
|
1013
|
+
evidence = scan(evidence_home, evidence_root)
|
|
750
1014
|
ledgers: list[str] = []
|
|
751
1015
|
if expected is not None:
|
|
752
1016
|
# Only a validator-accepted ledger counts. A receipt-shaped file proves
|
|
753
1017
|
# nothing on its own: this gate cannot authenticate that a controller
|
|
754
1018
|
# minted it, so any branch keyed on a self-declared field is a bypass a
|
|
755
1019
|
# contributor can hand-write.
|
|
756
|
-
proof = accepted_ledger_for(evidence,
|
|
1020
|
+
proof = accepted_ledger_for(evidence, evidence_home, validator, expected, ledgers)
|
|
757
1021
|
if proof is not None:
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
f"
|
|
1022
|
+
binding.ok = True
|
|
1023
|
+
binding.summary = (
|
|
1024
|
+
f"{proof.split(' -- ', 1)[0]} binds the landing candidate "
|
|
1025
|
+
f"({expected[:12]}...) -- {proof.split(' -- ', 1)[1]}"
|
|
761
1026
|
)
|
|
762
|
-
return
|
|
1027
|
+
return binding
|
|
763
1028
|
|
|
764
1029
|
manifests: list[str] = []
|
|
765
1030
|
for path, payload in evidence:
|
|
766
1031
|
if payload.get("kind") != MANIFEST_KIND:
|
|
767
1032
|
continue
|
|
768
|
-
relative = str(path.relative_to(
|
|
1033
|
+
relative = str(path.relative_to(evidence_home))
|
|
769
1034
|
try:
|
|
770
1035
|
proofs = bind_manifest(
|
|
771
|
-
module, repo_root,
|
|
1036
|
+
module, repo_root, fork, payload, excludes, changed, evidence, evidence_home, validator, ledgers
|
|
772
1037
|
)
|
|
773
1038
|
except ManifestError as exc:
|
|
774
1039
|
manifests.append(f"{relative}: {exc}")
|
|
@@ -776,40 +1041,149 @@ def main() -> int:
|
|
|
776
1041
|
except Exception as exc: # noqa: BLE001 - surface the controller's own message
|
|
777
1042
|
manifests.append(f"{relative}: cannot freeze a partition packet: {exc}")
|
|
778
1043
|
continue
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
f"{len(proofs)} partitions
|
|
1044
|
+
binding.ok = True
|
|
1045
|
+
binding.summary = (
|
|
1046
|
+
f"{relative} binds the landing candidate as {len(proofs)} partitions "
|
|
1047
|
+
f"(aggregate {payload['candidate_sha256'][:12]}...)"
|
|
1048
|
+
)
|
|
1049
|
+
binding.proofs = proofs
|
|
1050
|
+
return binding
|
|
1051
|
+
|
|
1052
|
+
chain_rows: list[str] = []
|
|
1053
|
+
if allow_chain and tuple(user_paths) == DEFAULT_PATHS:
|
|
1054
|
+
try:
|
|
1055
|
+
rounds, proofs = bind_chain(repo_root, base_tip, changed, evidence_root)
|
|
1056
|
+
except ChainError as exc:
|
|
1057
|
+
chain_rows.append(str(exc))
|
|
1058
|
+
else:
|
|
1059
|
+
binding.ok = True
|
|
1060
|
+
binding.summary = (
|
|
1061
|
+
f"the first-parent chain binds the landing candidate as {rounds} rounds "
|
|
1062
|
+
f"({fork[:12]}..HEAD)"
|
|
1063
|
+
)
|
|
1064
|
+
binding.proofs = proofs
|
|
1065
|
+
return binding
|
|
1066
|
+
elif allow_chain:
|
|
1067
|
+
chain_rows.append(
|
|
1068
|
+
"first-parent chain binding is evaluated only over the default path set "
|
|
1069
|
+
"(--paths .); a narrowed scope has no round-level ledger to bind"
|
|
782
1070
|
)
|
|
783
|
-
for line in proofs:
|
|
784
|
-
print(line)
|
|
785
|
-
return 0
|
|
786
1071
|
|
|
787
1072
|
if expected is None:
|
|
788
|
-
|
|
1073
|
+
binding.failure.append(
|
|
789
1074
|
"review_ledger_binding_failed: the whole candidate cannot be frozen as one "
|
|
790
1075
|
f"packet ({whole_error}) and no committed landing partition manifest binds it"
|
|
791
1076
|
)
|
|
792
|
-
|
|
1077
|
+
binding.failure.append(
|
|
793
1078
|
" split the candidate by path: --print-manifest --partition <paths> "
|
|
794
1079
|
"[--partition <paths> ...] renders the manifest; commit it with one "
|
|
795
1080
|
"validated ledger per partition"
|
|
796
1081
|
)
|
|
797
1082
|
else:
|
|
798
|
-
|
|
1083
|
+
binding.failure.append(
|
|
799
1084
|
"review_ledger_binding_failed: no accepted review evidence binds the landing "
|
|
800
1085
|
f"candidate {expected}"
|
|
801
1086
|
)
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
for row in ledgers
|
|
805
|
-
|
|
806
|
-
for row in
|
|
807
|
-
emit(f" rejected manifest -> {row}")
|
|
1087
|
+
binding.failure.append(f" reviewed paths: {' '.join(paths)} against {fork}")
|
|
1088
|
+
binding.failure.append(f" changed files: {len(changed)}")
|
|
1089
|
+
binding.failure.extend(f" rejected ledger -> {row}" for row in ledgers)
|
|
1090
|
+
binding.failure.extend(f" rejected manifest -> {row}" for row in manifests)
|
|
1091
|
+
binding.failure.extend(f" rejected chain -> {row}" for row in chain_rows)
|
|
808
1092
|
if not ledgers and not manifests:
|
|
809
|
-
|
|
1093
|
+
binding.failure.append(
|
|
810
1094
|
" no committed ledger records this candidate; run the extraction review "
|
|
811
1095
|
"lane against the final, committed tree"
|
|
812
1096
|
)
|
|
1097
|
+
return binding
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
def main() -> int:
|
|
1101
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
1102
|
+
parser.add_argument("--repo-root", default=".")
|
|
1103
|
+
parser.add_argument("--base", default=None)
|
|
1104
|
+
parser.add_argument(
|
|
1105
|
+
"--allow-unevaluated",
|
|
1106
|
+
action="store_true",
|
|
1107
|
+
help="permit a run with no resolvable base to exit 0, for events that have none",
|
|
1108
|
+
)
|
|
1109
|
+
parser.add_argument("--evidence-root", default="specs")
|
|
1110
|
+
parser.add_argument("--paths", nargs="*", default=list(DEFAULT_PATHS))
|
|
1111
|
+
parser.add_argument(
|
|
1112
|
+
"--print-candidate",
|
|
1113
|
+
action="store_true",
|
|
1114
|
+
help="print the candidate hash the evidence must bind, then exit",
|
|
1115
|
+
)
|
|
1116
|
+
parser.add_argument(
|
|
1117
|
+
"--print-manifest",
|
|
1118
|
+
action="store_true",
|
|
1119
|
+
help=(
|
|
1120
|
+
"render a landing partition manifest for the --partition groups given, "
|
|
1121
|
+
"with every hash computed by this gate, then exit"
|
|
1122
|
+
),
|
|
1123
|
+
)
|
|
1124
|
+
parser.add_argument(
|
|
1125
|
+
"--partition",
|
|
1126
|
+
action="append",
|
|
1127
|
+
nargs="+",
|
|
1128
|
+
metavar="PATH",
|
|
1129
|
+
default=[],
|
|
1130
|
+
help="one partition's paths; repeat per partition (only with --print-manifest)",
|
|
1131
|
+
)
|
|
1132
|
+
args = parser.parse_args()
|
|
1133
|
+
if args.print_manifest and not args.partition:
|
|
1134
|
+
parser.error("--print-manifest needs at least one --partition")
|
|
1135
|
+
if args.partition and not args.print_manifest:
|
|
1136
|
+
parser.error("--partition is only meaningful with --print-manifest")
|
|
1137
|
+
|
|
1138
|
+
repo_root = Path(args.repo_root).resolve()
|
|
1139
|
+
base = args.base or os.environ.get("CCL_SKILL_BASE_REF", "")
|
|
1140
|
+
if not base:
|
|
1141
|
+
# The gate is base-relative by construction, so a run with no base has
|
|
1142
|
+
# checked nothing. Exiting 0 there is how a base-relative gate becomes
|
|
1143
|
+
# decorative: a base-wiring mistake would read as a passing required
|
|
1144
|
+
# check. Fail closed; a caller whose event genuinely has no base must
|
|
1145
|
+
# say so out loud with --allow-unevaluated.
|
|
1146
|
+
emit(
|
|
1147
|
+
"review_ledger_binding_unevaluated: no base ref supplied "
|
|
1148
|
+
"(pass --base or set CCL_SKILL_BASE_REF); nothing was checked"
|
|
1149
|
+
)
|
|
1150
|
+
return 0 if args.allow_unevaluated else 2
|
|
1151
|
+
|
|
1152
|
+
base_tip = resolve_base(repo_root, base)
|
|
1153
|
+
user_paths = tuple(args.paths)
|
|
1154
|
+
|
|
1155
|
+
if args.print_candidate or args.print_manifest:
|
|
1156
|
+
fork, excludes, paths, changed = candidate_scope(repo_root, base_tip, user_paths)
|
|
1157
|
+
if not changed:
|
|
1158
|
+
emit(f"review_ledger_binding_no_change: no reviewed-path change against {fork}")
|
|
1159
|
+
return 0
|
|
1160
|
+
module = load_controller(repo_root)
|
|
1161
|
+
if args.print_manifest:
|
|
1162
|
+
try:
|
|
1163
|
+
manifest = render_manifest(module, repo_root, fork, args.partition, excludes, changed)
|
|
1164
|
+
except ManifestError as exc:
|
|
1165
|
+
emit(f"review_ledger_binding_error: cannot render a landing partition manifest: {exc}")
|
|
1166
|
+
return 1
|
|
1167
|
+
except Exception as exc: # noqa: BLE001 - surface the controller's own message
|
|
1168
|
+
emit(f"review_ledger_binding_error: cannot freeze a partition packet: {exc}")
|
|
1169
|
+
return 1
|
|
1170
|
+
print(json.dumps(manifest, indent=2, ensure_ascii=False))
|
|
1171
|
+
return 0
|
|
1172
|
+
try:
|
|
1173
|
+
print(candidate_hash(module, repo_root, fork, paths))
|
|
1174
|
+
except Exception as exc: # noqa: BLE001 - surface the controller's own message
|
|
1175
|
+
emit(f"review_ledger_binding_error: cannot freeze the candidate packet: {exc}")
|
|
1176
|
+
return 1
|
|
1177
|
+
return 0
|
|
1178
|
+
|
|
1179
|
+
binding = bind_candidate(repo_root, base_tip, user_paths, args.evidence_root, allow_chain=True)
|
|
1180
|
+
if binding.ok:
|
|
1181
|
+
print(f"review_ledger_binding_ok: {binding.summary}")
|
|
1182
|
+
for line in binding.proofs:
|
|
1183
|
+
print(line)
|
|
1184
|
+
return 0
|
|
1185
|
+
for line in binding.failure:
|
|
1186
|
+
emit(line)
|
|
813
1187
|
return 1
|
|
814
1188
|
|
|
815
1189
|
|