@ccoalm/ccl-skills 0.10.0 → 0.11.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.
Files changed (38) hide show
  1. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/SKILL.md +4 -3
  2. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/references/staged-review-contract.md +18 -0
  3. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/review_gate.py +178 -4
  4. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_review_gate.sh +127 -0
  5. package/dist/assets/marketplace/plugins/ccl-skills/skills/defect-diagnosis/SKILL.md +2 -1
  6. package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-dev/SKILL.md +1 -1
  7. package/dist/assets/marketplace/plugins/ccl-skills/skills/llm-inference-integration/SKILL.md +1 -0
  8. package/dist/assets/marketplace/plugins/ccl-skills/skills/nodejs-service-dev/SKILL.md +11 -1
  9. package/dist/assets/marketplace/plugins/ccl-skills/skills/nodejs-service-dev/references/async-lifecycle-and-performance.md +16 -0
  10. package/dist/assets/marketplace/plugins/ccl-skills/skills/nodejs-service-dev/references/source-map.md +1 -0
  11. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/SKILL.md +1 -1
  12. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/delivery-lifecycle.md +1 -1
  13. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/dispatch-owner-skills.md +9 -1
  14. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/problem-resolution-and-learning.md +2 -0
  15. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/SKILL.md +1 -1
  16. package/dist/assets/marketplace/plugins/ccl-skills/skills/release-coordination/references/tag-and-prod-pipeline-gate.md +9 -0
  17. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/SKILL.md +9 -9
  18. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/dual-track-review-gate.md +7 -23
  19. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/external-practice-controls.md +16 -0
  20. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/extraction-quickstart.md +1 -1
  21. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/rule-consolidation.md +2 -0
  22. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/source-register.md +27 -0
  23. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/source-to-skill-extraction.md +2 -2
  24. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/validation-and-landing.md +2 -0
  25. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/check-ccl-skills.sh +5 -0
  26. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/contract-anchors.tsv +1 -0
  27. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/impact-chain-gate.rb +35 -6
  28. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/review_ledger_binding.py +476 -0
  29. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_impact_chain_refscripts.sh +81 -1
  30. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_regressions.sh +3 -0
  31. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_review_ledger_binding.sh +336 -0
  32. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_routing_pointer_integrity.sh +41 -1
  33. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_validate_extraction_review_state.sh +116 -9
  34. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/validate_extraction_review_state.py +123 -32
  35. package/dist/assets/marketplace/plugins/ccl-skills/skills/test-artifact-management/SKILL.md +1 -1
  36. package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/SKILL.md +9 -8
  37. package/dist/assets/release.json +47 -37
  38. package/package.json +1 -1
