fix(gates): block a dangling /name route with no preceding verb
The header promised explicit route notation always blocks. It did not: /name
reached extraction only behind a ROUTE_VERB, so a target with no verb before it
was never extracted at all -- exit 0, no output. Taking the SUGGESTION's own
advice ('write it as /name and it will be checked properly') was the one edit
that blinded the gate.
Adds two notation sweeps gated on BOUNDARY_MARKER and routed through _add, plus
a path guard so file paths and URLs are not read as routes. Also excises the
matched pointer span before the REFERENCE_PAST sweep, so a reference file can no
longer exempt itself by its own filename, and guards the agent branch with the
isfile test the skills branch already had.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJJrm5YmacbwMdzZpXcoti
This commit is contained in:
@@ -264,6 +264,16 @@ def _collect_package(pkg_dir, names):
|
|||||||
names.add(os.path.basename(path.rstrip('/')).lower())
|
names.add(os.path.basename(path.rstrip('/')).lower())
|
||||||
for sub in ('.apm/agents/*.md', 'agents/*.md'):
|
for sub in ('.apm/agents/*.md', 'agents/*.md'):
|
||||||
for path in glob.glob(os.path.join(safe_dir, sub)):
|
for path in glob.glob(os.path.join(safe_dir, sub)):
|
||||||
|
# The same rule one directory over, which until now had no
|
||||||
|
# counterpart here at all: the skills branch above tests for a
|
||||||
|
# SKILL.md, the agents branch took every glob hit on trust. A
|
||||||
|
# DIRECTORY named `ghost-agent.md` matches `*.md` and glob does not
|
||||||
|
# tell the two apart, so a leftover of that shape resolved a routing
|
||||||
|
# target on the machine holding it and dangled everywhere else —
|
||||||
|
# identical install-dependence, arriving through the one door
|
||||||
|
# nobody guarded.
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
continue
|
||||||
base = os.path.basename(path)
|
base = os.path.basename(path)
|
||||||
if base.endswith('.agent.md'):
|
if base.endswith('.agent.md'):
|
||||||
base = base[:-len('.agent.md')]
|
base = base[:-len('.agent.md')]
|
||||||
@@ -542,6 +552,34 @@ def known_targets(start_dir):
|
|||||||
# ambiguity to resolve, and an author who wants a route checked unconditionally
|
# ambiguity to resolve, and an author who wants a route checked unconditionally
|
||||||
# has two ways to say so.
|
# has two ways to say so.
|
||||||
#
|
#
|
||||||
|
# BOTH FORMS ARE SWEPT FOR ON THEIR OWN inside a boundary sentence, and that is
|
||||||
|
# a repair of the promise above rather than a widening of it. Until the sweeps
|
||||||
|
# existed, notation was only ever seen as the OBJECT OF A ROUTE VERB (`use
|
||||||
|
# /name`) or as the tail of a `not ... ->` clause with no `;` or sentence end in
|
||||||
|
# between. Every one of these therefore exited 0 in total silence — no ERROR, no
|
||||||
|
# SUGGESTION, not even the target's name:
|
||||||
|
# Do not use for Y — /no-such-skill instead.
|
||||||
|
# Do not use for Y; /no-such-skill handles that.
|
||||||
|
# Do not use for Y (/no-such-skill covers it).
|
||||||
|
# Do not use for Y — that is /no-such-skill's job.
|
||||||
|
# Do not use for Y — defer to /no-such-skill.
|
||||||
|
# Do not use for Y — /no-such-skill.
|
||||||
|
# Do not use for Y; -> no-such-skill covers it.
|
||||||
|
# The target was never EXTRACTED, so the notation-first rule in _add() had
|
||||||
|
# nothing to apply itself to and the "always blocks" promise was false for the
|
||||||
|
# ordinary way an author writes the thing. The SUGGESTION tier made it worse
|
||||||
|
# than a gap: its printed remedy tells the author to "write it as `/name` or
|
||||||
|
# `-> name` and it will be checked properly", and taking that advice turned a
|
||||||
|
# visible SUGGESTION into silence — the gate teaching the one edit that blinds
|
||||||
|
# it.
|
||||||
|
#
|
||||||
|
# The sweeps are gated on the sentence carrying a BOUNDARY_MARKER, the same gate
|
||||||
|
# the backtick sweep uses, and NOTATION_SLASH refuses a token that is part of a
|
||||||
|
# PATH: a following `/`, or a `.` followed by a non-space, means
|
||||||
|
# `references/foo.md`, `docs/a/b.md` or `https://x/y`, not a route. A sentence's
|
||||||
|
# closing `.` is not followed by a non-space, so `— /no-such-skill.` still
|
||||||
|
# counts.
|
||||||
|
#
|
||||||
# NAMESPACE: `plugin:skill` is live in this repo (native user-scope installs
|
# NAMESPACE: `plugin:skill` is live in this repo (native user-scope installs
|
||||||
# still resolve `gitea:gitea-prs`), so the patterns admit an optional
|
# still resolve `gitea:gitea-prs`), so the patterns admit an optional
|
||||||
# `<plugin>:` prefix and normalize_target() strips it before resolution.
|
# `<plugin>:` prefix and normalize_target() strips it before resolution.
|
||||||
@@ -565,6 +603,23 @@ ROUTE_ANY = re.compile(r"\b%s\s+(?:the\s+|an?\s+)?%s" % (ROUTE_VERB, ANY_TARGET)
|
|||||||
CONT_MARKED = re.compile(r"\s*(?:or|and|/|,)\s*%s" % MARKED_TARGET, re.I)
|
CONT_MARKED = re.compile(r"\s*(?:or|and|/|,)\s*%s" % MARKED_TARGET, re.I)
|
||||||
CONT_ANY = re.compile(r"\s*(?:or|and|/|,)\s*%s" % ANY_TARGET, re.I)
|
CONT_ANY = re.compile(r"\s*(?:or|and|/|,)\s*%s" % ANY_TARGET, re.I)
|
||||||
ARROW_MARKED = re.compile(r"(?:->|→)\s*%s" % MARKED_TARGET, re.I)
|
ARROW_MARKED = re.compile(r"(?:->|→)\s*%s" % MARKED_TARGET, re.I)
|
||||||
|
# The two EXPLICIT ROUTE NOTATION sweeps, scoped to a boundary sentence by their
|
||||||
|
# caller. NOTATION_SLASH is deliberately not a reuse of MARKED_TARGET's `/name`
|
||||||
|
# alternative: that one only ever runs behind a route verb or an arrow, and the
|
||||||
|
# trailing lookahead here is the part that makes a FREE-STANDING sweep safe.
|
||||||
|
# NOTATION_ARROW is ARROW_BOUNDARY minus its leading `\bnot\b%s*?`, which is
|
||||||
|
# what made `Do not use for Y; -> no-such-skill covers it.` invisible:
|
||||||
|
# CLAUSE_BODY cannot cross the `;`, so the clause's own punctuation disarmed the
|
||||||
|
# check. Dropping that prefix costs the one false positive the bare-arrow bullet
|
||||||
|
# above names — a process chain ending in a hyphenated word, `Instead, reproduce
|
||||||
|
# -> minimise -> regression-test.` — and costs it only in a sentence that already
|
||||||
|
# carries a BOUNDARY_MARKER. That exposure is neither new nor larger: the same
|
||||||
|
# chain written `Do not use for X — reproduce -> regression-test.` was already a
|
||||||
|
# hard ERROR under ARROW_BOUNDARY, so this changes which boundary words reach the
|
||||||
|
# arrow, not whether prose can. An author who means the chain and not a route
|
||||||
|
# writes it in its own sentence, where neither pattern looks.
|
||||||
|
NOTATION_SLASH = re.compile(r"(?<![\w./*-])/(%s)\b(?!/|\.\S)" % NAME_ANY, re.I)
|
||||||
|
NOTATION_ARROW = re.compile(r"(?:->|→)\s*(%s)\b" % NAME_HYPH, re.I)
|
||||||
# CLAUSE_BODY is what may sit between `Not` and the arrow, and it is NOT
|
# CLAUSE_BODY is what may sit between `Not` and the arrow, and it is NOT
|
||||||
# `[^.;]`. That class cannot cross a `.`, so every boundary clause naming a
|
# `[^.;]`. That class cannot cross a `.`, so every boundary clause naming a
|
||||||
# DOTTED FILENAME between the two — `.pre-commit-config.yaml`, `AGENTS.md`,
|
# DOTTED FILENAME between the two — `.pre-commit-config.yaml`, `AGENTS.md`,
|
||||||
@@ -740,6 +795,16 @@ def _extract_sentence(sentence):
|
|||||||
_add(out, sentence, match.group(1), match.start(1), match.end(1),
|
_add(out, sentence, match.group(1), match.start(1), match.end(1),
|
||||||
strict=True, arrow=True)
|
strict=True, arrow=True)
|
||||||
if boundary:
|
if boundary:
|
||||||
|
# Route notation wherever it sits in the clause, not only where a route
|
||||||
|
# verb or an arrow happens to precede it. See the EXPLICIT ROUTE
|
||||||
|
# NOTATION note in the header for the seven phrasings this recovers and
|
||||||
|
# for why silence was the failure mode. Neither sweep takes the follower
|
||||||
|
# test: _add() reads the notation first and both forms reach it marked.
|
||||||
|
for match in NOTATION_SLASH.finditer(sentence):
|
||||||
|
_add(out, sentence, match.group(1), match.start(1), match.end(1))
|
||||||
|
for match in NOTATION_ARROW.finditer(sentence):
|
||||||
|
_add(out, sentence, match.group(1), match.start(1), match.end(1),
|
||||||
|
strict=True, arrow=True)
|
||||||
for match in BACKTICK.finditer(sentence):
|
for match in BACKTICK.finditer(sentence):
|
||||||
_add(out, sentence, match.group(1), match.start(1), match.end(1))
|
_add(out, sentence, match.group(1), match.start(1), match.end(1))
|
||||||
return out
|
return out
|
||||||
@@ -1110,7 +1175,15 @@ def missing_reference_pointers(body, skill_dir):
|
|||||||
end = masked.find('\n', match.end())
|
end = masked.find('\n', match.end())
|
||||||
if end < 0:
|
if end < 0:
|
||||||
end = len(masked)
|
end = len(masked)
|
||||||
if REFERENCE_PAST.search(masked[start:end]):
|
# The pointer's OWN SPAN is excised before the sweep. Run over the
|
||||||
|
# whole line, the past-tense test matched the very path it was judging,
|
||||||
|
# so a file exempted itself by its NAME: `references/deprecated-api.md`,
|
||||||
|
# `references/removed-flags.md` and `references/gone.md` produced no
|
||||||
|
# ERROR at all, while `references/missing.md` — an identical break —
|
||||||
|
# errored. The exemption is about what the SENTENCE says about the
|
||||||
|
# pointer, never about what the pointer is called.
|
||||||
|
line = masked[start:match.start()] + masked[match.end():end]
|
||||||
|
if REFERENCE_PAST.search(line):
|
||||||
continue
|
continue
|
||||||
if REFERENCE_QUALIFIER.search(masked[start:match.start()]):
|
if REFERENCE_QUALIFIER.search(masked[start:match.start()]):
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -190,6 +190,16 @@ def _collect_package(pkg_dir, names):
|
|||||||
names.add(os.path.basename(path.rstrip('/')).lower())
|
names.add(os.path.basename(path.rstrip('/')).lower())
|
||||||
for sub in ('.apm/agents/*.md', 'agents/*.md'):
|
for sub in ('.apm/agents/*.md', 'agents/*.md'):
|
||||||
for path in glob.glob(os.path.join(safe_dir, sub)):
|
for path in glob.glob(os.path.join(safe_dir, sub)):
|
||||||
|
# The same rule one directory over, which until now had no
|
||||||
|
# counterpart here at all: the skills branch above tests for a
|
||||||
|
# SKILL.md, the agents branch took every glob hit on trust. A
|
||||||
|
# DIRECTORY named `ghost-agent.md` matches `*.md` and glob does not
|
||||||
|
# tell the two apart, so a leftover of that shape resolved a routing
|
||||||
|
# target on the machine holding it and dangled everywhere else —
|
||||||
|
# identical install-dependence, arriving through the one door
|
||||||
|
# nobody guarded.
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
continue
|
||||||
base = os.path.basename(path)
|
base = os.path.basename(path)
|
||||||
if base.endswith('.agent.md'):
|
if base.endswith('.agent.md'):
|
||||||
base = base[:-len('.agent.md')]
|
base = base[:-len('.agent.md')]
|
||||||
@@ -468,6 +478,34 @@ def known_targets(start_dir):
|
|||||||
# ambiguity to resolve, and an author who wants a route checked unconditionally
|
# ambiguity to resolve, and an author who wants a route checked unconditionally
|
||||||
# has two ways to say so.
|
# has two ways to say so.
|
||||||
#
|
#
|
||||||
|
# BOTH FORMS ARE SWEPT FOR ON THEIR OWN inside a boundary sentence, and that is
|
||||||
|
# a repair of the promise above rather than a widening of it. Until the sweeps
|
||||||
|
# existed, notation was only ever seen as the OBJECT OF A ROUTE VERB (`use
|
||||||
|
# /name`) or as the tail of a `not ... ->` clause with no `;` or sentence end in
|
||||||
|
# between. Every one of these therefore exited 0 in total silence — no ERROR, no
|
||||||
|
# SUGGESTION, not even the target's name:
|
||||||
|
# Do not use for Y — /no-such-skill instead.
|
||||||
|
# Do not use for Y; /no-such-skill handles that.
|
||||||
|
# Do not use for Y (/no-such-skill covers it).
|
||||||
|
# Do not use for Y — that is /no-such-skill's job.
|
||||||
|
# Do not use for Y — defer to /no-such-skill.
|
||||||
|
# Do not use for Y — /no-such-skill.
|
||||||
|
# Do not use for Y; -> no-such-skill covers it.
|
||||||
|
# The target was never EXTRACTED, so the notation-first rule in _add() had
|
||||||
|
# nothing to apply itself to and the "always blocks" promise was false for the
|
||||||
|
# ordinary way an author writes the thing. The SUGGESTION tier made it worse
|
||||||
|
# than a gap: its printed remedy tells the author to "write it as `/name` or
|
||||||
|
# `-> name` and it will be checked properly", and taking that advice turned a
|
||||||
|
# visible SUGGESTION into silence — the gate teaching the one edit that blinds
|
||||||
|
# it.
|
||||||
|
#
|
||||||
|
# The sweeps are gated on the sentence carrying a BOUNDARY_MARKER, the same gate
|
||||||
|
# the backtick sweep uses, and NOTATION_SLASH refuses a token that is part of a
|
||||||
|
# PATH: a following `/`, or a `.` followed by a non-space, means
|
||||||
|
# `references/foo.md`, `docs/a/b.md` or `https://x/y`, not a route. A sentence's
|
||||||
|
# closing `.` is not followed by a non-space, so `— /no-such-skill.` still
|
||||||
|
# counts.
|
||||||
|
#
|
||||||
# NAMESPACE: `plugin:skill` is live in this repo (native user-scope installs
|
# NAMESPACE: `plugin:skill` is live in this repo (native user-scope installs
|
||||||
# still resolve `gitea:gitea-prs`), so the patterns admit an optional
|
# still resolve `gitea:gitea-prs`), so the patterns admit an optional
|
||||||
# `<plugin>:` prefix and normalize_target() strips it before resolution.
|
# `<plugin>:` prefix and normalize_target() strips it before resolution.
|
||||||
@@ -491,6 +529,23 @@ ROUTE_ANY = re.compile(r"\b%s\s+(?:the\s+|an?\s+)?%s" % (ROUTE_VERB, ANY_TARGET)
|
|||||||
CONT_MARKED = re.compile(r"\s*(?:or|and|/|,)\s*%s" % MARKED_TARGET, re.I)
|
CONT_MARKED = re.compile(r"\s*(?:or|and|/|,)\s*%s" % MARKED_TARGET, re.I)
|
||||||
CONT_ANY = re.compile(r"\s*(?:or|and|/|,)\s*%s" % ANY_TARGET, re.I)
|
CONT_ANY = re.compile(r"\s*(?:or|and|/|,)\s*%s" % ANY_TARGET, re.I)
|
||||||
ARROW_MARKED = re.compile(r"(?:->|→)\s*%s" % MARKED_TARGET, re.I)
|
ARROW_MARKED = re.compile(r"(?:->|→)\s*%s" % MARKED_TARGET, re.I)
|
||||||
|
# The two EXPLICIT ROUTE NOTATION sweeps, scoped to a boundary sentence by their
|
||||||
|
# caller. NOTATION_SLASH is deliberately not a reuse of MARKED_TARGET's `/name`
|
||||||
|
# alternative: that one only ever runs behind a route verb or an arrow, and the
|
||||||
|
# trailing lookahead here is the part that makes a FREE-STANDING sweep safe.
|
||||||
|
# NOTATION_ARROW is ARROW_BOUNDARY minus its leading `\bnot\b%s*?`, which is
|
||||||
|
# what made `Do not use for Y; -> no-such-skill covers it.` invisible:
|
||||||
|
# CLAUSE_BODY cannot cross the `;`, so the clause's own punctuation disarmed the
|
||||||
|
# check. Dropping that prefix costs the one false positive the bare-arrow bullet
|
||||||
|
# above names — a process chain ending in a hyphenated word, `Instead, reproduce
|
||||||
|
# -> minimise -> regression-test.` — and costs it only in a sentence that already
|
||||||
|
# carries a BOUNDARY_MARKER. That exposure is neither new nor larger: the same
|
||||||
|
# chain written `Do not use for X — reproduce -> regression-test.` was already a
|
||||||
|
# hard ERROR under ARROW_BOUNDARY, so this changes which boundary words reach the
|
||||||
|
# arrow, not whether prose can. An author who means the chain and not a route
|
||||||
|
# writes it in its own sentence, where neither pattern looks.
|
||||||
|
NOTATION_SLASH = re.compile(r"(?<![\w./*-])/(%s)\b(?!/|\.\S)" % NAME_ANY, re.I)
|
||||||
|
NOTATION_ARROW = re.compile(r"(?:->|→)\s*(%s)\b" % NAME_HYPH, re.I)
|
||||||
# CLAUSE_BODY is what may sit between `Not` and the arrow, and it is NOT
|
# CLAUSE_BODY is what may sit between `Not` and the arrow, and it is NOT
|
||||||
# `[^.;]`. That class cannot cross a `.`, so every boundary clause naming a
|
# `[^.;]`. That class cannot cross a `.`, so every boundary clause naming a
|
||||||
# DOTTED FILENAME between the two — `.pre-commit-config.yaml`, `AGENTS.md`,
|
# DOTTED FILENAME between the two — `.pre-commit-config.yaml`, `AGENTS.md`,
|
||||||
@@ -666,6 +721,16 @@ def _extract_sentence(sentence):
|
|||||||
_add(out, sentence, match.group(1), match.start(1), match.end(1),
|
_add(out, sentence, match.group(1), match.start(1), match.end(1),
|
||||||
strict=True, arrow=True)
|
strict=True, arrow=True)
|
||||||
if boundary:
|
if boundary:
|
||||||
|
# Route notation wherever it sits in the clause, not only where a route
|
||||||
|
# verb or an arrow happens to precede it. See the EXPLICIT ROUTE
|
||||||
|
# NOTATION note in the header for the seven phrasings this recovers and
|
||||||
|
# for why silence was the failure mode. Neither sweep takes the follower
|
||||||
|
# test: _add() reads the notation first and both forms reach it marked.
|
||||||
|
for match in NOTATION_SLASH.finditer(sentence):
|
||||||
|
_add(out, sentence, match.group(1), match.start(1), match.end(1))
|
||||||
|
for match in NOTATION_ARROW.finditer(sentence):
|
||||||
|
_add(out, sentence, match.group(1), match.start(1), match.end(1),
|
||||||
|
strict=True, arrow=True)
|
||||||
for match in BACKTICK.finditer(sentence):
|
for match in BACKTICK.finditer(sentence):
|
||||||
_add(out, sentence, match.group(1), match.start(1), match.end(1))
|
_add(out, sentence, match.group(1), match.start(1), match.end(1))
|
||||||
return out
|
return out
|
||||||
@@ -1036,7 +1101,15 @@ def missing_reference_pointers(body, skill_dir):
|
|||||||
end = masked.find('\n', match.end())
|
end = masked.find('\n', match.end())
|
||||||
if end < 0:
|
if end < 0:
|
||||||
end = len(masked)
|
end = len(masked)
|
||||||
if REFERENCE_PAST.search(masked[start:end]):
|
# The pointer's OWN SPAN is excised before the sweep. Run over the
|
||||||
|
# whole line, the past-tense test matched the very path it was judging,
|
||||||
|
# so a file exempted itself by its NAME: `references/deprecated-api.md`,
|
||||||
|
# `references/removed-flags.md` and `references/gone.md` produced no
|
||||||
|
# ERROR at all, while `references/missing.md` — an identical break —
|
||||||
|
# errored. The exemption is about what the SENTENCE says about the
|
||||||
|
# pointer, never about what the pointer is called.
|
||||||
|
line = masked[start:match.start()] + masked[match.end():end]
|
||||||
|
if REFERENCE_PAST.search(line):
|
||||||
continue
|
continue
|
||||||
if REFERENCE_QUALIFIER.search(masked[start:match.start()]):
|
if REFERENCE_QUALIFIER.search(masked[start:match.start()]):
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -264,6 +264,16 @@ def _collect_package(pkg_dir, names):
|
|||||||
names.add(os.path.basename(path.rstrip('/')).lower())
|
names.add(os.path.basename(path.rstrip('/')).lower())
|
||||||
for sub in ('.apm/agents/*.md', 'agents/*.md'):
|
for sub in ('.apm/agents/*.md', 'agents/*.md'):
|
||||||
for path in glob.glob(os.path.join(safe_dir, sub)):
|
for path in glob.glob(os.path.join(safe_dir, sub)):
|
||||||
|
# The same rule one directory over, which until now had no
|
||||||
|
# counterpart here at all: the skills branch above tests for a
|
||||||
|
# SKILL.md, the agents branch took every glob hit on trust. A
|
||||||
|
# DIRECTORY named `ghost-agent.md` matches `*.md` and glob does not
|
||||||
|
# tell the two apart, so a leftover of that shape resolved a routing
|
||||||
|
# target on the machine holding it and dangled everywhere else —
|
||||||
|
# identical install-dependence, arriving through the one door
|
||||||
|
# nobody guarded.
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
continue
|
||||||
base = os.path.basename(path)
|
base = os.path.basename(path)
|
||||||
if base.endswith('.agent.md'):
|
if base.endswith('.agent.md'):
|
||||||
base = base[:-len('.agent.md')]
|
base = base[:-len('.agent.md')]
|
||||||
@@ -542,6 +552,34 @@ def known_targets(start_dir):
|
|||||||
# ambiguity to resolve, and an author who wants a route checked unconditionally
|
# ambiguity to resolve, and an author who wants a route checked unconditionally
|
||||||
# has two ways to say so.
|
# has two ways to say so.
|
||||||
#
|
#
|
||||||
|
# BOTH FORMS ARE SWEPT FOR ON THEIR OWN inside a boundary sentence, and that is
|
||||||
|
# a repair of the promise above rather than a widening of it. Until the sweeps
|
||||||
|
# existed, notation was only ever seen as the OBJECT OF A ROUTE VERB (`use
|
||||||
|
# /name`) or as the tail of a `not ... ->` clause with no `;` or sentence end in
|
||||||
|
# between. Every one of these therefore exited 0 in total silence — no ERROR, no
|
||||||
|
# SUGGESTION, not even the target's name:
|
||||||
|
# Do not use for Y — /no-such-skill instead.
|
||||||
|
# Do not use for Y; /no-such-skill handles that.
|
||||||
|
# Do not use for Y (/no-such-skill covers it).
|
||||||
|
# Do not use for Y — that is /no-such-skill's job.
|
||||||
|
# Do not use for Y — defer to /no-such-skill.
|
||||||
|
# Do not use for Y — /no-such-skill.
|
||||||
|
# Do not use for Y; -> no-such-skill covers it.
|
||||||
|
# The target was never EXTRACTED, so the notation-first rule in _add() had
|
||||||
|
# nothing to apply itself to and the "always blocks" promise was false for the
|
||||||
|
# ordinary way an author writes the thing. The SUGGESTION tier made it worse
|
||||||
|
# than a gap: its printed remedy tells the author to "write it as `/name` or
|
||||||
|
# `-> name` and it will be checked properly", and taking that advice turned a
|
||||||
|
# visible SUGGESTION into silence — the gate teaching the one edit that blinds
|
||||||
|
# it.
|
||||||
|
#
|
||||||
|
# The sweeps are gated on the sentence carrying a BOUNDARY_MARKER, the same gate
|
||||||
|
# the backtick sweep uses, and NOTATION_SLASH refuses a token that is part of a
|
||||||
|
# PATH: a following `/`, or a `.` followed by a non-space, means
|
||||||
|
# `references/foo.md`, `docs/a/b.md` or `https://x/y`, not a route. A sentence's
|
||||||
|
# closing `.` is not followed by a non-space, so `— /no-such-skill.` still
|
||||||
|
# counts.
|
||||||
|
#
|
||||||
# NAMESPACE: `plugin:skill` is live in this repo (native user-scope installs
|
# NAMESPACE: `plugin:skill` is live in this repo (native user-scope installs
|
||||||
# still resolve `gitea:gitea-prs`), so the patterns admit an optional
|
# still resolve `gitea:gitea-prs`), so the patterns admit an optional
|
||||||
# `<plugin>:` prefix and normalize_target() strips it before resolution.
|
# `<plugin>:` prefix and normalize_target() strips it before resolution.
|
||||||
@@ -565,6 +603,23 @@ ROUTE_ANY = re.compile(r"\b%s\s+(?:the\s+|an?\s+)?%s" % (ROUTE_VERB, ANY_TARGET)
|
|||||||
CONT_MARKED = re.compile(r"\s*(?:or|and|/|,)\s*%s" % MARKED_TARGET, re.I)
|
CONT_MARKED = re.compile(r"\s*(?:or|and|/|,)\s*%s" % MARKED_TARGET, re.I)
|
||||||
CONT_ANY = re.compile(r"\s*(?:or|and|/|,)\s*%s" % ANY_TARGET, re.I)
|
CONT_ANY = re.compile(r"\s*(?:or|and|/|,)\s*%s" % ANY_TARGET, re.I)
|
||||||
ARROW_MARKED = re.compile(r"(?:->|→)\s*%s" % MARKED_TARGET, re.I)
|
ARROW_MARKED = re.compile(r"(?:->|→)\s*%s" % MARKED_TARGET, re.I)
|
||||||
|
# The two EXPLICIT ROUTE NOTATION sweeps, scoped to a boundary sentence by their
|
||||||
|
# caller. NOTATION_SLASH is deliberately not a reuse of MARKED_TARGET's `/name`
|
||||||
|
# alternative: that one only ever runs behind a route verb or an arrow, and the
|
||||||
|
# trailing lookahead here is the part that makes a FREE-STANDING sweep safe.
|
||||||
|
# NOTATION_ARROW is ARROW_BOUNDARY minus its leading `\bnot\b%s*?`, which is
|
||||||
|
# what made `Do not use for Y; -> no-such-skill covers it.` invisible:
|
||||||
|
# CLAUSE_BODY cannot cross the `;`, so the clause's own punctuation disarmed the
|
||||||
|
# check. Dropping that prefix costs the one false positive the bare-arrow bullet
|
||||||
|
# above names — a process chain ending in a hyphenated word, `Instead, reproduce
|
||||||
|
# -> minimise -> regression-test.` — and costs it only in a sentence that already
|
||||||
|
# carries a BOUNDARY_MARKER. That exposure is neither new nor larger: the same
|
||||||
|
# chain written `Do not use for X — reproduce -> regression-test.` was already a
|
||||||
|
# hard ERROR under ARROW_BOUNDARY, so this changes which boundary words reach the
|
||||||
|
# arrow, not whether prose can. An author who means the chain and not a route
|
||||||
|
# writes it in its own sentence, where neither pattern looks.
|
||||||
|
NOTATION_SLASH = re.compile(r"(?<![\w./*-])/(%s)\b(?!/|\.\S)" % NAME_ANY, re.I)
|
||||||
|
NOTATION_ARROW = re.compile(r"(?:->|→)\s*(%s)\b" % NAME_HYPH, re.I)
|
||||||
# CLAUSE_BODY is what may sit between `Not` and the arrow, and it is NOT
|
# CLAUSE_BODY is what may sit between `Not` and the arrow, and it is NOT
|
||||||
# `[^.;]`. That class cannot cross a `.`, so every boundary clause naming a
|
# `[^.;]`. That class cannot cross a `.`, so every boundary clause naming a
|
||||||
# DOTTED FILENAME between the two — `.pre-commit-config.yaml`, `AGENTS.md`,
|
# DOTTED FILENAME between the two — `.pre-commit-config.yaml`, `AGENTS.md`,
|
||||||
@@ -740,6 +795,16 @@ def _extract_sentence(sentence):
|
|||||||
_add(out, sentence, match.group(1), match.start(1), match.end(1),
|
_add(out, sentence, match.group(1), match.start(1), match.end(1),
|
||||||
strict=True, arrow=True)
|
strict=True, arrow=True)
|
||||||
if boundary:
|
if boundary:
|
||||||
|
# Route notation wherever it sits in the clause, not only where a route
|
||||||
|
# verb or an arrow happens to precede it. See the EXPLICIT ROUTE
|
||||||
|
# NOTATION note in the header for the seven phrasings this recovers and
|
||||||
|
# for why silence was the failure mode. Neither sweep takes the follower
|
||||||
|
# test: _add() reads the notation first and both forms reach it marked.
|
||||||
|
for match in NOTATION_SLASH.finditer(sentence):
|
||||||
|
_add(out, sentence, match.group(1), match.start(1), match.end(1))
|
||||||
|
for match in NOTATION_ARROW.finditer(sentence):
|
||||||
|
_add(out, sentence, match.group(1), match.start(1), match.end(1),
|
||||||
|
strict=True, arrow=True)
|
||||||
for match in BACKTICK.finditer(sentence):
|
for match in BACKTICK.finditer(sentence):
|
||||||
_add(out, sentence, match.group(1), match.start(1), match.end(1))
|
_add(out, sentence, match.group(1), match.start(1), match.end(1))
|
||||||
return out
|
return out
|
||||||
@@ -1110,7 +1175,15 @@ def missing_reference_pointers(body, skill_dir):
|
|||||||
end = masked.find('\n', match.end())
|
end = masked.find('\n', match.end())
|
||||||
if end < 0:
|
if end < 0:
|
||||||
end = len(masked)
|
end = len(masked)
|
||||||
if REFERENCE_PAST.search(masked[start:end]):
|
# The pointer's OWN SPAN is excised before the sweep. Run over the
|
||||||
|
# whole line, the past-tense test matched the very path it was judging,
|
||||||
|
# so a file exempted itself by its NAME: `references/deprecated-api.md`,
|
||||||
|
# `references/removed-flags.md` and `references/gone.md` produced no
|
||||||
|
# ERROR at all, while `references/missing.md` — an identical break —
|
||||||
|
# errored. The exemption is about what the SENTENCE says about the
|
||||||
|
# pointer, never about what the pointer is called.
|
||||||
|
line = masked[start:match.start()] + masked[match.end():end]
|
||||||
|
if REFERENCE_PAST.search(line):
|
||||||
continue
|
continue
|
||||||
if REFERENCE_QUALIFIER.search(masked[start:match.start()]):
|
if REFERENCE_QUALIFIER.search(masked[start:match.start()]):
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -190,6 +190,16 @@ def _collect_package(pkg_dir, names):
|
|||||||
names.add(os.path.basename(path.rstrip('/')).lower())
|
names.add(os.path.basename(path.rstrip('/')).lower())
|
||||||
for sub in ('.apm/agents/*.md', 'agents/*.md'):
|
for sub in ('.apm/agents/*.md', 'agents/*.md'):
|
||||||
for path in glob.glob(os.path.join(safe_dir, sub)):
|
for path in glob.glob(os.path.join(safe_dir, sub)):
|
||||||
|
# The same rule one directory over, which until now had no
|
||||||
|
# counterpart here at all: the skills branch above tests for a
|
||||||
|
# SKILL.md, the agents branch took every glob hit on trust. A
|
||||||
|
# DIRECTORY named `ghost-agent.md` matches `*.md` and glob does not
|
||||||
|
# tell the two apart, so a leftover of that shape resolved a routing
|
||||||
|
# target on the machine holding it and dangled everywhere else —
|
||||||
|
# identical install-dependence, arriving through the one door
|
||||||
|
# nobody guarded.
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
continue
|
||||||
base = os.path.basename(path)
|
base = os.path.basename(path)
|
||||||
if base.endswith('.agent.md'):
|
if base.endswith('.agent.md'):
|
||||||
base = base[:-len('.agent.md')]
|
base = base[:-len('.agent.md')]
|
||||||
@@ -468,6 +478,34 @@ def known_targets(start_dir):
|
|||||||
# ambiguity to resolve, and an author who wants a route checked unconditionally
|
# ambiguity to resolve, and an author who wants a route checked unconditionally
|
||||||
# has two ways to say so.
|
# has two ways to say so.
|
||||||
#
|
#
|
||||||
|
# BOTH FORMS ARE SWEPT FOR ON THEIR OWN inside a boundary sentence, and that is
|
||||||
|
# a repair of the promise above rather than a widening of it. Until the sweeps
|
||||||
|
# existed, notation was only ever seen as the OBJECT OF A ROUTE VERB (`use
|
||||||
|
# /name`) or as the tail of a `not ... ->` clause with no `;` or sentence end in
|
||||||
|
# between. Every one of these therefore exited 0 in total silence — no ERROR, no
|
||||||
|
# SUGGESTION, not even the target's name:
|
||||||
|
# Do not use for Y — /no-such-skill instead.
|
||||||
|
# Do not use for Y; /no-such-skill handles that.
|
||||||
|
# Do not use for Y (/no-such-skill covers it).
|
||||||
|
# Do not use for Y — that is /no-such-skill's job.
|
||||||
|
# Do not use for Y — defer to /no-such-skill.
|
||||||
|
# Do not use for Y — /no-such-skill.
|
||||||
|
# Do not use for Y; -> no-such-skill covers it.
|
||||||
|
# The target was never EXTRACTED, so the notation-first rule in _add() had
|
||||||
|
# nothing to apply itself to and the "always blocks" promise was false for the
|
||||||
|
# ordinary way an author writes the thing. The SUGGESTION tier made it worse
|
||||||
|
# than a gap: its printed remedy tells the author to "write it as `/name` or
|
||||||
|
# `-> name` and it will be checked properly", and taking that advice turned a
|
||||||
|
# visible SUGGESTION into silence — the gate teaching the one edit that blinds
|
||||||
|
# it.
|
||||||
|
#
|
||||||
|
# The sweeps are gated on the sentence carrying a BOUNDARY_MARKER, the same gate
|
||||||
|
# the backtick sweep uses, and NOTATION_SLASH refuses a token that is part of a
|
||||||
|
# PATH: a following `/`, or a `.` followed by a non-space, means
|
||||||
|
# `references/foo.md`, `docs/a/b.md` or `https://x/y`, not a route. A sentence's
|
||||||
|
# closing `.` is not followed by a non-space, so `— /no-such-skill.` still
|
||||||
|
# counts.
|
||||||
|
#
|
||||||
# NAMESPACE: `plugin:skill` is live in this repo (native user-scope installs
|
# NAMESPACE: `plugin:skill` is live in this repo (native user-scope installs
|
||||||
# still resolve `gitea:gitea-prs`), so the patterns admit an optional
|
# still resolve `gitea:gitea-prs`), so the patterns admit an optional
|
||||||
# `<plugin>:` prefix and normalize_target() strips it before resolution.
|
# `<plugin>:` prefix and normalize_target() strips it before resolution.
|
||||||
@@ -491,6 +529,23 @@ ROUTE_ANY = re.compile(r"\b%s\s+(?:the\s+|an?\s+)?%s" % (ROUTE_VERB, ANY_TARGET)
|
|||||||
CONT_MARKED = re.compile(r"\s*(?:or|and|/|,)\s*%s" % MARKED_TARGET, re.I)
|
CONT_MARKED = re.compile(r"\s*(?:or|and|/|,)\s*%s" % MARKED_TARGET, re.I)
|
||||||
CONT_ANY = re.compile(r"\s*(?:or|and|/|,)\s*%s" % ANY_TARGET, re.I)
|
CONT_ANY = re.compile(r"\s*(?:or|and|/|,)\s*%s" % ANY_TARGET, re.I)
|
||||||
ARROW_MARKED = re.compile(r"(?:->|→)\s*%s" % MARKED_TARGET, re.I)
|
ARROW_MARKED = re.compile(r"(?:->|→)\s*%s" % MARKED_TARGET, re.I)
|
||||||
|
# The two EXPLICIT ROUTE NOTATION sweeps, scoped to a boundary sentence by their
|
||||||
|
# caller. NOTATION_SLASH is deliberately not a reuse of MARKED_TARGET's `/name`
|
||||||
|
# alternative: that one only ever runs behind a route verb or an arrow, and the
|
||||||
|
# trailing lookahead here is the part that makes a FREE-STANDING sweep safe.
|
||||||
|
# NOTATION_ARROW is ARROW_BOUNDARY minus its leading `\bnot\b%s*?`, which is
|
||||||
|
# what made `Do not use for Y; -> no-such-skill covers it.` invisible:
|
||||||
|
# CLAUSE_BODY cannot cross the `;`, so the clause's own punctuation disarmed the
|
||||||
|
# check. Dropping that prefix costs the one false positive the bare-arrow bullet
|
||||||
|
# above names — a process chain ending in a hyphenated word, `Instead, reproduce
|
||||||
|
# -> minimise -> regression-test.` — and costs it only in a sentence that already
|
||||||
|
# carries a BOUNDARY_MARKER. That exposure is neither new nor larger: the same
|
||||||
|
# chain written `Do not use for X — reproduce -> regression-test.` was already a
|
||||||
|
# hard ERROR under ARROW_BOUNDARY, so this changes which boundary words reach the
|
||||||
|
# arrow, not whether prose can. An author who means the chain and not a route
|
||||||
|
# writes it in its own sentence, where neither pattern looks.
|
||||||
|
NOTATION_SLASH = re.compile(r"(?<![\w./*-])/(%s)\b(?!/|\.\S)" % NAME_ANY, re.I)
|
||||||
|
NOTATION_ARROW = re.compile(r"(?:->|→)\s*(%s)\b" % NAME_HYPH, re.I)
|
||||||
# CLAUSE_BODY is what may sit between `Not` and the arrow, and it is NOT
|
# CLAUSE_BODY is what may sit between `Not` and the arrow, and it is NOT
|
||||||
# `[^.;]`. That class cannot cross a `.`, so every boundary clause naming a
|
# `[^.;]`. That class cannot cross a `.`, so every boundary clause naming a
|
||||||
# DOTTED FILENAME between the two — `.pre-commit-config.yaml`, `AGENTS.md`,
|
# DOTTED FILENAME between the two — `.pre-commit-config.yaml`, `AGENTS.md`,
|
||||||
@@ -666,6 +721,16 @@ def _extract_sentence(sentence):
|
|||||||
_add(out, sentence, match.group(1), match.start(1), match.end(1),
|
_add(out, sentence, match.group(1), match.start(1), match.end(1),
|
||||||
strict=True, arrow=True)
|
strict=True, arrow=True)
|
||||||
if boundary:
|
if boundary:
|
||||||
|
# Route notation wherever it sits in the clause, not only where a route
|
||||||
|
# verb or an arrow happens to precede it. See the EXPLICIT ROUTE
|
||||||
|
# NOTATION note in the header for the seven phrasings this recovers and
|
||||||
|
# for why silence was the failure mode. Neither sweep takes the follower
|
||||||
|
# test: _add() reads the notation first and both forms reach it marked.
|
||||||
|
for match in NOTATION_SLASH.finditer(sentence):
|
||||||
|
_add(out, sentence, match.group(1), match.start(1), match.end(1))
|
||||||
|
for match in NOTATION_ARROW.finditer(sentence):
|
||||||
|
_add(out, sentence, match.group(1), match.start(1), match.end(1),
|
||||||
|
strict=True, arrow=True)
|
||||||
for match in BACKTICK.finditer(sentence):
|
for match in BACKTICK.finditer(sentence):
|
||||||
_add(out, sentence, match.group(1), match.start(1), match.end(1))
|
_add(out, sentence, match.group(1), match.start(1), match.end(1))
|
||||||
return out
|
return out
|
||||||
@@ -1036,7 +1101,15 @@ def missing_reference_pointers(body, skill_dir):
|
|||||||
end = masked.find('\n', match.end())
|
end = masked.find('\n', match.end())
|
||||||
if end < 0:
|
if end < 0:
|
||||||
end = len(masked)
|
end = len(masked)
|
||||||
if REFERENCE_PAST.search(masked[start:end]):
|
# The pointer's OWN SPAN is excised before the sweep. Run over the
|
||||||
|
# whole line, the past-tense test matched the very path it was judging,
|
||||||
|
# so a file exempted itself by its NAME: `references/deprecated-api.md`,
|
||||||
|
# `references/removed-flags.md` and `references/gone.md` produced no
|
||||||
|
# ERROR at all, while `references/missing.md` — an identical break —
|
||||||
|
# errored. The exemption is about what the SENTENCE says about the
|
||||||
|
# pointer, never about what the pointer is called.
|
||||||
|
line = masked[start:match.start()] + masked[match.end():end]
|
||||||
|
if REFERENCE_PAST.search(line):
|
||||||
continue
|
continue
|
||||||
if REFERENCE_QUALIFIER.search(masked[start:match.start()]):
|
if REFERENCE_QUALIFIER.search(masked[start:match.start()]):
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -292,6 +292,16 @@ def _collect_package(pkg_dir, names):
|
|||||||
names.add(os.path.basename(path.rstrip('/')).lower())
|
names.add(os.path.basename(path.rstrip('/')).lower())
|
||||||
for sub in ('.apm/agents/*.md', 'agents/*.md'):
|
for sub in ('.apm/agents/*.md', 'agents/*.md'):
|
||||||
for path in glob.glob(os.path.join(safe_dir, sub)):
|
for path in glob.glob(os.path.join(safe_dir, sub)):
|
||||||
|
# The same rule one directory over, which until now had no
|
||||||
|
# counterpart here at all: the skills branch above tests for a
|
||||||
|
# SKILL.md, the agents branch took every glob hit on trust. A
|
||||||
|
# DIRECTORY named `ghost-agent.md` matches `*.md` and glob does not
|
||||||
|
# tell the two apart, so a leftover of that shape resolved a routing
|
||||||
|
# target on the machine holding it and dangled everywhere else —
|
||||||
|
# identical install-dependence, arriving through the one door
|
||||||
|
# nobody guarded.
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
continue
|
||||||
base = os.path.basename(path)
|
base = os.path.basename(path)
|
||||||
if base.endswith('.agent.md'):
|
if base.endswith('.agent.md'):
|
||||||
base = base[:-len('.agent.md')]
|
base = base[:-len('.agent.md')]
|
||||||
@@ -570,6 +580,34 @@ def known_targets(start_dir):
|
|||||||
# ambiguity to resolve, and an author who wants a route checked unconditionally
|
# ambiguity to resolve, and an author who wants a route checked unconditionally
|
||||||
# has two ways to say so.
|
# has two ways to say so.
|
||||||
#
|
#
|
||||||
|
# BOTH FORMS ARE SWEPT FOR ON THEIR OWN inside a boundary sentence, and that is
|
||||||
|
# a repair of the promise above rather than a widening of it. Until the sweeps
|
||||||
|
# existed, notation was only ever seen as the OBJECT OF A ROUTE VERB (`use
|
||||||
|
# /name`) or as the tail of a `not ... ->` clause with no `;` or sentence end in
|
||||||
|
# between. Every one of these therefore exited 0 in total silence — no ERROR, no
|
||||||
|
# SUGGESTION, not even the target's name:
|
||||||
|
# Do not use for Y — /no-such-skill instead.
|
||||||
|
# Do not use for Y; /no-such-skill handles that.
|
||||||
|
# Do not use for Y (/no-such-skill covers it).
|
||||||
|
# Do not use for Y — that is /no-such-skill's job.
|
||||||
|
# Do not use for Y — defer to /no-such-skill.
|
||||||
|
# Do not use for Y — /no-such-skill.
|
||||||
|
# Do not use for Y; -> no-such-skill covers it.
|
||||||
|
# The target was never EXTRACTED, so the notation-first rule in _add() had
|
||||||
|
# nothing to apply itself to and the "always blocks" promise was false for the
|
||||||
|
# ordinary way an author writes the thing. The SUGGESTION tier made it worse
|
||||||
|
# than a gap: its printed remedy tells the author to "write it as `/name` or
|
||||||
|
# `-> name` and it will be checked properly", and taking that advice turned a
|
||||||
|
# visible SUGGESTION into silence — the gate teaching the one edit that blinds
|
||||||
|
# it.
|
||||||
|
#
|
||||||
|
# The sweeps are gated on the sentence carrying a BOUNDARY_MARKER, the same gate
|
||||||
|
# the backtick sweep uses, and NOTATION_SLASH refuses a token that is part of a
|
||||||
|
# PATH: a following `/`, or a `.` followed by a non-space, means
|
||||||
|
# `references/foo.md`, `docs/a/b.md` or `https://x/y`, not a route. A sentence's
|
||||||
|
# closing `.` is not followed by a non-space, so `— /no-such-skill.` still
|
||||||
|
# counts.
|
||||||
|
#
|
||||||
# NAMESPACE: `plugin:skill` is live in this repo (native user-scope installs
|
# NAMESPACE: `plugin:skill` is live in this repo (native user-scope installs
|
||||||
# still resolve `gitea:gitea-prs`), so the patterns admit an optional
|
# still resolve `gitea:gitea-prs`), so the patterns admit an optional
|
||||||
# `<plugin>:` prefix and normalize_target() strips it before resolution.
|
# `<plugin>:` prefix and normalize_target() strips it before resolution.
|
||||||
@@ -593,6 +631,23 @@ ROUTE_ANY = re.compile(r"\b%s\s+(?:the\s+|an?\s+)?%s" % (ROUTE_VERB, ANY_TARGET)
|
|||||||
CONT_MARKED = re.compile(r"\s*(?:or|and|/|,)\s*%s" % MARKED_TARGET, re.I)
|
CONT_MARKED = re.compile(r"\s*(?:or|and|/|,)\s*%s" % MARKED_TARGET, re.I)
|
||||||
CONT_ANY = re.compile(r"\s*(?:or|and|/|,)\s*%s" % ANY_TARGET, re.I)
|
CONT_ANY = re.compile(r"\s*(?:or|and|/|,)\s*%s" % ANY_TARGET, re.I)
|
||||||
ARROW_MARKED = re.compile(r"(?:->|→)\s*%s" % MARKED_TARGET, re.I)
|
ARROW_MARKED = re.compile(r"(?:->|→)\s*%s" % MARKED_TARGET, re.I)
|
||||||
|
# The two EXPLICIT ROUTE NOTATION sweeps, scoped to a boundary sentence by their
|
||||||
|
# caller. NOTATION_SLASH is deliberately not a reuse of MARKED_TARGET's `/name`
|
||||||
|
# alternative: that one only ever runs behind a route verb or an arrow, and the
|
||||||
|
# trailing lookahead here is the part that makes a FREE-STANDING sweep safe.
|
||||||
|
# NOTATION_ARROW is ARROW_BOUNDARY minus its leading `\bnot\b%s*?`, which is
|
||||||
|
# what made `Do not use for Y; -> no-such-skill covers it.` invisible:
|
||||||
|
# CLAUSE_BODY cannot cross the `;`, so the clause's own punctuation disarmed the
|
||||||
|
# check. Dropping that prefix costs the one false positive the bare-arrow bullet
|
||||||
|
# above names — a process chain ending in a hyphenated word, `Instead, reproduce
|
||||||
|
# -> minimise -> regression-test.` — and costs it only in a sentence that already
|
||||||
|
# carries a BOUNDARY_MARKER. That exposure is neither new nor larger: the same
|
||||||
|
# chain written `Do not use for X — reproduce -> regression-test.` was already a
|
||||||
|
# hard ERROR under ARROW_BOUNDARY, so this changes which boundary words reach the
|
||||||
|
# arrow, not whether prose can. An author who means the chain and not a route
|
||||||
|
# writes it in its own sentence, where neither pattern looks.
|
||||||
|
NOTATION_SLASH = re.compile(r"(?<![\w./*-])/(%s)\b(?!/|\.\S)" % NAME_ANY, re.I)
|
||||||
|
NOTATION_ARROW = re.compile(r"(?:->|→)\s*(%s)\b" % NAME_HYPH, re.I)
|
||||||
# CLAUSE_BODY is what may sit between `Not` and the arrow, and it is NOT
|
# CLAUSE_BODY is what may sit between `Not` and the arrow, and it is NOT
|
||||||
# `[^.;]`. That class cannot cross a `.`, so every boundary clause naming a
|
# `[^.;]`. That class cannot cross a `.`, so every boundary clause naming a
|
||||||
# DOTTED FILENAME between the two — `.pre-commit-config.yaml`, `AGENTS.md`,
|
# DOTTED FILENAME between the two — `.pre-commit-config.yaml`, `AGENTS.md`,
|
||||||
@@ -768,6 +823,16 @@ def _extract_sentence(sentence):
|
|||||||
_add(out, sentence, match.group(1), match.start(1), match.end(1),
|
_add(out, sentence, match.group(1), match.start(1), match.end(1),
|
||||||
strict=True, arrow=True)
|
strict=True, arrow=True)
|
||||||
if boundary:
|
if boundary:
|
||||||
|
# Route notation wherever it sits in the clause, not only where a route
|
||||||
|
# verb or an arrow happens to precede it. See the EXPLICIT ROUTE
|
||||||
|
# NOTATION note in the header for the seven phrasings this recovers and
|
||||||
|
# for why silence was the failure mode. Neither sweep takes the follower
|
||||||
|
# test: _add() reads the notation first and both forms reach it marked.
|
||||||
|
for match in NOTATION_SLASH.finditer(sentence):
|
||||||
|
_add(out, sentence, match.group(1), match.start(1), match.end(1))
|
||||||
|
for match in NOTATION_ARROW.finditer(sentence):
|
||||||
|
_add(out, sentence, match.group(1), match.start(1), match.end(1),
|
||||||
|
strict=True, arrow=True)
|
||||||
for match in BACKTICK.finditer(sentence):
|
for match in BACKTICK.finditer(sentence):
|
||||||
_add(out, sentence, match.group(1), match.start(1), match.end(1))
|
_add(out, sentence, match.group(1), match.start(1), match.end(1))
|
||||||
return out
|
return out
|
||||||
@@ -1138,7 +1203,15 @@ def missing_reference_pointers(body, skill_dir):
|
|||||||
end = masked.find('\n', match.end())
|
end = masked.find('\n', match.end())
|
||||||
if end < 0:
|
if end < 0:
|
||||||
end = len(masked)
|
end = len(masked)
|
||||||
if REFERENCE_PAST.search(masked[start:end]):
|
# The pointer's OWN SPAN is excised before the sweep. Run over the
|
||||||
|
# whole line, the past-tense test matched the very path it was judging,
|
||||||
|
# so a file exempted itself by its NAME: `references/deprecated-api.md`,
|
||||||
|
# `references/removed-flags.md` and `references/gone.md` produced no
|
||||||
|
# ERROR at all, while `references/missing.md` — an identical break —
|
||||||
|
# errored. The exemption is about what the SENTENCE says about the
|
||||||
|
# pointer, never about what the pointer is called.
|
||||||
|
line = masked[start:match.start()] + masked[match.end():end]
|
||||||
|
if REFERENCE_PAST.search(line):
|
||||||
continue
|
continue
|
||||||
if REFERENCE_QUALIFIER.search(masked[start:match.start()]):
|
if REFERENCE_QUALIFIER.search(masked[start:match.start()]):
|
||||||
continue
|
continue
|
||||||
|
|||||||
Reference in New Issue
Block a user