fix(skills): strip section anchors from code-span resource refs (#5841)

* fix(skills): strip section anchors from code-span resource refs

Markdown-link targets already drop their #fragment before the
reference is resolved, but code-span refs did not: a prose reference
like `references/faq.md#pricing` kept the anchor and was resolved as
a literal path, producing a resource.missing finding for a file that
exists and is referenced. The bare-path token scanner stops at '#',
so only the code-span branch leaked fragments.

Split the fragment off code-span tokens the same way the link branch
does, then strip trailing sentence punctuation. Real dotted filenames
keep their dots: the split happens at the first '#', and '.' is only
stripped from the very end.

Signed-off-by: yetuge <2219677952@qq.com>

* fix(skills): prefer exact hash-bearing filenames over fragment stripping

Package paths permit '#', so a code-span reference to a real file like
references/C#.md was truncated to references/C by the fragment split,
producing both a false resource.missing and an orphan report (the bare
path token stopped at '#' the same way). Keep the whole reference in
every extraction branch (link target, code span, bare path token now
includes '#' in its character class) and resolve the fragment
existence-aware: prefer the exact token when it names a real file and
treat the suffix after the first '#' as a section fragment only when
it does not.

Red/green: a new test pins references/C#.md resolving to the real file
while references/faq.md#pricing still strips to faq.md — it fails on
the reviewed head and passes with the change; full
test_skill_review_core.py is 36 passed.

Signed-off-by: yetuge <2219677952@qq.com>

* fix(skills): strip the fragment before normalizing the fallback path

The exact-path preference checked the normalized reference, so a token
like references/faq.md#/../other.md normalized to references/other.md;
when that file existed the check succeeded and the fragment-removal
branch was skipped, recording the wrong edge and orphaning faq.md.
Check the literal token against the package files instead, and drop
the fragment before the remaining path is normalized — normalizing a
hash-bearing segment must never retarget an edge.

Signed-off-by: yetuge <2219677952@qq.com>

* fix(skills): canonicalize leading segments in the hash-filename lookup

The literal-file check compared the verbatim joined path, whose leading
relative segments are uncanonicalized: from references/sub/guide.md, a
reference to ../C#.md joins to references/sub/../C#.md, matches no
snapshot key, and falls through to fragment stripping — truncating the
real references/C#.md into a false missing and an orphan. Compare
against the normpath-canonicalized whole token instead, and treat the
post-'#' text as a fragment whenever it is path-like (carries / or ..)
so it can still never participate in normalization and retarget an
edge.

Signed-off-by: yetuge <2219677952@qq.com>

* fix(skills): check the canonical whole path before stripping a fragment

The path-like-fragment heuristic dropped any post-'#' text carrying '/':
a real package file such as references/C#/readme.md (a '#' inside a
directory name) was truncated to references/C, producing a false
resource.missing and orphaning readme.md. Only a '..' segment in the
post-'#' text forces the strip-first fallback — that is the one case
where fragment text participates in normalization and can retarget an
edge (faq.md#/../other.md). Every other fragment first gets the
canonicalized whole-token lookup, which already canonicalizes leading
relative segments for nested sources.

Signed-off-by: yetuge <2219677952@qq.com>

* fix(skills): always strip markdown link fragments and blind the bare-path pass to links

Markdown link targets have fixed fragment semantics: the text after '#'
is a URL fragment, never part of the filename, so a link to a file
literally named references/faq.md#pricing would have to percent-encode
it. The exact-file branch applied the hash-filename preference to link
targets too, recording a valid edge and suppressing resource.missing
for a broken link when a literal same-named file existed. Links strip
the fragment again, and their full construct is blanked out of the
residual text so the code-span and bare-path passes — literal-path
contexts where '#' may be part of a real filename — never see
link-internal text.

Signed-off-by: yetuge <2219677952@qq.com>

---------

Signed-off-by: yetuge <2219677952@qq.com>
This commit is contained in:
yetuge 2026-09-25 15:18:30 +08:00 committed by GitHub
parent b2a2fb757a
commit b4a60fbfe2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 166 additions and 7 deletions

View File

@ -2,7 +2,9 @@
from __future__ import annotations
import posixpath
import re
from collections.abc import Set as AbstractSet
from pathlib import PurePosixPath
from typing import Any
@ -11,7 +13,7 @@ from deerflow.skills.review.models import make_finding, normalize_relative_path
_MARKDOWN_LINK_RE = re.compile(r"!?\[[^\]]*]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)")
_CODE_SPAN_RE = re.compile(r"`([^`]+)`")
_PATH_TOKEN_RE = re.compile(r"(?<![\w./-])(?:references|scripts|templates|assets|evals)/[A-Za-z0-9._~/%+-]+")
_PATH_TOKEN_RE = re.compile(r"(?<![\w./-])(?:references|scripts|templates|assets|evals)/[A-Za-z0-9._~/%+#-]+")
_RESOURCE_DIRS = {"references", "scripts", "templates", "assets", "evals"}
@ -29,7 +31,7 @@ def build_resource_graph(snapshot: dict[str, Any]) -> tuple[dict[str, Any], list
continue
content = str(entry.get("content") or "")
for raw_ref in _extract_references(content):
resolved = _resolve_reference(path, raw_ref)
resolved = _resolve_reference(path, raw_ref, files.keys())
if resolved is None:
continue
if resolved == "__ESCAPES__":
@ -96,18 +98,32 @@ _TRAILING_SENTENCE_PUNCTUATION = ".?!"
def _extract_references(content: str) -> set[str]:
refs: set[str] = set()
# Markdown link syntax fixes the fragment semantics: the text after '#'
# in a link target is ALWAYS a URL fragment, never part of the filename
# — a link to a file literally named "faq.md#pricing" would have to
# percent-encode it. So links always strip the fragment, and their full
# construct is blanked out of the residual text: the code-span and
# bare-path passes below are literal-path contexts where '#' may be
# part of a real filename, and they must never see link-internal text.
# Trailing sentence punctuation is still stripped here (#5739).
residual = content
for match in _MARKDOWN_LINK_RE.finditer(content):
refs.add(match.group(1).split("#", 1)[0].rstrip(_TRAILING_SENTENCE_PUNCTUATION))
for match in _CODE_SPAN_RE.finditer(content):
residual = residual.replace(match.group(0), " " * len(match.group(0)))
for match in _CODE_SPAN_RE.finditer(residual):
token = match.group(1).strip()
if "/" in token:
refs.add(token.rstrip(_TRAILING_SENTENCE_PUNCTUATION))
for match in _PATH_TOKEN_RE.finditer(content):
for match in _PATH_TOKEN_RE.finditer(residual):
refs.add(match.group(0).rstrip(_TRAILING_SENTENCE_PUNCTUATION))
return refs
def _resolve_reference(source_path: str, raw_ref: str) -> str | None:
def _resolve_reference(
source_path: str,
raw_ref: str,
files: AbstractSet[str] | None = None,
) -> str | None:
ref = raw_ref.strip().strip("\"'")
if not ref or ref.startswith("#") or re.match(r"^[A-Za-z][A-Za-z0-9+.-]*:", ref):
return None
@ -117,7 +133,23 @@ def _resolve_reference(source_path: str, raw_ref: str) -> str | None:
base = PurePosixPath(source_path).parent
if "://" in ref:
return None
candidate = (base / ref).as_posix()
return normalize_relative_path(candidate)
if "#" in ref:
pre_hash, fragment = ref.split("#", 1)
# A '..' segment in the post-'#' text must never participate in
# normalization: it would collapse the hash-bearing segment and
# retarget the edge ("faq.md#/../other.md" must resolve to
# faq.md). Any other fragment gets its chance first: the whole
# token may name a real package file — '#' is legal in file AND
# directory names ("references/C#.md", "references/C#/readme.md"),
# from nested sources too ("../C#.md"). Only when the
# canonicalized whole token matches no file does the suffix
# become a section fragment and get stripped.
if "/.." in f"/{fragment}" or fragment.startswith(".."):
ref = pre_hash
elif files is not None and posixpath.normpath((base / ref).as_posix()) in files:
return normalize_relative_path((base / ref).as_posix())
else:
ref = pre_hash
return normalize_relative_path((base / ref).as_posix())
except ValueError:
return "__ESCAPES__"

View File

@ -152,6 +152,133 @@ def test_resource_graph_keeps_real_dotted_filenames(tmp_path):
assert not any(f["rule_id"] == "resource.missing" and f["path"] == "SKILL.md" for f in facts["findings"])
def test_resource_graph_strips_fragment_from_code_span_refs(tmp_path):
# A code span can carry a section anchor just like a markdown link
# target ("`references/faq.md#pricing`"). The anchor is not part of
# the path: it must not turn a valid reference into a
# resource.missing finding.
_write(
tmp_path / "SKILL.md",
_valid_skill() + "\nSee `references/faq.md#pricing` for details.\n",
)
_write(tmp_path / "references" / "faq.md", "# FAQ\n")
facts = analyze_skill_package(LocalDirectoryReader(tmp_path).read())
assert {"source": "SKILL.md", "target": "references/faq.md"} in facts["resources"]["edges"]
assert "references/faq.md" 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_dots_when_stripping_code_span_fragments(tmp_path):
# Stripping the fragment must not eat real dotted filenames: the
# extension dots of a fragment-bearing code span reference survive.
_write(
tmp_path / "SKILL.md",
_valid_skill() + "\nRead `references/v1.0.md#notes`.\n",
)
_write(tmp_path / "references" / "v1.0.md", "# v1.0\n")
facts = analyze_skill_package(LocalDirectoryReader(tmp_path).read())
assert {"source": "SKILL.md", "target": "references/v1.0.md"} in facts["resources"]["edges"]
assert not any(f["rule_id"] == "resource.missing" and f["path"] == "SKILL.md" for f in facts["findings"])
def test_resource_graph_prefers_hash_filenames_over_fragments(tmp_path):
# A package filename may legally contain '#': a code-span reference to
# `references/C#.md` must resolve to the real file, not be truncated to
# `references/C` by fragment stripping. The suffix is only treated as a
# fragment when the exact path does not exist.
_write(
tmp_path / "SKILL.md",
_valid_skill() + "\nSee `references/C#.md` and `references/faq.md#pricing`.\n",
)
_write(tmp_path / "references" / "C#.md", "# C#\n")
_write(tmp_path / "references" / "faq.md", "# FAQ\n")
facts = analyze_skill_package(LocalDirectoryReader(tmp_path).read())
assert {"source": "SKILL.md", "target": "references/C#.md"} in facts["resources"]["edges"]
assert {"source": "SKILL.md", "target": "references/faq.md"} in facts["resources"]["edges"]
assert not any(f["rule_id"] == "resource.missing" and f["path"] == "SKILL.md" for f in facts["findings"])
def test_resource_graph_prefers_hash_filenames_from_nested_sources(tmp_path):
# The literal-file check must canonicalize leading relative segments
# before comparing against the snapshot keys: from
# references/sub/guide.md, `../C#.md` joins to
# `references/sub/../C#.md`, which matches no key verbatim — without
# canonicalization the reference is truncated to `../C` and produces a
# false resource.missing plus an orphan report for the real file.
_write(tmp_path / "SKILL.md", _valid_skill())
_write(
tmp_path / "references" / "sub" / "guide.md",
_valid_skill("guide") + "\nSee `../C#.md`.\n",
)
_write(tmp_path / "references" / "C#.md", "# C#\n")
facts = analyze_skill_package(LocalDirectoryReader(tmp_path).read())
assert {"source": "references/sub/guide.md", "target": "references/C#.md"} in facts["resources"]["edges"]
assert not any(f["rule_id"] in {"resource.missing", "resource.escaping-link"} and f["path"] == "references/sub/guide.md" for f in facts["findings"])
def test_resource_graph_prefers_hash_directory_paths_over_stripping(tmp_path):
# '#' is legal in directory names too: the whole token
# `references/C#/readme.md` names a real file even though the text
# after '#' contains '/'. It must not be truncated to `references/C`
# — only a '..' segment in the post-'#' text forces the strip-first
# fallback ("faq.md#/../other.md").
_write(
tmp_path / "SKILL.md",
_valid_skill() + "\nSee `references/C#/readme.md`.\n",
)
_write(tmp_path / "references" / "C#" / "readme.md", "# C#\n")
facts = analyze_skill_package(LocalDirectoryReader(tmp_path).read())
assert {"source": "SKILL.md", "target": "references/C#/readme.md"} in facts["resources"]["edges"]
assert not any(f["rule_id"] == "resource.missing" and f["path"] == "SKILL.md" for f in facts["findings"])
def test_resource_graph_strips_fragment_before_normalizing_fallback(tmp_path):
# The exact-path preference must check the literal token: normalizing
# first collapses a hash-bearing segment ("faq.md#/.." -> "other.md")
# and can silently retarget the edge and orphan faq.md. The fragment is
# dropped first when the literal token is not a real file.
_write(
tmp_path / "SKILL.md",
_valid_skill() + "\nSee `references/faq.md#/../other.md`.\n",
)
_write(tmp_path / "references" / "faq.md", "# FAQ\n")
_write(tmp_path / "references" / "other.md", "# Other\n")
facts = analyze_skill_package(LocalDirectoryReader(tmp_path).read())
assert {"source": "SKILL.md", "target": "references/faq.md"} in facts["resources"]["edges"]
assert not any(f["rule_id"] == "resource.missing" and f["path"] == "SKILL.md" for f in facts["findings"])
def test_resource_graph_always_strips_markdown_link_fragments(tmp_path):
# In Markdown link syntax the text after '#' is always a URL fragment —
# a link to a file literally named "faq.md#pricing" would have to
# percent-encode it. The link must resolve to faq.md and stay broken
# (resource.missing) even when a file literally named
# references/faq.md#pricing exists; the bare-path pass must not see the
# link-internal text and resurrect the literal edge.
_write(
tmp_path / "SKILL.md",
_valid_skill() + "\nSee [FAQ](references/faq.md#pricing).\n",
)
_write(tmp_path / "references" / "faq.md#pricing", "# trap\n")
facts = analyze_skill_package(LocalDirectoryReader(tmp_path).read())
assert not any(e["target"] == "references/faq.md#pricing" for e in facts["resources"]["edges"])
assert any(f["rule_id"] == "resource.missing" and f["path"] == "SKILL.md" and "references/faq.md" in f["message"] for f in facts["findings"])
def test_resource_graph_ignores_eval_fixture_references(tmp_path):
_write(tmp_path / "SKILL.md", _valid_skill())
_write(