mirror of
https://github.com/getsentry/sentry-for-ai.git
synced 2026-09-14 14:28:22 +08:00
da5b79c583
Skill link checking has been silently off. build-skill-tree.sh has a
breadcrumb-link check, but it opens by skipping any skill that is neither a
router nor categorized:
if [[ "$role" != "router" && -z "$cat" ]]; then
continue
fi
Every skill is standalone now, so every skill takes that branch and no link is
checked at all. It was not turned off deliberately -- the guard was written when
standalone skills were the exception, and going flat quietly disabled it for
everything. That leaves the failure #308 fixed with nothing guarding it.
Check the built plugin trees rather than the source. A skill only becomes
self-contained once the build hydrates its declared references in, so the source
tree cannot answer the question on its own -- any check there has to
re-implement the hydrator's glob semantics and can drift from it. Building
first removes the simulation: what gets validated is the tree a user installs,
per agent, including each agent's own transform.
Every agent is built and checked, since layout and transforms differ between
them and a link can survive one while breaking another. The builds need no
network or credentials and take well under a second each, so the whole sweep
costs a few seconds per pull request.
Passing the source library in as well buys a cause instead of a symptom: a
target missing from the built skill but present in the library means the
manifest never declared it, so the error says to fix references.yml rather than
leaving a bare dangling-link report.
Runs in the existing validate job, which gains a uv install matching the one
deploy-plugins already uses.
100 lines
3.8 KiB
Python
Executable File
100 lines
3.8 KiB
Python
Executable File
#!/usr/bin/env -S uv run --script
|
|
# /// script
|
|
# requires-python = ">=3.9"
|
|
# ///
|
|
#
|
|
# validate-skill-links.py — Fail the build on a skill link that won't resolve.
|
|
#
|
|
# Every shipped skill must be self-contained: each relative link in its markdown
|
|
# has to point at a file that exists inside that skill's own directory. This
|
|
# runs against a BUILT plugin tree, so what it checks is the artifact users
|
|
# actually install — after reference hydration and after each agent's transform.
|
|
#
|
|
# Checking the built tree rather than the source is what makes this trustworthy.
|
|
# The source tree cannot answer the question on its own: skills link
|
|
# `references/...` paths that only exist once the build hydrates them, so any
|
|
# source-level check has to re-implement the hydrator's glob semantics and can
|
|
# drift from it. Here there is nothing to simulate.
|
|
#
|
|
# Pass --references (the source library) to get a cause rather than a symptom:
|
|
# a link whose target is missing from the built skill but present in the library
|
|
# means the skill's references.yml never declared it, so hydration skipped it.
|
|
# That is the failure mode a manifest makes easy to hit and hard to see.
|
|
#
|
|
# Usage:
|
|
# uv run --script validate-skill-links.py --skills <built-skills-dir> \
|
|
# [--references <source-library>] [--label <name>]
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
# Markdown inline links whose target ends in .md — the only kind that must
|
|
# resolve on disk. Anchors are stripped; external URLs are skipped.
|
|
LINK_RE = re.compile(r"\]\(([^)]+?\.md)(#[^)]*)?\)")
|
|
|
|
|
|
def check_skill(skill: Path, references: Path | None) -> list[str]:
|
|
hydrated_root = (skill / "references").resolve()
|
|
errors: list[str] = []
|
|
|
|
for md in sorted(skill.rglob("*.md")):
|
|
for target, _anchor in LINK_RE.findall(md.read_text(encoding="utf-8")):
|
|
if target.startswith(("http://", "https://", "mailto:")):
|
|
continue
|
|
|
|
resolved = (md.parent / target).resolve()
|
|
if resolved.is_file():
|
|
continue
|
|
|
|
where = f"{md.relative_to(skill.parent)} -> {target}"
|
|
|
|
# Missing. If the source library has it, the manifest is the cause.
|
|
if references is not None:
|
|
try:
|
|
rel = resolved.relative_to(hydrated_root).as_posix()
|
|
except ValueError:
|
|
rel = None
|
|
|
|
if rel and (references / rel).is_file():
|
|
errors.append(
|
|
f"{where}: '{rel}' exists in the reference library but was not "
|
|
"hydrated — add it to this skill's references.yml"
|
|
)
|
|
continue
|
|
|
|
errors.append(f"{where}: dangling link (no such file)")
|
|
|
|
return errors
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description="Validate that every skill link resolves.")
|
|
parser.add_argument("--skills", type=Path, required=True, help="Built skills dir (one subdir per skill)")
|
|
parser.add_argument("--references", type=Path, default=None, help="Source reference library, for better diagnostics")
|
|
parser.add_argument("--label", default="", help="Name for this tree in the summary line")
|
|
args = parser.parse_args()
|
|
|
|
skills = sorted(p for p in args.skills.iterdir() if (p / "SKILL.md").is_file())
|
|
if not skills:
|
|
parser.error(f"no skills found under {args.skills}")
|
|
|
|
failures = 0
|
|
for skill in skills:
|
|
errors = check_skill(skill, args.references)
|
|
failures += len(errors)
|
|
for error in errors:
|
|
print(f"error: {error}", file=sys.stderr)
|
|
|
|
label = f"{args.label}: " if args.label else ""
|
|
print(f"{label}checked links in {len(skills)} built skills, {failures} error(s)")
|
|
if failures:
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|