@ccoalm/ccl-skills 0.1.0 → 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.
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_init_policy_matrix.sh +93 -16
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_parse_probe_result.sh +10 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_review_gate.sh +249 -5
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_review_gate_abort_leak.sh +394 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-architecture/references/architecture-playbook.md +2 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-architecture/references/cross-cutting-concerns.md +6 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/llm-inference-integration/SKILL.md +3 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/llm-inference-integration/references/agent-capability-composition.md +128 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/llm-inference-integration/references/agent-command-sandbox.md +35 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/llm-inference-integration/references/agent-session-persistence.md +54 -2
- package/dist/assets/marketplace/plugins/ccl-skills/skills/llm-inference-integration/references/agent-tool-dispatch.md +11 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/llm-inference-integration/references/model-prompt-evaluation.md +7 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-release-engineering/references/canary-and-rollout-strategy.md +6 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/external-ui-ux-quality-benchmarks.md +50 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/ui-ux-audit.md +1 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/architecture-playbook.md +1 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/packaging-runtime-readiness.md +6 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/SKILL.md +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/external-practice-controls.md +59 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/harness-patterns-and-eval.md +1 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/rule-consolidation.md +3 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/source-register.md +43 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/source-to-skill-extraction.md +16 -4
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/impact-chain-gate.rb +391 -14
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/register-firing-path-resolution.rb +74 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_regressions.sh +74 -33
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_skill_catalog.sh +11 -4
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_impact_chain_gate_verdict_differential.sh +421 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_impact_chain_round_attribution.sh +576 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_impact_chain_source_refuted.sh +176 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_regression_runner_lanes.sh +101 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_validate_skill_root_depth.sh +6 -2
- package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/SKILL.md +2 -2
- package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/references/ci-fixtures-and-flake-control.md +19 -2
- package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/references/e2e-real-flow-testing.md +1 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/references/test-code-authoring-patterns.md +3 -3
- package/dist/assets/release.json +63 -33
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|