fix(skills): strip trailing sentence punctuation from extracted resource refs (#5739)

* fix(skills): strip trailing sentence punctuation from extracted resource refs

The resource-graph extractor's _PATH_TOKEN_RE includes '.' in its path
character class, so a bare package-relative path at the end of a sentence
(e.g. "Read references/setup.md.") is extracted with the trailing period
still attached. build_resource_graph then emits a false-positive
resource.missing finding for "references/setup.md." and, because the
real file is never matched, reports the actual references/setup.md as an
unreferenced orphan.

Other sentence punctuation (",;:!?()") already falls outside the regex
character class and is correctly dropped; only '.' is ambiguous because it
is both a valid path character and prose punctuation.

Strip trailing '.', '?' and '!' from every extracted reference in
_extract_references. Real dotted filenames are unaffected: rstrip only
removes punctuation at the very end, so references/config.yaml and a link
target such as references/v1.0.md keep their dots.

Adds two regression tests covering prose-terminated bare paths and real
dotted filenames.

* style(tests): format skill review tests

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
illusion 2026-09-24 20:52:58 +08:00 committed by GitHub
parent bcaf6db36a
commit 83c42a61e7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 48 additions and 3 deletions

View File

@ -86,16 +86,24 @@ def build_resource_graph(snapshot: dict[str, Any]) -> tuple[dict[str, Any], list
return graph, findings
# Trailing sentence punctuation a resource path can legitimately be followed
# by in prose (".", "?", "!") but that is never part of a package-relative
# path. "." in particular is also a valid path character, so it must only be
# stripped from the very end — e.g. "references/setup.md." -> "references/setup.md"
# while "references/config.yaml" keeps its extension.
_TRAILING_SENTENCE_PUNCTUATION = ".?!"
def _extract_references(content: str) -> set[str]:
refs: set[str] = set()
for match in _MARKDOWN_LINK_RE.finditer(content):
refs.add(match.group(1).split("#", 1)[0])
refs.add(match.group(1).split("#", 1)[0].rstrip(_TRAILING_SENTENCE_PUNCTUATION))
for match in _CODE_SPAN_RE.finditer(content):
token = match.group(1).strip()
if "/" in token:
refs.add(token)
refs.add(token.rstrip(_TRAILING_SENTENCE_PUNCTUATION))
for match in _PATH_TOKEN_RE.finditer(content):
refs.add(match.group(0))
refs.add(match.group(0).rstrip(_TRAILING_SENTENCE_PUNCTUATION))
return refs

View File

@ -115,6 +115,43 @@ def test_resource_graph_tracks_referenced_resource(tmp_path):
assert "references/guide.md" not in facts["resources"]["orphans"]
def test_resource_graph_strips_trailing_sentence_punctuation_from_prose_refs(tmp_path):
# A bare path at the end of an English sentence is followed by "." or "!".
# Those are prose punctuation, not part of the path: they must not turn a
# valid reference into a resource.missing finding or orphan the real file.
_write(
tmp_path / "SKILL.md",
_valid_skill() + "\nRead references/setup.md.\nAlso see references/usage.md!\n",
)
_write(tmp_path / "references" / "setup.md", "# Setup\n")
_write(tmp_path / "references" / "usage.md", "# Usage\n")
facts = analyze_skill_package(LocalDirectoryReader(tmp_path).read())
for target in ("references/setup.md", "references/usage.md"):
assert {"source": "SKILL.md", "target": target} in facts["resources"]["edges"]
assert target not in facts["resources"]["orphans"]
assert not any(f["rule_id"] == "resource.missing" and f["path"] == "SKILL.md" for f in facts["findings"])
def test_resource_graph_keeps_real_dotted_filenames(tmp_path):
# "." is also a legitimate path character: a real dotted filename in a
# link target or a path token must keep its dots, not be stripped.
_write(
tmp_path / "SKILL.md",
_valid_skill() + "\nSee [config](references/config.yaml) and references/v1.0.md.\n",
)
_write(tmp_path / "references" / "config.yaml", "a: 1\n")
_write(tmp_path / "references" / "v1.0.md", "# v1.0\n")
facts = analyze_skill_package(LocalDirectoryReader(tmp_path).read())
for target in ("references/config.yaml", "references/v1.0.md"):
assert {"source": "SKILL.md", "target": target} in facts["resources"]["edges"]
assert target not in facts["resources"]["orphans"]
assert not any(f["rule_id"] == "resource.missing" and f["path"] == "SKILL.md" for f in facts["findings"])
def test_resource_graph_ignores_eval_fixture_references(tmp_path):
_write(tmp_path / "SKILL.md", _valid_skill())
_write(