@@ -1327,19 +1327,48 @@ if upstream.any? || routing_entrypoint_changed || changed_paths.include?(LEDGER_
1327
1327
  # path is broken gets the firing-path message; every other failure shape
1328
1328
  # gets the behavior-evidence message. (A declared wording-only row can
1329
1329
  # never be a RED row, so no exclusion is needed here.)
1330
- firing_path_incomplete = evaluated.any? do |entry|
1331
- entry[:red_declared] && !entry[:firing_path_valid]
1332
- end
1333
- if firing_path_incomplete
1334
- firing_path_failures << path
1330
+ # Name the OFFENDING ROWS, not just the owner. The owner-level `all?` above
1331
+ # means one bad row reddens the package, so an owner-only diagnostic sends the
1332
+ # author to inspect whichever row they were just writing — which is usually the
1333
+ # correct one. Observed: a round whose new row was fine failed because an
1334
+ # unrelated ALREADY-COMMITTED ledger row had been edited by an unbounded string
1335
+ # replace; the edit made that old row an added row of this round, so its own
1336
+ # original anchor no longer resolved here. Three iterations went into rewriting
1337
+ # the good anchor before the real offender was found by patching debug output
1338
+ # into this script. The row identity is what closes that gap.
1339
+ offending_rows = evaluated.each_index.select do |index|
1340
+ evaluated[index][:red_declared] && !evaluated[index][:firing_path_valid]
1341
+ end.map { |index| owner_rows[index] }
1342
+ if offending_rows.any?
1343
+ firing_path_failures << { path: path, rows: offending_rows }
1335
1344
  else
1336
1345
  behavior_failures << path
1337
1346
  end
1338
1347
  end
1348
+ # First cell of a ledger row, bounded: enough to recognise which row is meant
1349
+ # without dumping a multi-thousand-character evidence cell into the diagnostic.
1350
+ # This renders REPOSITORY TEXT a contributor controls onto a terminal/CI channel,
1351
+ # so it is scrubbed before it is printed, not merely bounded. C0/C1 controls,
1352
+ # bidi overrides, and zero-width characters can erase earlier output, restyle it,
1353
+ # or reorder the rendered row so the named offender is not the one a reader sees —
1354
+ # a length cap does nothing about any of that. Invalid bytes are replaced first so
1355
+ # the escape pass cannot raise on a malformed cell. Both reviewer lanes raised this
1356
+ # independently.
1357
+ CONTROL_OR_INVISIBLE = /[\u0000-\u001F\u007F-\u009F\u200B-\u200F\u2028-\u202E\u2060-\u2064\u206A-\u206F\uFEFF]/.freeze
1358
+ row_label = lambda do |row|
1359
+ cell = row[:line].to_s.sub(/\A\+/, "").split("|")[1].to_s.strip
1360
+ cell = cell.empty? ? row[:line].to_s.strip : cell
1361
+ visible = cell.scrub("?").gsub(CONTROL_OR_INVISIBLE) { |ch| format("\\u%04X", ch.ord) }
1362
+ visible.length > 120 ? "#{visible[0, 117]}..." : visible
1363
+ end
1339
1364
  unless firing_path_failures.empty?
1340
1365
  warn "impact_chain_firing_path_missing: RED-baseline row has no owner-scoped firing path in this committed diff"
1341
1366
  warn " fix: point to this owner with `firing-path: command:<changed repo executable>` or `firing-path: file:<changed markdown>#<unique token on a changed numbered/list rule with a normative action>`"
1342
- firing_path_failures.each { |path| warn " incomplete: #{path}" }
1367
+ warn " note: the rows named below are the ones that failed — every RED row bound to an owner must resolve, so a row you did not intend to touch can redden the owner. If a named row is one you only EDITED, it became an added row of this round and its original anchor no longer resolves against this round's diff: restore that row rather than rewriting an anchor that was already correct."
1368
+ firing_path_failures.each do |entry|
1369
+ warn " incomplete: #{entry[:path]}"
1370
+ entry[:rows].each { |row| warn " offending row: #{row_label.call(row)}" }
1371
+ end
1343
1372
  exit 1
1344
1373
  end
1345
1374
  unless behavior_failures.empty?
@@ -0,0 +1,476 @@
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
+ Boundaries this gate does NOT close, stated because a gate that lives inside the
48
+ candidate cannot authenticate itself: it cannot prove the caller retained every
49
+ earlier chain; it runs the candidate's own validator, so a candidate that also
50
+ rewrites that validator is outside what any in-repo check can settle; and a pull
51
+ request may edit the workflow step that runs it. The fail-closed branch is pinned
52
+ as a contract anchor so hollowing it also edits a registry another required check
53
+ verifies; the workflow step itself is NOT pinned, because that registry addresses
54
+ skill files and a cross-tree row breaks the checker against its own fixtures. The terminal authority is the platform's
55
+ required-check configuration plus human review of changes to this gate itself,
56
+ both of which live outside the candidate. What this proves is narrow and worth
57
+ stating plainly: that what merges is the candidate an external round inspected --
58
+ not that the round was honest, and not that it found nothing. Any terminal state
59
+ the validator accepts satisfies this gate, including one that carries unresolved
60
+ findings forward for a human: this binds identity, and the verdict on the findings
61
+ stays with the human who merges.
62
+ """
63
+
64
+ from __future__ import annotations
65
+
66
+ import sys
67
+
68
+ # Importing the controller must not perturb the tree this gate hashes: a written
69
+ # __pycache__ lands as an untracked binary file inside the reviewed paths and the
70
+ # packet freeze then fails on it. Set before any import that can write bytecode.
71
+ sys.dont_write_bytecode = True
72
+
73
+ import argparse
74
+ import hashlib
75
+ import importlib.util
76
+ import json
77
+ import os
78
+ import re
79
+ import subprocess
80
+ import time
81
+ import types
82
+ from pathlib import Path
83
+
84
+ VALIDATOR = "validate_extraction_review_state.py"
85
+ CONTROLLER = Path("skills") / "code-review" / "scripts" / "review_gate.py"
86
+ # Every tracked path. See the module docstring: the inversion is what stops an
87
+ # unreviewed path from riding along on a valid ledger. The only exclusion is
88
+ # computed per run by `added_evidence_paths` -- the receipts this round adds --
89
+ # because a written-down subtree would also hide edits to committed history.
90
+ EVIDENCE_ROOT = "specs"
91
+ EVIDENCE_MEMBER = re.compile(r"^specs/[^/]+/evidence/")
92
+ # A receipt is small; anything larger is not one, and reading it is not free.
93
+ MAX_RECEIPT_BYTES = 4_000_000
94
+ DEFAULT_PATHS = (".",)
95
+
96
+
97
+ def emit(message: str) -> None:
98
+ print(message, file=sys.stderr)
99
+
100
+
101
+ def load_controller(repo_root: Path) -> types.ModuleType:
102
+ controller_path = repo_root / CONTROLLER
103
+ if not controller_path.is_file():
104
+ raise SystemExit(
105
+ f"review_ledger_binding_error: controller not found at {controller_path}"
106
+ )
107
+ spec = importlib.util.spec_from_file_location(
108
+ "ccl_review_gate_for_binding", controller_path
109
+ )
110
+ if spec is None or spec.loader is None:
111
+ raise SystemExit("review_ledger_binding_error: controller is not importable")
112
+ module = importlib.util.module_from_spec(spec)
113
+ spec.loader.exec_module(module)
114
+ return module
115
+
116
+
117
+ def resolve_base(repo_root: Path, base: str) -> str:
118
+ """Resolve the caller's base to a commit id before it reaches any git command.
119
+
120
+ An option-shaped base (``--quiet``) is read by git as an option rather than a
121
+ revision, and ``git diff`` with no revision compares the index to the working
122
+ tree: in a clean checkout that reports no paths at all, so the gate would pass
123
+ having compared nothing.
124
+ """
125
+ result = subprocess.run(
126
+ [
127
+ "git",
128
+ "-C",
129
+ str(repo_root),
130
+ "rev-parse",
131
+ "--verify",
132
+ "--quiet",
133
+ "--end-of-options",
134
+ f"{base}^{{commit}}",
135
+ ],
136
+ stdout=subprocess.PIPE,
137
+ stderr=subprocess.PIPE,
138
+ text=True,
139
+ check=False,
140
+ )
141
+ resolved = result.stdout.strip()
142
+ if result.returncode != 0 or len(resolved) != 40 or not all(
143
+ character in "0123456789abcdef" for character in resolved
144
+ ):
145
+ raise SystemExit(
146
+ f"review_ledger_binding_error: base does not resolve to a commit: {base}"
147
+ )
148
+ return resolved
149
+
150
+
151
+ def fork_point(repo_root: Path, base: str) -> str:
152
+ """Compare against where this branch left the base, not the base's tip.
153
+
154
+ A candidate measured against the tip absorbs every unrelated change the base
155
+ branch gained meanwhile, so an advance on the target branch silently restates
156
+ what this branch is and voids evidence that is still correct. The fork point
157
+ is what the branch actually adds, and it does not move when someone else
158
+ merges.
159
+ """
160
+ result = subprocess.run(
161
+ ["git", "-C", str(repo_root), "merge-base", base, "HEAD"],
162
+ stdout=subprocess.PIPE,
163
+ stderr=subprocess.PIPE,
164
+ text=True,
165
+ check=False,
166
+ )
167
+ resolved = result.stdout.strip()
168
+ if result.returncode != 0 or len(resolved) != 40:
169
+ raise SystemExit(
170
+ f"review_ledger_binding_error: no fork point between HEAD and {base}"
171
+ )
172
+ return resolved
173
+
174
+
175
+ def added_evidence_paths(repo_root: Path, base: str) -> list[str]:
176
+ """Paths this round ADDS under a round's evidence directory.
177
+
178
+ These are the only paths the candidate may exclude, and the predicate is what
179
+ the file IS, not where it sits. Two earlier shapes of this exclusion were
180
+ each broken by an adversarial round, and both failures were the same one: the
181
+ rule named a location and the location stood in for "this is a receipt".
182
+ Excluding all of `specs/` let a pull request delete or rewrite an earlier
183
+ round's plan and receipts -- the committed review history itself -- with no
184
+ evidence required. Narrowing that to added paths under an evidence directory
185
+ then let an arbitrary added file there, a script included, ride through
186
+ unreviewed for exactly the same reason.
187
+
188
+ So the third shape stops using the path as a proxy. A file is excluded only
189
+ when it is what the exclusion exists for: a committed JSON object carrying
190
+ the 64-hex `candidate_sha256` that makes it a receipt about some candidate.
191
+ Its directory still has to be a round's evidence directory, because that is
192
+ where receipts belong, but the directory alone no longer buys exclusion.
193
+ Anything else added there -- a script, a fixture, a data file, a JSON file
194
+ with no candidate binding -- is bound like any other path, as is every
195
+ modification and deletion under `specs/`.
196
+ """
197
+ result = subprocess.run(
198
+ [
199
+ "git", "-C", str(repo_root), "diff", "--name-only",
200
+ "--diff-filter=A", base, "HEAD", "--", EVIDENCE_ROOT,
201
+ ],
202
+ stdout=subprocess.PIPE,
203
+ stderr=subprocess.PIPE,
204
+ text=True,
205
+ check=False,
206
+ )
207
+ if result.returncode != 0:
208
+ raise SystemExit(
209
+ "review_ledger_binding_error: cannot enumerate added evidence: "
210
+ f"{result.stderr.strip()}"
211
+ )
212
+ excluded: list[str] = []
213
+ for line in result.stdout.splitlines():
214
+ if not EVIDENCE_MEMBER.match(line):
215
+ continue
216
+ if is_candidate_receipt(repo_root, line):
217
+ excluded.append(line)
218
+ return excluded
219
+
220
+
221
+ def is_candidate_receipt(repo_root: Path, path_value: str) -> bool:
222
+ """Whether the committed blob at this path is a receipt about a candidate.
223
+
224
+ Read from the object store rather than the working tree: the exclusion has to
225
+ describe what merges. A blob that is not JSON, is not an object, or carries no
226
+ 64-hex `candidate_sha256` is not a receipt, whatever it is named or wherever
227
+ it sits, and it stays inside the candidate.
228
+ """
229
+ result = subprocess.run(
230
+ ["git", "-C", str(repo_root), "cat-file", "blob", f"HEAD:{path_value}"],
231
+ stdout=subprocess.PIPE,
232
+ stderr=subprocess.PIPE,
233
+ check=False,
234
+ )
235
+ if result.returncode != 0 or len(result.stdout) > MAX_RECEIPT_BYTES:
236
+ return False
237
+ try:
238
+ payload = json.loads(result.stdout.decode("utf-8"))
239
+ except (UnicodeDecodeError, json.JSONDecodeError):
240
+ return False
241
+ if not isinstance(payload, dict):
242
+ return False
243
+ binding = payload.get("candidate_sha256")
244
+ return (
245
+ isinstance(binding, str)
246
+ and len(binding) == 64
247
+ and all(character in "0123456789abcdef" for character in binding)
248
+ )
249
+
250
+
251
+ def require_committed_tree(repo_root: Path, paths: tuple[str, ...]) -> None:
252
+ """Refuse a candidate the merge cannot reproduce.
253
+
254
+ The frozen packet is built from the working tree and includes untracked
255
+ files, so a scratch file or an unstaged edit inside the bound paths silently
256
+ produces a hash no clean checkout will ever recompute: the author records it
257
+ in the ledger and the merge-side run then reports that no evidence binds the
258
+ landing candidate. Refusing out loud costs a commit; the alternative costs a
259
+ review round nobody can reproduce. This is the same stance the evidence tree
260
+ already takes -- what merges is the committed tree.
261
+ """
262
+ result = subprocess.run(
263
+ ["git", "-C", str(repo_root), "status", "--porcelain", "--", *paths],
264
+ stdout=subprocess.PIPE,
265
+ stderr=subprocess.PIPE,
266
+ text=True,
267
+ check=False,
268
+ )
269
+ if result.returncode != 0:
270
+ raise SystemExit(
271
+ f"review_ledger_binding_error: cannot read tree state: {result.stderr.strip()}"
272
+ )
273
+ dirty = [line for line in result.stdout.splitlines() if line]
274
+ if dirty:
275
+ raise SystemExit(
276
+ "review_ledger_binding_error: the candidate tree carries uncommitted "
277
+ "changes, so its hash is not the one a clean checkout recomputes; "
278
+ "commit them first: " + ", ".join(entry[3:] for entry in dirty[:5])
279
+ )
280
+
281
+
282
+ def changed_skill_paths(repo_root: Path, base: str, paths: tuple[str, ...]) -> list[str]:
283
+ result = subprocess.run(
284
+ ["git", "-C", str(repo_root), "diff", "--name-only", base, "--", *paths],
285
+ stdout=subprocess.PIPE,
286
+ stderr=subprocess.PIPE,
287
+ text=True,
288
+ check=False,
289
+ )
290
+ if result.returncode != 0:
291
+ raise SystemExit(
292
+ f"review_ledger_binding_error: cannot diff against {base}: {result.stderr.strip()}"
293
+ )
294
+ return [line for line in result.stdout.splitlines() if line]
295
+
296
+
297
+ def candidate_hash(module: types.ModuleType, repo_root: Path, base: str, paths: tuple[str, ...]) -> str:
298
+ args = argparse.Namespace(
299
+ cwd=str(repo_root),
300
+ diff_file=None,
301
+ base=base,
302
+ paths=list(paths),
303
+ wording_only_proof_file=None,
304
+ )
305
+ packet_path, packet_sha256, _paths, _secrets = module.freeze_packet(
306
+ args, time.monotonic() + 120
307
+ )
308
+ try:
309
+ Path(packet_path).unlink(missing_ok=True)
310
+ except OSError:
311
+ pass
312
+ return packet_sha256
313
+
314
+
315
+ def validator_accepts(validator: Path, ledger_path: Path) -> tuple[bool, str]:
316
+ result = subprocess.run(
317
+ [sys.executable, str(validator), str(ledger_path)],
318
+ stdout=subprocess.PIPE,
319
+ stderr=subprocess.STDOUT,
320
+ text=True,
321
+ check=False,
322
+ timeout=60,
323
+ )
324
+ return result.returncode == 0, result.stdout.strip()
325
+
326
+
327
+ def scan(repo_root: Path, evidence_root: str) -> list[tuple[Path, dict]]:
328
+ """Enumerate committed evidence only.
329
+
330
+ What merges is the committed tree, so evidence that is untracked or modified
331
+ in the working tree is not evidence about the landing candidate -- and a gate
332
+ that reads it would accept a ledger nobody can find after the merge. The
333
+ enumeration comes from HEAD, and a dirty evidence tree is refused outright
334
+ rather than silently read from disk.
335
+ """
336
+ listing = subprocess.run(
337
+ ["git", "-C", str(repo_root), "ls-tree", "-r", "--name-only", "HEAD", "--", evidence_root],
338
+ stdout=subprocess.PIPE,
339
+ stderr=subprocess.PIPE,
340
+ text=True,
341
+ check=False,
342
+ )
343
+ if listing.returncode != 0:
344
+ return []
345
+ dirty = subprocess.run(
346
+ ["git", "-C", str(repo_root), "status", "--porcelain", "--", evidence_root],
347
+ stdout=subprocess.PIPE,
348
+ stderr=subprocess.PIPE,
349
+ text=True,
350
+ check=False,
351
+ )
352
+ if dirty.returncode != 0 or dirty.stdout.strip():
353
+ raise SystemExit(
354
+ "review_ledger_binding_error: the evidence tree has uncommitted changes; "
355
+ "commit the ledger and its receipts before this gate can read them"
356
+ )
357
+ found: list[tuple[Path, dict]] = []
358
+ for relative in sorted(line for line in listing.stdout.splitlines() if line.endswith(".json")):
359
+ path = repo_root / relative
360
+ try:
361
+ payload = json.loads(path.read_text(encoding="utf-8"))
362
+ except (OSError, UnicodeError, json.JSONDecodeError):
363
+ continue
364
+ if isinstance(payload, dict):
365
+ found.append((path, payload))
366
+ return found
367
+
368
+
369
+ def main() -> int:
370
+ parser = argparse.ArgumentParser(description=__doc__)
371
+ parser.add_argument("--repo-root", default=".")
372
+ parser.add_argument("--base", default=None)
373
+ parser.add_argument(
374
+ "--allow-unevaluated",
375
+ action="store_true",
376
+ help="permit a run with no resolvable base to exit 0, for events that have none",
377
+ )
378
+ parser.add_argument("--evidence-root", default="specs")
379
+ parser.add_argument("--paths", nargs="*", default=list(DEFAULT_PATHS))
380
+ parser.add_argument(
381
+ "--print-candidate",
382
+ action="store_true",
383
+ help="print the candidate hash the evidence must bind, then exit",
384
+ )
385
+ args = parser.parse_args()
386
+
387
+ repo_root = Path(args.repo_root).resolve()
388
+ base = args.base or os.environ.get("CCL_SKILL_BASE_REF", "")
389
+ if not base:
390
+ # The gate is base-relative by construction, so a run with no base has
391
+ # checked nothing. Exiting 0 there is how a base-relative gate becomes
392
+ # decorative: a base-wiring mistake would read as a passing required
393
+ # check. Fail closed; a caller whose event genuinely has no base must
394
+ # say so out loud with --allow-unevaluated.
395
+ emit(
396
+ "review_ledger_binding_unevaluated: no base ref supplied "
397
+ "(pass --base or set CCL_SKILL_BASE_REF); nothing was checked"
398
+ )
399
+ return 0 if args.allow_unevaluated else 2
400
+
401
+ base = fork_point(repo_root, resolve_base(repo_root, base))
402
+ # The exclusion is derived from this round's own diff, not written down as a
403
+ # subtree, so edits to committed history stay inside the candidate.
404
+ # These names come from the candidate's own tree and are handed back to git as
405
+ # pathspecs, so `literal` stops git reading a filename as a pattern. A review
406
+ # round called this a total bypass -- a receipt named `*` excluding everything
407
+ # -- and that did not reproduce: the exclusion carries the full path, so a glob
408
+ # in the filename expands only within that one evidence directory, whose other
409
+ # members are receipts anyway. The claim is recorded as narrowed rather than
410
+ # confirmed, and no test asserts a bypass this gate does not have. `literal`
411
+ # stays because interpreting these names as patterns is a capability the gate
412
+ # never needed, and removing it costs nothing.
413
+ paths = tuple(args.paths) + tuple(
414
+ f":(exclude,literal){path}" for path in added_evidence_paths(repo_root, base)
415
+ )
416
+ require_committed_tree(repo_root, paths)
417
+ changed = changed_skill_paths(repo_root, base, paths)
418
+ if not changed:
419
+ # No reviewed path moved, so there is no candidate to freeze and nothing to
420
+ # bind. Say which it is rather than letting an empty packet surface as a
421
+ # freeze error, which reads like a broken gate.
422
+ if args.print_candidate:
423
+ emit(f"review_ledger_binding_no_change: no reviewed-path change against {base}")
424
+ else:
425
+ print(f"review_ledger_binding_ok: no reviewed-path change against {base}")
426
+ return 0
427
+
428
+ module = load_controller(repo_root)
429
+ try:
430
+ expected = candidate_hash(module, repo_root, base, paths)
431
+ except Exception as exc: # noqa: BLE001 - surface the controller's own message
432
+ emit(f"review_ledger_binding_error: cannot freeze the candidate packet: {exc}")
433
+ return 1
434
+
435
+ if args.print_candidate:
436
+ print(expected)
437
+ return 0
438
+
439
+ validator = repo_root / "skills" / "skill-extraction-workflow" / "scripts" / VALIDATOR
440
+ ledgers: list[str] = []
441
+ for path, payload in scan(repo_root, args.evidence_root):
442
+ if payload.get("candidate_sha256") != expected:
443
+ continue
444
+ relative = str(path.relative_to(repo_root))
445
+ # Only a validator-accepted ledger counts. A receipt-shaped file proves
446
+ # nothing on its own: this gate cannot authenticate that a controller
447
+ # minted it, so any branch keyed on a self-declared field is a bypass a
448
+ # contributor can hand-write.
449
+ if "closeout_state" in payload and "controller_receipts" in payload:
450
+ accepted, output = validator_accepts(validator, path)
451
+ if accepted:
452
+ print(
453
+ f"review_ledger_binding_ok: {relative} binds the landing candidate "
454
+ f"({expected[:12]}...) -- {output}"
455
+ )
456
+ return 0
457
+ ledgers.append(f"{relative}: {output}")
458
+
459
+ emit(
460
+ "review_ledger_binding_failed: no accepted review evidence binds the landing "
461
+ f"candidate {expected}"
462
+ )
463
+ emit(f" reviewed paths: {' '.join(paths)} against {base}")
464
+ emit(f" changed files: {len(changed)}")
465
+ for row in ledgers:
466
+ emit(f" rejected ledger -> {row}")
467
+ if not ledgers:
468
+ emit(
469
+ " no committed ledger records this candidate; run the extraction review "
470
+ "lane against the final, committed tree"
471
+ )
472
+ return 1
473
+
474
+
475
+ if __name__ == "__main__":
476
+ raise SystemExit(main())
@@ -194,6 +194,86 @@ run_gate
194
194
  assert_not_contains "impact_chain_firing_path_missing" "$out" "a changed shebang owner executable should satisfy the firing-path gate"
195
195
  assert_rc "$rc" 0 "a complete RED row with a changed owner executable must pass"
196
196
 
197
+ # Non-ASCII anchors must be accepted, and the proof must be DIFFERENTIAL rather
198
+ # than one literal passing: a special-cased implementation, or one that handles
199
+ # Han but not a combining mark, would satisfy a single-literal leg. Each row below
200
+ # pairs a non-ASCII anchor with an ASCII anchor matched on everything the gate
201
+ # actually predicates on - uniqueness, the 16-character bar, list-line shape, a
202
+ # whitelisted normative verb - so the two verdicts must agree. This exists because
203
+ # a round concluded from reading the wrong lambda (`raw_blob_at` forces binary;
204
+ # `locator_valid` resolves through `blob_at`, which does not) that a non-ASCII
205
+ # anchor could never validate, and recorded a defect that was not there.
206
+ #
207
+ # Representations covered: Han; a precomposed Latin letter (NFC, two bytes); the
208
+ # same letter decomposed into base + combining mark (NFD, so the anchor carries a
209
+ # codepoint that is not a character on its own).
210
+ anchor_pair_case() {
211
+ # $1 = case label, $2 = non-ASCII anchor, $3 = ASCII anchor of the same shape
212
+ local label="$1" nonascii="$2" ascii="$3" verdict_nonascii verdict_ascii
213
+ new_case "case-ref-anchor-pair-$label-nonascii"
214
+ printf '\n- %s\n' "$nonascii" >> "$REPO/$UPSTREAM_REF"
215
+ printf '| Fixture anchor pair %s non-ASCII | `downstream-executor` | behavioral-evidence: RED-baseline; observed-failure: yes; result-class: failure; bank-evidence: downscoped:REFSCRIPTS-FIXTURE-NO-BANK; firing-path: file:skills/product-rd-workflow/references/adr-convention.md#%s | `updated` | `product-rd-workflow/SKILL.md` reference edit |\n' "$label" "$nonascii" >> "$REGISTER"
216
+ routing_surface_downscope
217
+ commit_case "anchor pair $label non-ASCII"
218
+ run_gate
219
+ verdict_nonascii="$rc"
220
+
221
+ new_case "case-ref-anchor-pair-$label-ascii"
222
+ printf '\n- %s\n' "$ascii" >> "$REPO/$UPSTREAM_REF"
223
+ printf '| Fixture anchor pair %s ASCII | `downstream-executor` | behavioral-evidence: RED-baseline; observed-failure: yes; result-class: failure; bank-evidence: downscoped:REFSCRIPTS-FIXTURE-NO-BANK; firing-path: file:skills/product-rd-workflow/references/adr-convention.md#%s | `updated` | `product-rd-workflow/SKILL.md` reference edit |\n' "$label" "$ascii" >> "$REGISTER"
224
+ routing_surface_downscope
225
+ commit_case "anchor pair $label ASCII"
226
+ run_gate
227
+ verdict_ascii="$rc"
228
+
229
+ assert_rc "$verdict_ascii" 0 "the ASCII control of pair $label must be accepted"
230
+ assert_rc "$verdict_nonascii" "$verdict_ascii" "pair $label: the non-ASCII anchor must get the same verdict as its ASCII control"
231
+ }
232
+
233
+ anchor_pair_case han \
234
+ '下游执行者必须在收到该契约变更时同步更新其验证面。' \
235
+ 'The downstream executor must refresh its verification surface on this change.'
236
+ # Same sentence and the same whitelisted verb in both arms; only the accented
237
+ # codepoint differs, so a verdict difference can only come from byte composition.
238
+ anchor_pair_case nfc \
239
+ 'The owner must refresh its vérification surface on this change.' \
240
+ 'The owner must refresh its verification surface on this change.'
241
+ anchor_pair_case nfd \
242
+ 'The owner must refresh its vérification surface on that change.' \
243
+ 'The owner must refresh its verification surface on that change.'
244
+
245
+ # When one owner carries several RED rows, the owner-level check reddens the whole
246
+ # package for a single bad row — so the diagnostic must name WHICH row failed.
247
+ # Without it the author inspects the row they just wrote (usually the correct one)
248
+ # while an unrelated row is the offender; that cost one round three iterations.
249
+ new_case case-ref-firing-path-names-offending-row
250
+ printf '\n- 下游执行者必须在收到该契约变更时同步更新其验证面。\n' >> "$REPO/$UPSTREAM_REF"
251
+ printf '| Fixture good row beside a bad one | `downstream-executor` | behavioral-evidence: RED-baseline; observed-failure: yes; result-class: failure; bank-evidence: downscoped:REFSCRIPTS-FIXTURE-NO-BANK; firing-path: file:skills/product-rd-workflow/references/adr-convention.md#下游执行者必须在收到该契约变更时同步更新其验证面 | `updated` | `product-rd-workflow/SKILL.md` reference edit |\n' >> "$REGISTER"
252
+ printf '| Fixture offending row with an unresolvable anchor | `downstream-executor` | behavioral-evidence: RED-baseline; observed-failure: yes; result-class: failure; bank-evidence: downscoped:REFSCRIPTS-FIXTURE-NO-BANK; firing-path: file:skills/product-rd-workflow/references/adr-convention.md#this anchor text was never added by this round | `updated` | `product-rd-workflow/SKILL.md` reference edit |\n' >> "$REGISTER"
253
+ routing_surface_downscope
254
+ commit_case "one good row beside one unresolvable row"
255
+ run_gate
256
+ assert_rc "$rc" 1 "an unresolvable anchor on any bound RED row must fail the owner"
257
+ assert_contains "impact_chain_firing_path_missing" "$out" "checker should report the firing-path verdict"
258
+ assert_contains "Fixture offending row with an unresolvable anchor" "$out" "the diagnostic must name the offending row"
259
+ assert_not_contains "offending row: Fixture good row beside a bad one" "$out" "the diagnostic must not name a row whose anchor resolves"
260
+
261
+ # The offending-row line renders repository text a contributor controls onto a
262
+ # terminal/CI channel. Control and invisible characters must be escaped, not merely
263
+ # truncated: raw ANSI or carriage-return bytes can erase or restyle surrounding
264
+ # output, and bidi overrides can reorder it, so a reader sees a diagnostic that does
265
+ # not match what the gate decided. Both reviewer lanes raised this independently.
266
+ new_case case-ref-offending-row-escapes-control-bytes
267
+ printf '\n- 下游执行者必须在收到该契约变更时同步更新其验证面。\n' >> "$REPO/$UPSTREAM_REF"
268
+ printf '| Fixture control-byte row \033[31m\rSPOOFED\u202E | `downstream-executor` | behavioral-evidence: RED-baseline; observed-failure: yes; result-class: failure; bank-evidence: downscoped:REFSCRIPTS-FIXTURE-NO-BANK; firing-path: file:skills/product-rd-workflow/references/adr-convention.md#this anchor text was never added by this round | `updated` | `product-rd-workflow/SKILL.md` reference edit |\n' >> "$REGISTER"
269
+ routing_surface_downscope
270
+ commit_case "offending row carrying control bytes"
271
+ run_gate
272
+ assert_rc "$rc" 1 "an unresolvable anchor must still fail with a control-byte row"
273
+ assert_contains "\\u001B" "$out" "the diagnostic must escape an ANSI introducer rather than emit it"
274
+ assert_contains "\\u000D" "$out" "the diagnostic must escape a carriage return rather than emit it"
275
+ assert_contains "\\u202E" "$out" "the diagnostic must escape a bidi override rather than emit it"
276
+
197
277
  # A malformed impact-chain-looking row (wrong cell count) is warned but stays
198
278
  # advisory: hard-blocking would false-positive on the register's many other
199
279
  # legitimate table shapes, and deliberate mangling is outside the trust model
@@ -1329,6 +1409,6 @@ assert_rc "$rc" 1 "a directory masquerading as SKILL.md must not read as a prese
1329
1409
  assert_contains "platform-observability/SKILL.md" "$out" "the masqueraded owner must be named"
1330
1410
 
1331
1411
  assert_rc "$full_check_runs" 1 "fixture suite must retain exactly one full checker wiring case"
1332
- assert_rc "$gate_runs" 80 "all remaining impact-chain fixtures must run the standalone gate"
1412
+ assert_rc "$gate_runs" 88 "all remaining impact-chain fixtures must run the standalone gate"
1333
1413
 
1334
1414
  echo "test_check_ccl_impact_chain_refscripts: ok"
@@ -114,6 +114,9 @@ fast_tests=(
114
114
  test_shared_git_surface_gate.sh
115
115
  test_extraction_review_gate.sh
116
116
  test_validate_extraction_review_state.sh
117
+ # Merge-side binding between the landing candidate and the review evidence that
118
+ # inspected it: own throwaway git repo, no clone, seconds.
119
+ test_review_ledger_binding.sh
117
120
  # Candidate-SHA-bound gate receipts (mint/verify): own throwaway git repo,
118
121
  # no clone, seconds — belongs in the lane every run exercises.
119
122
  test_gate_receipt.sh