@ccoalm/ccl-skills 0.1.1 → 0.2.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 (24) hide show
  1. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_init_policy_matrix.sh +93 -16
  2. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_parse_probe_result.sh +10 -0
  3. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_review_gate.sh +249 -5
  4. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_review_gate_abort_leak.sh +394 -0
  5. package/dist/assets/marketplace/plugins/ccl-skills/skills/llm-inference-integration/references/model-prompt-evaluation.md +7 -0
  6. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/external-ui-ux-quality-benchmarks.md +50 -1
  7. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/ui-ux-audit.md +1 -0
  8. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/SKILL.md +1 -1
  9. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/external-practice-controls.md +59 -1
  10. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/rule-consolidation.md +3 -1
  11. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/source-register.md +34 -0
  12. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/source-to-skill-extraction.md +16 -4
  13. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/impact-chain-gate.rb +391 -14
  14. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/register-firing-path-resolution.rb +74 -0
  15. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_regressions.sh +74 -33
  16. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_skill_catalog.sh +11 -4
  17. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_impact_chain_gate_verdict_differential.sh +421 -0
  18. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_impact_chain_round_attribution.sh +576 -0
  19. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_impact_chain_source_refuted.sh +176 -0
  20. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_regression_runner_lanes.sh +101 -0
  21. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_validate_skill_root_depth.sh +6 -2
  22. package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/references/ci-fixtures-and-flake-control.md +1 -1
  23. package/dist/assets/release.json +45 -20
  24. package/package.json +1 -1
@@ -292,12 +292,20 @@ upstream = upstream.reject do |path|
292
292
  rename_excused[path] = true if excused
293
293
  excused
294
294
  end
295
- if upstream.any?
295
+ # The block runs when a selected owner changed (there is something to demand) OR
296
+ # when the ledger itself changed (there is something to validate). It used to
297
+ # gate on `upstream.any?` alone, which is the demand side only — and that is the
298
+ # other half of the restored-owner hole: revert the owner and the range has no
299
+ # changed owner at all, so the entire row evaluation was skipped and the
300
+ # surviving row was never even looked at. With no ledger change there are no
301
+ # added rows, so the skip stays exactly as cheap as before for unrelated diffs.
302
+ if upstream.any? || changed_paths.include?(LEDGER_PATH)
296
303
  # Rows are collected PER ROUND and carry the scope they were authored against,
297
304
  # so the classifiers below judge a row against its own round's diff. Reading the
298
305
  # cumulative register diff instead would re-judge every earlier round's rows
299
306
  # against a range that keeps growing — the defect this partition removes.
300
307
  rows = []
308
+ added_lines = []
301
309
  round_bounds.each do |span_base, span_head|
302
310
  round_scope = scope_at.call(span_base, span_head)
303
311
  register_diff = git_read.call("diff", span_base, span_head, "--", LEDGER_PATH)
@@ -308,6 +316,13 @@ if upstream.any?
308
316
  # the pending-status scan below normalizes identically, keeping the two
309
317
  # consistent (previously "+|" alone falsely blocked a valid indented row).
310
318
  stripped = line.sub(/\A\+/, "").strip
319
+ # EVERY added line is a candidate, recorded before any structural rejection.
320
+ # Enumerating rejection paths one at a time was the wrong shape: a stray pipe,
321
+ # an empty cell, an unrecognized status word and a missing outer pipe are four
322
+ # spellings of one escape, and each was found separately. The scoped check
323
+ # below is defined by "this line did not become a row" rather than by which
324
+ # check turned it away, so a fifth spelling closes with them.
325
+ added_lines << { line: line.strip, raw: stripped }
311
326
  next unless stripped.start_with?("|") && stripped.end_with?("|")
312
327
  raw = stripped[1..-2]
313
328
  # Honor markdown-escaped pipes (\|) so a literal | inside a cell does not
@@ -374,6 +389,7 @@ if upstream.any?
374
389
  end
375
390
  bad_evidence_files = []
376
391
  ambiguous_evidence_rows = []
392
+ unchanged_owner_rows = []
377
393
  evidence_rows_by_upstream_path = Hash.new(0)
378
394
  rows_by_upstream_path = Hash.new { |h, k| h[k] = [] }
379
395
  declared_in_round = {}
@@ -428,27 +444,241 @@ if upstream.any?
428
444
  !(destination && selectable_path.call("#{destination}/SKILL.md"))
429
445
  end
430
446
  end
