fix(gates): report an unparsed routing clause beside a parsing sibling
boundary_clause_status() ran BOUNDARY_ARROW.search() and _arrow_targets()
over the whole description, so one arrow clause that parsed suppressed the
diagnostic for every other clause in it. A backticked hyphenated routing
target wrapped across lines in a folded scalar was therefore silently
unchecked -- no error, no suggestion, exit 0 -- whenever the description
carried one other clause that parsed. Written bare, the same wrap errors
correctly. That is the shape #100 regressed on.
The check is now per clause. Nothing that passed starts failing: all 68
routing targets across the 38 SKILL.md files resolved before and still do.
26 of those descriptions carry more than one arrow clause, so the
suppression was live across two thirds of the corpus, not an edge case.
validate-skill.bats pins the shape. test-adr0020-targets.sh's comment
described the #100 regression as a backticked wrap; the historical text was
unbackticked, which is precisely the shape the gate did not catch.
Also closes three README misroutes the branch left in the enforcement
layer: CompositionNote.yml's message, agent-description-quality.md:58 and
vale-wrap.sh's header still sent overflow to a skill-root README.md and
named the two skills ADR-0025 merged away. 1ec3e8a fixed the prose and
missed the rules that enforce it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NwD8Egs5r4ndqeFLmhusX2
This commit is contained in:
@@ -732,8 +732,41 @@ def boundary_targets(description):
|
||||
return sorted({name for name, _, _ in _extract(description)})
|
||||
|
||||
|
||||
def _arrow_targets(description):
|
||||
"""Names extracted from ARROW notation specifically.
|
||||
def _clause_end(description, pos):
|
||||
"""Where CLAUSE_BODY stops scanning forward from `pos`.
|
||||
|
||||
The same two stops the class itself encodes: a `;`, or a `.` that is not
|
||||
followed by a non-space character (a sentence end rather than a dot inside
|
||||
`AGENTS.md`).
|
||||
"""
|
||||
for index in range(pos, len(description)):
|
||||
char = description[index]
|
||||
if char == ';':
|
||||
return index
|
||||
if char == '.' and not description[index + 1:index + 2].strip():
|
||||
return index
|
||||
return len(description)
|
||||
|
||||
|
||||
def _arrow_clause_spans(description):
|
||||
"""(start, end) for EACH ADR-0020 arrow clause, one span per clause.
|
||||
|
||||
A clause runs from its `Not` to whichever comes first: the start of the
|
||||
NEXT arrow clause, or the end of the clause body. Bounding on the next
|
||||
clause is what keeps two clauses joined by a comma inside one sentence
|
||||
apart — a sentence-scoped span would merge them and let the second clause's
|
||||
target vouch for the first.
|
||||
"""
|
||||
starts = [match.start() for match in BOUNDARY_ARROW.finditer(description)]
|
||||
spans = []
|
||||
for index, start in enumerate(starts):
|
||||
limit = starts[index + 1] if index + 1 < len(starts) else len(description)
|
||||
spans.append((start, min(limit, _clause_end(description, start))))
|
||||
return spans
|
||||
|
||||
|
||||
def _arrow_clause_parses(clause):
|
||||
"""True when either arrow extractor reads a target out of ONE clause.
|
||||
|
||||
Kept apart from boundary_targets() because the arrow form is the one shape
|
||||
that ALWAYS names a target: ADR-0020's `Not <thing> -> <name>`. A clause
|
||||
@@ -741,15 +774,11 @@ def _arrow_targets(description):
|
||||
that deserves its own message, and telling it apart needs the arrow targets
|
||||
alone rather than every target in the description.
|
||||
"""
|
||||
out = []
|
||||
for sentence in SENTENCE_SPLIT.split(description):
|
||||
for match in ARROW_MARKED.finditer(sentence):
|
||||
name, _, _ = _first(match)
|
||||
if name:
|
||||
out.append(name)
|
||||
for match in ARROW_BOUNDARY.finditer(sentence):
|
||||
out.append(match.group(1))
|
||||
return out
|
||||
for match in ARROW_MARKED.finditer(clause):
|
||||
name, _, _ = _first(match)
|
||||
if name:
|
||||
return True
|
||||
return bool(ARROW_BOUNDARY.search(clause))
|
||||
|
||||
|
||||
def boundary_clause_status(description):
|
||||
@@ -761,16 +790,28 @@ def boundary_clause_status(description):
|
||||
three of them reworded a correct clause to satisfy a regex instead.
|
||||
|
||||
'unparsed' is the narrow, certain case: an ADR-0020 arrow clause was
|
||||
detected and NO target came out of it. The arrow form always names one, so
|
||||
detected and NO target came out of IT. The arrow form always names one, so
|
||||
zero targets means the name is written in a shape the extractor cannot see
|
||||
— a single-word bare target (`Not X -> forge`, which has to be written
|
||||
`` `forge` `` or `/forge`) is the live example, since single-word names are
|
||||
deliberately not matchable bare.
|
||||
|
||||
The test is PER CLAUSE, and that is the whole point of the span walk. Both
|
||||
operands used to take the whole description, so ONE arrow clause that
|
||||
parsed suppressed the diagnostic for every other clause beside it: a
|
||||
backticked hyphenated target wrapped across a line break inside a `>`
|
||||
folded scalar — `` `git-`` / ``commits` `` — went unchecked with no ERROR
|
||||
and no SUGGESTION, while the same wrap written bare was reported correctly.
|
||||
26 of this corpus's 38 skill descriptions carry more than one arrow clause,
|
||||
so the suppression covered most of it. This is the issue #100 regression
|
||||
class, and a whole-description test cannot see it by construction.
|
||||
|
||||
A PROSE clause yielding no target is NOT reported: "Do not use for anything
|
||||
else" is a complete and legitimate boundary clause that names nowhere to go.
|
||||
"""
|
||||
if BOUNDARY_ARROW.search(description) and not _arrow_targets(description):
|
||||
spans = _arrow_clause_spans(description)
|
||||
if spans and not all(_arrow_clause_parses(description[start:end])
|
||||
for start, end in spans):
|
||||
return 'unparsed'
|
||||
if has_boundary_clause(description):
|
||||
return 'present'
|
||||
|
||||
Reference in New Issue
Block a user