447
+ # OWNERSHIP RESOLUTION reads the evidence cell by OWNER-PACKAGE PREFIX, not by
448
+ # an exact `<slug>/SKILL.md` match. The exact match was a proxy for "this row
449
+ # is about that owner" and it under-matched the register's own writing
450
+ # convention: measured over the shipped ledger, a large minority of rows carry
451
+ # a full behavioral-evidence declaration while citing a path INSIDE the owner
452
+ # package (`<owner>/scripts/...`, `<owner>/references/...`) and never the
453
+ # SKILL.md itself. Those rows bound to nothing, so nothing ever evaluated the
454
+ # declaration they make — the row was silently inert rather than rejected.
455
+ # Any path under an owner package identifies the owner just as well, which is
456
+ # the same normalization the subject set above already applies to the diff.
457
+ #
458
+ # RESOLUTION IS SEPARATE FROM CHANGED-NESS. The old predicate filtered the
459
+ # cited paths through `upstream_set` (the CUMULATIVE changed set) while
460
+ # resolving them, so an owner that was absent from that set produced an empty
461
+ # list — and an empty list meant "not this gate's business", silently. That
462
+ # single filter switched off BOTH obligations at once: the row stopped being
463
+ # evaluated AND stopped being demanded. An owner restored to its base bytes
464
+ # (a rebase or a conflict resolved to the base side) leaves the changed set
465
+ # while its row survives at HEAD, and the row kept vouching for a change the
466
+ # delivered diff no longer contains. Resolution now answers only "which owner
467
+ # is this row about" (a property of the NAME); changed-ness is asked
468
+ # afterwards, as its own question with its own refusal.
469
+ # The lookbehind refuses a match preceded by a path character, which is what
470
+ # keeps a URL or a longer unrelated path from resolving to an owner. It also
471
+ # refuses the two forms an author writes without meaning anything unusual — a
472
+ # leading `./` or a rooted `/skills/...` — and a citation that resolves to
473
+ # nothing is silently skipped, which is precisely the state the new refusals
474
+ # exist to remove. So `skills/` may be reached through an explicit `./` or `/`
475
+ # prefix, and only through it: the prefix must be followed by the literal
476
+ # `skills/` segment, so widening here cannot admit an arbitrary deep path.
477
+ # A `..` segment is NOT normalized; a citation that walks out of the package it
478
+ # names is dishonest authorship, which this gate's trust model already assigns
479
+ # to the mandatory independent review rather than to a lexical check.
480
+ #
481
+ # Only the PREFIX form is widened. It is the one gated behind a
482
+ # behavioral-evidence declaration, so widening it can only reach rows this gate
483
+ # already governs. The exact form stays byte-for-byte as it was, because it is
484
+ # unconditional: widening it would newly bind rows in the register's other
485
+ # tables and drag them into evaluation — the regression the declaration
486
+ # condition exists to prevent.
487
+ # The prefix allowance is `./skills/` or `/skills/` — a separator is admitted
488
+ # only when the literal `skills/` segment follows it. That keeps out the forms
489
+ # that are not paths inside this repository's owner package (`../skills/...`,
490
+ # `../<owner>/...`, a bare `/<owner>/...`), while the lookbehind still excludes
491
+ # a `skills/` segment sitting inside a URL or a longer path, since the
492
+ # character before the separator is then a word character. Leaving the rooted
493
+ # form out was itself an evasion: the citation resolved to nothing, and a row
494
+ # that resolves to nothing is skipped before any refusal can apply.
495
+ # OWNERSHIP RESOLUTION IS A MEMBERSHIP TEST, NOT A PATH PARSE.
496
+ #
497
+ # The evidence cell is prose that happens to mention paths, and four review
498
+ # rounds each found another path spelling the parse did not accept — `./skills/`,
499
+ # a rooted `/skills/`, a remainder starting `@` or holding a non-ASCII
500
+ # character, and an ordinary Markdown-relative `../../<owner>/…`. Every miss had
501
+ # the same consequence: the citation resolved to nothing, and a row that
502
+ # resolves to nothing is skipped before any refusal can apply. Same class four
503
+ # times is the signal to stop patching the proxy.
504
+ #
505
+ # So the predicate no longer reasons about path syntax at all. The gate owns a
506
+ # finite vocabulary — the selectable owner names — and asks only whether the
507
+ # cell mentions one of them as a path segment. There is no grammar left to
508
+ # exhaust: every spelling above contains `<owner>/`, and a name is admitted only
509
+ # when the character before it is not a word character or hyphen, so a longer
510
+ # slug ending in a shorter one never matches.
511
+ #
512
+ # The residual is the mirror image and is smaller: a URL whose path happens to
513
+ # carry a selectable name binds the row to that owner. Such a link usually
514
+ # points AT that skill, so the binding is right; when it is not, the cost is one
515
+ # visible refusal carrying a diagnostic, against the alternative of rows that
516
+ # are silently never evaluated at all.
517
+ owner_names_at = lambda do |ref|
518
+ out = IO.popen(["git", "-C", root, "ls-tree", "-z", "--name-only", "#{ref}:skills"],
519
+ err: File::NULL, &:read)
520
+ $?.success? ? out.split("\0").reject(&:empty?) : []
521
+ end
522
+ resolvable_owner_names = (
523
+ upstream_owner_skills +
524
+ owner_names_at.call("HEAD") + owner_names_at.call(base_ref)
525
+ ).uniq.select { |name| selectable_path.call("#{name}/SKILL.md") }
526
+ owner_mentioned = lambda do |evidence, name|
527
+ evidence.match?(/(?<![\w-])#{Regexp.escape(name)}\//)
528
+ end
529
+ owner_skill_md_path = %r{(?<![\w/.-])([a-z][a-z0-9_-]+)/SKILL\.md(?![\w/.-])}
431
530
  rows.each do |row|
432
- paths = row[:evidence].scan(/(?<![\w\/.-])([a-z][a-z0-9_-]+\/SKILL\.md)(?![\w\/.-])/).flatten.uniq
531
+ # THE REGISTER HOLDS MORE THAN THIS GATE'S TABLE. Its row filter is
532
+ # deliberately loose — five columns, a status word, a non-header first cell —
533
+ # and the exact `<slug>/SKILL.md` match was doing double duty as the de-facto
534
+ # discriminator: an unrelated five-column row almost never contains one.
535
+ # Widening resolution to any owner-package path removed that discriminator,
536
+ # so an unrelated row citing `skills/<owner>/references/note.md` would newly
537
+ # resolve to a selected owner and could be refused as an unchanged-owner row
538
+ # or dragged into behavioral validation. Adversarial review caught this
539
+ # against this change's own acceptance criterion that other table shapes keep
540
+ # their prior behavior.
541
+ #
542
+ # The widening is therefore conditioned on the row making an impact-chain
543
+ # CLAIM — carrying a `behavioral-evidence:` fragment, the declaration this
544
+ # gate owns and no other register table uses. Exact SKILL.md resolution stays
545
+ # unconditional so no pre-existing refusal path is lost, and a row with no
546
+ # claim is not held to the new unchanged-owner refusal either: a row that
547
+ # declares nothing is not vouching for anything.
548
+ # THE BIFURCATION. A row carrying the declaration is one of this gate's rows
549
+ # and gets the new semantics. A row without one keeps the OLD predicate
550
+ # verbatim — including its changed-set filter — because "prior behavior" has
551
+ # to mean the whole predicate, not just the parts that were convenient to
552
+ # keep. Conditioning only the widening while sharing the counting step looks
553
+ # equivalent and is not: a two-owner citation where only one owner changed
554
+ # used to resolve to that one owner and be evaluated, and sharing the
555
+ # name-level count turned it into an ambiguity that drops the row, losing a
556
+ # refusal while the change was billed as a tightening.
557
+ declares_impact_chain = row[:behavior].split(";").any? do |fragment|
558
+ fragment.match?(/\A\s*behavioral-evidence:/i)
559
+ end
560
+ candidate_paths =
561
+ if declares_impact_chain
562
+ # Name-level resolution: which owner is this row about, independent of
563
+ # whether that owner changed. Changed-ness is asked separately below.
564
+ # `lineage_extra` carries the rename chain's transient names — X renamed to
565
+ # Y, then Y to Z. A row citing Y is a real declaration and the map exists
566
+ # to keep it one, so the vocabulary is "selectable names OR lineage names",
567
+ # the same admission the per-round subject set uses.
568
+ lineage_names = lineage_extra.keys.map { |path| path.sub(%r{/SKILL\.md\z}, "") }
569
+ (resolvable_owner_names | lineage_names)
570
+ .select { |name| owner_mentioned.call(row[:evidence], name) }
571
+ .map { |name| "#{name}/SKILL.md" }.uniq
572
+ else
573
+ # Prior behavior, unchanged: exact SKILL.md citations filtered through the
574
+ # cumulative changed set. The scan captures the slug, so it is mapped back
575
+ # to the owner path the changed set is keyed by.
576
+ row[:evidence].scan(owner_skill_md_path).flatten.uniq
577
+ .map { |slug| "#{slug}/SKILL.md" }
578
+ .select { |path| upstream_set[path] || lineage_extra[path] }
579
+ end
580
+ next if candidate_paths.empty?
581
+ if candidate_paths.length > 1
582
+ # BLOCKING for a declaring row: advisory plus drop is the worst pairing for
583
+ # a row this gate governs — the author sees a warning, the row is never
584
+ # evaluated, and the exit code says the gate passed. Measured over the
585
+ # shipped ledger no declaring row resolves to more than one selected owner,
586
+ # so blocking refuses a shape the register does not use. A non-declaring row
587
+ # keeps the old advisory, reached only through the old predicate above.
588
+ ambiguous_evidence_rows << {
589
+ line: row[:line].sub(/^\+/, "").strip,
590
+ paths: candidate_paths,
591
+ blocking: declares_impact_chain
592
+ }
593
+ next
594
+ end
595
+ path = candidate_paths.first
433
596
  # A lineage name's rows are recognized as declarations, matching the
434
597
  # per-round subject set above — demanding a row the mapping then ignored
435
598
  # would be the same contradiction the excuse used to justify.
436
- upstream_paths_in_row = paths.select { |path| upstream_set[path] || lineage_extra[path] }
437
- if upstream_paths_in_row.length > 1
438
- ambiguous_evidence_rows << { line: row[:line].sub(/^\+/, "").strip, paths: upstream_paths_in_row }
599
+ unless upstream_set[path] || lineage_extra[path]
600
+ # A row that declares nothing is not vouching for a change, so it is not
601
+ # held to this refusal; it simply resolves to nothing this gate governs.
602
+ #
603
+ # There is deliberately NO author-declared escape here. A corrective
604
+ # rewrite that back-fills a row for a round which merged red produces this
605
+ # exact shape, and the diff cannot tell it apart from a reverted owner — but
606
+ # an author-controlled marker would weaken the refusal for everyone in order
607
+ # to smooth a rare, deliberate, user-run repair, and it also cannot exempt
608
+ # one refusal without skipping the row's remaining validation. The repair is
609
+ # already an operation a person adjudicates; it can adjudicate this red too.
610
+ unchanged_owner_rows << { line: row[:line].sub(/^\+/, "").strip, path: path } if declares_impact_chain
611
+ next
612
+ end
613
+ evidence_rows_by_upstream_path[path] += 1
614
+ rows_by_upstream_path[path] << row
615
+ declared_in_round[[row[:scope].base, row[:scope].head, path]] = true
616
+ end
617
+ # A malformed line is blocking only when it is BOTH making this gate's
618
+ # declaration and naming a selected owner — the exact pair that would otherwise
619
+ # buy silence. Everything else stays advisory, because the register carries
620
+ # other tables whose prose can hold a status word and a citation.
621
+ # Malformed lines face the SAME survival budget as parsed rows. Without it a
622
+ # declaring row that was malformed in one round and corrected or removed in a
623
+ # later one would keep blocking at HEAD — the gate would refuse the very repair
624
+ # it asked for. The budget is shared with the parsed rows deliberately: a line
625
+ # is one occurrence whether or not it parsed.
626
+ # Whatever did not become a row is the escape surface, however it failed.
627
+ parsed_line_texts = Hash.new(0)
628
+ rows.each { |row| parsed_line_texts[row[:line].sub(/\A\+/, "").strip] += 1 }
629
+ malformed_rows = added_lines.reject do |entry|
630
+ key = entry[:line].sub(/\A\+/, "").strip
631
+ if parsed_line_texts[key].positive?
632
+ parsed_line_texts[key] -= 1
633
+ true
634
+ else
635
+ false
439
636
  end
440
- if upstream_paths_in_row.length == 1
441
- evidence_rows_by_upstream_path[upstream_paths_in_row.first] += 1
442
- rows_by_upstream_path[upstream_paths_in_row.first] << row
443
- declared_in_round[[row[:scope].base, row[:scope].head, upstream_paths_in_row.first]] = true
637
+ end
638
+ malformed_rows.select! do |entry|
639
+ # The budget is keyed by the row text without the diff marker, the same key
640
+ # the parsed rows use; keeping the marker here would never match and would
641
+ # quietly drop every malformed row back out of the check.
642
+ key = entry[:line].sub(/\A\+/, "").strip
643
+ next false unless row_budget[key].positive?
644
+ row_budget[key] -= 1
645
+ true
646
+ end
647
+ smuggled_malformed = malformed_rows.select do |entry|
648
+ # A malformed row has no trustworthy columns, so the declaration is matched at
649
+ # a fragment boundary in the raw line — start of line, a cell delimiter, or a
650
+ # semicolon — rather than by splitting cells that did not parse.
651
+ entry[:raw].match?(/(?:\A|[|;])\s*behavioral-evidence:/i) &&
652
+ (resolvable_owner_names | lineage_extra.keys.map { |path| path.sub(%r{/SKILL\.md\z}, "") })
653
+ .any? { |name| owner_mentioned.call(entry[:raw], name) }
654
+ end
655
+ unless smuggled_malformed.empty?
656
+ warn "impact_chain_row_blocking_malformed: a row carrying a behavioral-evidence declaration and naming a selected owner did not parse as five columns"
657
+ warn " note: a malformed row is skipped by every later check, so this pair — a real declaration plus a selected owner — cannot be left advisory: it would buy silence for the row"
658
+ warn " fix: escape any literal pipe inside a cell as backslash-pipe so the row parses, then let it face the ordinary checks"
659
+ smuggled_malformed.each { |entry| warn " row: #{entry[:line]}" }
660
+ exit 1
661
+ end
662
+ unless unchanged_owner_rows.empty?
663
+ warn "impact_chain_row_vouches_for_unchanged_owner: an added source-register row declares an upstream owner that this diff does not change"
664
+ warn " note: the row survives at HEAD but the owner package is byte-identical to the base — typically a rebase or a conflict resolved to the base side that reverted the owner while leaving its ledger row behind"
665
+ warn " fix: restore the owner change the row declares, or remove the row; a row must not vouch for a change the delivered diff does not contain"
666
+ warn " note: a paired-control row for an UNCHANGED downstream owner names it WITHOUT a path (`testing-strategy`, not `testing-strategy/SKILL.md`) — the evidence cell's owner key identifies the CHANGED upstream owner this row declares, so a package path there is read as that claim"
667
+ warn " note: if this row back-fills a round that merged with the gate red, the owner change sits below this base and the diff cannot tell that apart from a reverted owner — that repair is adjudicated by a person, who decides whether to accept this red"
668
+ unchanged_owner_rows.each do |entry|
669
+ warn " unchanged owner: #{entry[:path]}"
670
+ warn " row: #{entry[:line]}"
444
671
  end
672
+ exit 1
445
673
  end
446
674
  unless ambiguous_evidence_rows.empty?
447
- warn "impact_chain_row_ambiguous: source-register evidence row cites multiple changed upstream SKILL.md paths; split into one row per changed upstream SKILL.md"
675
+ warn "impact_chain_row_ambiguous: source-register evidence row cites multiple selected upstream owner packages; split into one row per changed upstream SKILL.md"
448
676
  ambiguous_evidence_rows.each do |row|
449
677
  warn " paths: #{row[:paths].join(", ")}"
450
678
  warn " row: #{row[:line]}"
679
+ warn " note: advisory — this row carries no behavioral-evidence declaration, so it is not one of this gate's rows and keeps its prior skip" unless row[:blocking]
451
680
  end
681
+ exit 1 if ambiguous_evidence_rows.any? { |row| row[:blocking] }
452
682
  end
453
683
  # Existence is checked against the ROW'S OWN ROUND HEAD, not the working tree.
454
684
  # A row is authored against one round's diff, so the name it cites only has to
@@ -577,6 +807,16 @@ if upstream.any?
577
807
  # change is accounted for byte-for-byte — one changed file, identical body,
578
808
  # identical frontmatter apart from the single description entry. Every shape the
579
809
  # split cannot fully account for refuses the class.
810
+
811
+ # 「新增行是否是一条规范规则」——与下方锚点判据同一套形状,抽出来供 source-refuted
812
+ # 的纯删除检查复用,避免两处各写一份而漂移。
813
+ normative_rule_line = lambda do |line|
814
+ next false if line.nil? || line.include?("<!--")
815
+ list_rule = line.lstrip.match?(/\A(?:[-*+]\s+|\d+[.)]\s+)/)
816
+ normative = line.match?(/(?:\b(?:must|shall|never|do\s+not|don'?t|required?|requires?|block(?:s|ed)?|reject(?:s|ed)?|deny|denied|invalidates?|forbid(?:s|den)?|cannot|enforcement)\b|必须|不得|禁止|拒绝|作废|仅限|只能|应当|应该|务必|不能|不允许|不可)/i)
817
+ list_rule && normative
818
+ end
819
+
580
820
  routing_surface_cache = {}
581
821
  # The file MODE is part of the entrypoint's identity, exactly as it is for the
582
822
  # package comparison above: bytes alone would let `chmod +x` on SKILL.md ride
@@ -872,7 +1112,7 @@ if upstream.any?
872
1112
  declarations[m[1].downcase] ||= m[2]
873
1113
  end
874
1114
  end
875
- status = declarations["behavioral-evidence"]&.[](/\A(RED-baseline|semantic-control|not-required\s+wording-only|not-required\s+identifier-rename)\z/i, 1)
1115
+ status = declarations["behavioral-evidence"]&.[](/\A(RED-baseline|semantic-control|source-refuted|not-required\s+wording-only|not-required\s+identifier-rename)\z/i, 1)
876
1116
  normalized_status = status&.downcase&.gsub(/\s+/, " ")
877
1117
  declared_wording_only = normalized_status == "not-required wording-only"
878
1118
  wording_only = declared_wording_only && wording_only_diff_for.call(row_scope, path)
@@ -884,7 +1124,124 @@ if upstream.any?
884
1124
  no_behavior_class = wording_only || identifier_rename
885
1125
  observed = declarations["observed-failure"]&.[](/\A(yes|no)\z/i, 1)&.downcase
886
1126
  semantic_control = normalized_status == "semantic-control" && observed == "no"
887
- status_allowed = no_behavior_class || normalized_status == "red-baseline" || semantic_control
1127
+ # `source-refuted` —— 撤回一条被一手源否证的陈述。它不是豁免,是**换一种证据**:
1128
+ # 行为证据在这里造不出来(删掉一句假陈述往往无可测行为差异),而一手源否证
1129
+ # 与义务保全是可核的。四道门槛全部机械可判,缺一即不成立:
1130
+ # (1) observed-failure: no —— 这条闸问的是规则有没有失灵;被撤回的陈述触发
1131
+ # 正常,它只是假的。自选 yes 不得走本类。
1132
+ # (2) 证据列含否证该陈述的一手源 URL。
1133
+ # (3) 证据列含零损失义务对照的可解析指针,且目标文件存在且非空。
1134
+ # (4) 该 owner 包在本行所属轮次的净字节变化 <= 0。撤回会让包缩小;这条挡住
1135
+ # 借事实更正之名夹带新规则。它是**机械下限不是证明**——净删除仍可能在别处
1136
+ # 夹带,那由 dual-track 的零损失审查兜,本闸不假装能替代它。
1137
+ evidence_cell = row[:evidence].to_s
1138
+ refuting_source = evidence_cell.match?(%r{https?://\S+})
1139
+ # 零损失指针:不止文件要在,**锚点必须解析得到**。只检文件存在非空时,
1140
+ # `README.md#不存在的锚` 就能廉价满足——独立挑战实测出的绕过。
1141
+ zero_loss_ptr = evidence_cell[/`([^`]+#[^`]+)`/, 1]
1142
+ zero_loss_ok = begin
1143
+ rel, anchor = zero_loss_ptr.to_s.split("#", 2)
1144
+ rel = rel.to_s.strip; anchor = anchor.to_s.strip
1145
+ # 路径必须是**仓内、被 git 跟踪的常规 blob**,且在本行所属轮次的 head 上存在。
1146
+ # 先前用 File.join(root, rel) + File.file? 判定,`../../仓外文件` 与逃出检出的
1147
+ # 符号链接都能过,File.read 还没有上限——独立评审实测出的绕过。改为只信 git:
1148
+ # 路径不得绝对、不得含 `..`,且必须能在 head 上取到 100644 的 blob。
1149
+ safe_rel = !rel.empty? && !rel.start_with?("/") &&
1150
+ !rel.split("/").include?("..") && !anchor.empty?
1151
+ if safe_rel
1152
+ meta = IO.popen(["git", "-C", root, "ls-tree", "-z", row_scope.head, "--", rel],
1153
+ err: File::NULL, &:read)
1154
+ entry = $?.success? ? meta.split("\0").reject(&:empty?).first : nil
1155
+ mode, type, = entry.to_s.split(/\s+/, 3)
1156
+ if mode == "100644" && type == "blob" && rel.end_with?(".md")
1157
+ body = IO.popen(["git", "-C", root, "cat-file", "blob", "#{row_scope.head}:#{rel}"],
1158
+ err: File::NULL) { |io| io.read(512 * 1024) } # 有界读
1159
+ if $?.success? && body
1160
+ body.force_encoding("UTF-8")
1161
+ # 锚点必须落在**标题**上。先前允许任意子串命中,于是 `README.md#a`
1162
+ # 只要文件里有字母 a 就过——独立挑战实测出的绕过。子串回退整条删除。
1163
+ slug = lambda { |t| t.downcase.gsub(/[^\p{Word}\- ]/, "").strip.gsub(/\s+/, "-") }
1164
+ want = slug.call(anchor)
1165
+ body.each_line.any? do |ln|
1166
+ next false unless ln.start_with?("#")
1167
+ head_text = ln.sub(/\A#+\s*/, "").strip
1168
+ head_text == anchor || slug.call(head_text) == want
1169
+ end
1170
+ else
1171
+ false
1172
+ end
1173
+ else
1174
+ false
1175
+ end
1176
+ else
1177
+ false
1178
+ end
1179
+ end
1180
+ # 撤回必须是**纯删除**,而"纯删除"有精确的机械定义——先前那版用「不得新增
1181
+ # 规范规则行」去近似它,是**代理不是不变量**:脚本改动、非列表格式的行为指令、
1182
+ # 以 `Always` 开头的规则(该谓词故意排除 always)全都不匹配,照过。两轮独立
1183
+ # 评审各自实测出这条绕过。改为直接分类整个 owner diff:
1184
+ # · 每个变更路径都是 skills/<owner>/ 下的常规 .md(脚本、模板、二进制一律不合格)
1185
+ # · 每个路径的状态都是 M(新增/删除/改名/复制/改权限一律不合格)
1186
+ # · 全 diff 新增行数 == 0,删除行数 > 0
1187
+ pure_deletion_owner_diff = lambda do
1188
+ # `--raw` 而不是 `--name-status`:后者把 chmod 也显示为 M、且 numstat 记 0/0,
1189
+ # 于是「改权限 + 一次真删除」能整体通过——独立评审实测出的绕过。raw 带出
1190
+ # 新旧两侧的 mode,可以直接要求两侧都是常规 blob 且 mode 一致。
1191
+ raw = IO.popen(["git", "-C", root, "diff", "--no-renames", "--raw", "-z",
1192
+ row_scope.base, row_scope.head, "--", "skills/#{owner}/"],
1193
+ err: File::NULL, &:read)
1194
+ return false unless $?.success?
1195
+ fields = raw.split("\0").reject(&:empty?)
1196
+ return false if fields.empty?
1197
+ i = 0
1198
+ while i < fields.length
1199
+ meta = fields[i]
1200
+ return false unless meta.start_with?(":")
1201
+ parts = meta[1..].split(/\s+/)
1202
+ old_mode, new_mode, _old_sha, _new_sha, status = parts
1203
+ path = fields[i + 1]
1204
+ return false if path.nil? || status.nil?
1205
+ return false unless status == "M"
1206
+ return false unless old_mode == "100644" && new_mode == "100644"
1207
+ return false unless path.end_with?(".md")
1208
+ i += 2
1209
+ end
1210
+ stat = IO.popen(["git", "-C", root, "diff", "--no-renames", "--numstat",
1211
+ row_scope.base, row_scope.head, "--", "skills/#{owner}/"],
1212
+ err: File::NULL, &:read)
1213
+ return false unless $?.success?
1214
+ added = deleted = 0
1215
+ stat.each_line do |ln|
1216
+ a, d, _ = ln.split("\t", 3)
1217
+ return false unless a =~ /\A\d+\z/ && d =~ /\A\d+\z/
1218
+ added += a.to_i; deleted += d.to_i
1219
+ end
1220
+ added.zero? && deleted.positive?
1221
+ end
1222
+ # 把指针**绑到撤回本身**,而不是只验它形式合规:本轮删掉的每一行有实质内容的
1223
+ # 文本,都必须逐字出现在零损失对照文件里。前六版门槛全部被独立评审击穿,共同点
1224
+ # 是它们只验「这个指针看起来合规」,从不验「它交代的正是被删掉的东西」。删了什么
1225
+ # 就必须抄出来——攻击者要删一条真实义务,就得把它原样写进对照表,藏不住。
1226
+ deleted_lines_accounted = lambda do
1227
+ rel = zero_loss_ptr.to_s.split("#", 2).first.to_s.strip
1228
+ map_body = IO.popen(["git", "-C", root, "cat-file", "blob", "#{row_scope.head}:#{rel}"],
1229
+ err: File::NULL) { |io| io.read(512 * 1024) }
1230
+ return false unless $?.success? && map_body
1231
+ map_body.force_encoding("UTF-8")
1232
+ diff = IO.popen(["git", "-C", root, "diff", "--no-renames", "-U0",
1233
+ row_scope.base, row_scope.head, "--", "skills/#{owner}/"],
1234
+ err: File::NULL, &:read)
1235
+ return false unless $?.success?
1236
+ removed = diff.each_line.select { |ln| ln.start_with?("-") && !ln.start_with?("---") }
1237
+ .map { |ln| ln[1..].to_s.strip }
1238
+ .reject { |ln| ln.empty? || ln.length < 12 }
1239
+ return false if removed.empty?
1240
+ removed.all? { |ln| map_body.include?(ln) }
1241
+ end
1242
+ source_refuted = normalized_status == "source-refuted" && observed == "no" &&
1243
+ refuting_source && zero_loss_ok && pure_deletion_owner_diff.call && deleted_lines_accounted.call
1244
+ status_allowed = no_behavior_class || normalized_status == "red-baseline" || semantic_control || source_refuted
888
1245
  firing_path = declarations["firing-path"]&.[](/\A((?:command|file):.+)\z/i, 1)&.strip
889
1246
  firing_parts = locator_parts.call(firing_path)
890
1247
  firing_path_valid = firing_locator_valid.call(row_scope, firing_parts, owner)
@@ -897,7 +1254,10 @@ if upstream.any?
897
1254
  # regeneration cost (full-suite reruns on every owner-script byte change)
898
1255
  # far outweighed the staleness detection it added under the
899
1256
  # unsigned-repository-local trust model.
900
- row_valid = if no_behavior_class
1257
+ row_valid = if source_refuted
1258
+ # 纯删除没有新增的规范行可供锚定,与两个 no-behavior 类同理免 firing-path。
1259
+ true
1260
+ elsif no_behavior_class
901
1261
  # Neither `not-required` class is an author-controlled waiver: each is
902
1262
  # accepted only when the owner diff itself passes the matching
903
1263
  # deterministic classifier — letters/digits-free for wording-only,
@@ -912,6 +1272,7 @@ if upstream.any?
912
1272
  end
913
1273
  {
914
1274
  red_declared: normalized_status == "red-baseline",
1275
+ source_refuted: source_refuted,
915
1276
  row_valid: row_valid,
916
1277
  firing_path_valid: firing_path_valid
917
1278
  }
@@ -931,6 +1292,22 @@ if upstream.any?
931
1292
  # that happened to be punctuation-only. Cumulative is the strictly stricter
932
1293
  # reading of the two, so the round scoping cannot loosen this floor.
933
1294
  valid = evaluated.all? { |entry| entry[:row_valid] }
1295
+ # `source-refuted` 与两个 no-behavior 类一样可以顶起这道底线,理由同构:底线存在
1296
+ # 是因为 semantic-control 标签只能为某一个具名行为背书、包里其余部分无人担保。
1297
+ # 而一次经四道门槛核过的撤回**没有未担保的余量**——它净删除、义务对照可查、
1298
+ # 一手源可查。对它坚持「必须有一条观察到的行为差异」只会逼人去发明一个。
1299
+ # 撤回类顶起底线时必须是**整包**的性质,不能是包里某一行的性质:一条合规撤回
1300
+ # 与一条搭便车的 semantic-control 行并存时,`any?` 会把整个 owner 放行——独立
1301
+ # 评审实测出的洞。bar 4(本轮不得新增规范规则行)已挡住带规范动词的搭车,这里
1302
+ # 再要求该 owner 的**全部**非 no-behavior 行都是撤回类,连非规范措辞的搭车一并挡掉。
1303
+ # `source-refuted` **不顶起这道底线**。七轮独立评审逐一击穿了保护它的每一版门槛
1304
+ # (净字节、规范行启发式、name-status、文件存在、子串锚、形式合规),两条通道两次
1305
+ # 给出同一条建议:在指针能被绑到被撤回的那条义务之前,不要让它清掉 RED 底线。
1306
+ # 剩下的缺口本质不可机械验证——指针可以指向真实标题、逐字抄录被删文本,而
1307
+ # 「这个一手源确实否证了那条陈述」仍然只有人能判。按本仓「同类跨轮复现是设计
1308
+ # 信号不是补丁信号」,这里删掉的是那个能力本身,不是再补一道门槛。
1309
+ # 该类保留的作用:让标签诚实、强制产出零损失义务对照、并把诊断指向正确的补法。
1310
+ # 撤回仍需一条 RED 行,或由**具名风险 owner** 经既有通道人工放行。
934
1311
  valid &&= evaluated.any? { |entry| entry[:red_declared] } ||
935
1312
  wording_only_diff_for.call(cumulative, path) ||
936
1313
  identifier_rename_diff_for.call(cumulative, path)
@@ -957,7 +1334,7 @@ if upstream.any?
957
1334
  end
958
1335
  unless behavior_failures.empty?
959
1336
  warn "impact_chain_behavior_evidence_missing: at least one added upstream-owner row lacks a complete behavioral-evidence declaration"
960
- warn " fix: every added row for a changed upstream owner needs `behavioral-evidence: RED-baseline` (observed deltas; observed-failure: yes requires it) or `semantic-control` (only with observed-failure: no), plus `observed-failure: yes/no` and an owner-scoped `firing-path:`; a non-wording owner package needs at least one RED-baseline row; only deterministically wording-only diffs (no letters or digits changed) may use `not-required wording-only`, and only diffs whose base bytes are reproduced exactly by applying git-derived skill-rename pairs may use `not-required identifier-rename` (both drop the firing-path requirement and require observed-failure: no); an owner whose ENTIRE change is the SKILL.md frontmatter description entry keeps the RED-baseline bar and may anchor its firing path on that changed description line"
1337
+ warn " fix: every added row for a changed upstream owner needs `behavioral-evidence: RED-baseline` (observed deltas; observed-failure: yes requires it) `semantic-control` (only with observed-failure: no), or `source-refuted` (a pure-deletion withdrawal of a claim a primary source refutes: observed-failure no, a refuting source URL, a resolvable zero-loss pointer, the owner's round diff touches only modified regular Markdown files whose file mode is unchanged, adds zero lines and deletes at least one, and every row for that owner is in the same class) — note that `source-refuted` records an honest label and forces a zero-loss map, but does NOT by itself lift the per-owner RED floor: a withdrawal still needs a RED-baseline row or a named risk owner's waiver, plus `observed-failure: yes/no` and an owner-scoped `firing-path:`; a non-wording owner package needs at least one RED-baseline row; only deterministically wording-only diffs (no letters or digits changed) may use `not-required wording-only`, and only diffs whose base bytes are reproduced exactly by applying git-derived skill-rename pairs may use `not-required identifier-rename` (both drop the firing-path requirement and require observed-failure: no); an owner whose ENTIRE change is the SKILL.md frontmatter description entry keeps the RED-baseline bar and may anchor its firing path on that changed description line"
961
1338
  behavior_failures.each { |path| warn " incomplete: #{path}" }
962
1339
  exit 1
963
1340
  end
@@ -419,6 +419,37 @@ end
419
419
  unresolved = []
420
420
  malformed = []
421
421
  locator_count = 0
422
+ # Advisory only. A `file:` locator whose target is an EXECUTABLE artifact asserts
423
+ # firing evidence that some runner reaches; when no entry point outside specs/ names
424
+ # that file, the anchor still resolves (the text is there) while nothing can run it.
425
+ # Observed 2026-08: ten locators across nine ledger rows anchored into an executable
426
+ # nothing runs, green
427
+ # for the whole life of the retirement. Prose anchors (.md) are a different class --
428
+ # they are read, not run -- and are deliberately NOT flagged.
429
+ unrunnable = []
430
+ skipped_runners = []
431
+ # Built once, lazily: a repo with no executable locators never pays for it, and a
432
+ # repo with many does not re-walk the tree per locator (O(locators x files)).
433
+ runner_corpus = nil
434
+ build_runner_corpus = lambda do |root|
435
+ (Dir.glob(File.join(root, "**", "*.{sh,py,rb,yml,yaml}"), File::FNM_DOTMATCH) +
436
+ Dir.glob(File.join(root, "**", "{Makefile,makefile}"), File::FNM_DOTMATCH)).reject do |cand|
437
+ cand.start_with?(File.join(root, "specs") + File::SEPARATOR) ||
438
+ cand.start_with?(File.join(root, ".git") + File::SEPARATOR) ||
439
+ !File.file?(cand)
440
+ end.filter_map do |cand|
441
+ # An advisory must never change the exit status, so an unreadable candidate is
442
+ # skipped rather than raised: one chmod-000 file in the tree previously took
443
+ # the whole gate red with Errno::EACCES. Skipping biases toward silence on
444
+ # that candidate, which is the safe direction for a non-blocking report.
445
+ begin
446
+ [cand, File.read(cand, encoding: "UTF-8", invalid: :replace)]
447
+ rescue SystemCallError, IOError
448
+ skipped_runners << cand
449
+ nil
450
+ end
451
+ end
452
+ end
422
453
  # Where each EXEMPT locator was actually cited. A waiver is written for ONE
423
454
  # historical row that can no longer be repaired; a second row quoting the same
424
455
  # retired locator is a new claim, not that row, and must not inherit the waiver.
@@ -513,6 +544,28 @@ File.foreach(register_path).with_index(1) do |line, lineno|
513
544
  unresolved << [lineno, locator, "file not found"]
514
545
  next
515
546
  end
547
+ if %w[.rb .sh .py].include?(File.extname(rel)) && !anchor_waived
548
+ # Match the basename WITH its extension at a non-word boundary. The
549
+ # stripped stem matched anywhere was trivially satisfiable: `run.sh`
550
+ # became `run`, which any `run_tests` identifier or prose mention
551
+ # cleared, so a decayed anchor read as reachable and this stayed quiet.
552
+ # Reachability is either a literal mention of the file, or a runner
553
+ # that sweeps its directory with a wildcard -- `for t in dir/*.sh` names
554
+ # no basename yet fires every file there. Missing the second shape is a
555
+ # FALSE ALARM on a live locator, which the acceptance bars, so both count.
556
+ needle = /(?<![\w.-])#{Regexp.escape(File.basename(rel))}(?![\w-])/
557
+ dirn = File.dirname(rel)
558
+ sweep = /#{Regexp.escape(dirn)}\/[^\s"']*[*?]/
559
+ # FNM_DOTMATCH is required, not cosmetic: the yml/yaml shapes this glob
560
+ # seeks live in .github/workflows, and without it the glob never
561
+ # descends there -- a target fired only by CI read as unrunnable.
562
+ runner_corpus ||= build_runner_corpus.call(root)
563
+ reachable = runner_corpus.any? do |cand, body|
564
+ next false if File.identical?(cand, target)
565
+ body.match?(needle) || body.match?(sweep)
566
+ end
567
+ unrunnable << [lineno, locator, rel] unless reachable
568
+ end
516
569
  if !resolves_inside?(root, rel)
517
570
  unresolved << [lineno, locator, "path escapes the repository"]
518
571
  next
@@ -691,6 +744,27 @@ unless unresolved.empty?
691
744
  end
692
745
  end
693
746
 
747
+ unless unrunnable.empty?
748
+ warn "register_firing_path_unrunnable_target: ADVISORY -- an anchored executable has no runner"
749
+ warn " cause: the anchor resolves (its text is present) but no entry point outside specs/"
750
+ warn " names that file, so nothing can execute what the row claims fires."
751
+ warn " fix: point the row at a live command, or supersede it if the mechanism retired."
752
+ unrunnable.each do |lineno, locator, rel|
753
+ warn " #{REGISTER}:#{lineno}: #{rel}"
754
+ anchor = locator.split("#", 2)[1]
755
+ warn " anchor: #{anchor}" if anchor && !anchor.empty?
756
+ end
757
+ warn " covered runner shapes: *.{sh,py,rb,yml,yaml} and **/{Makefile,makefile} outside specs/ and .git/,"
758
+ warn " matched by literal basename or by a wildcard sweep over the target's directory. NOT covered:"
759
+ warn " Rakefiles, extensionless bin scripts, and `find <dir> -name` style invocation — a target fired"
760
+ warn " only through those reads as unrunnable here."
761
+ unless skipped_runners.empty?
762
+ warn " #{skipped_runners.size} candidate(s) were unreadable and skipped; if a named row is fired only by"
763
+ warn " one of those, this report is a false alarm rather than a decayed anchor."
764
+ end
765
+ warn " advisory only: this never changes the exit status."
766
+ end
767
+
694
768
  exit 1 unless malformed.empty? && unresolved.empty?
695
769
 
696
770
  # NO zero-locator self-check here, deliberately. Two rounds of it each shipped a