symlink-lint
Capability: substrate:framework-dev
Harness: agnostic
Lints the framework’s self-adoption skill symlinks — the canonical
.agents/skills/ links and their relays — against two invariants. This is
the single place the rationale lives; the module, the prek hook, and the
error output all point back here.
The framework’s wiring is one-directional (see
skills/setup/agents.md): the canonical
link lives under .agents/skills/ and points into the source tree, and
every other agent dir (.claude/, .github/, .windsurf/, .goose/,
.kiro/, …) carries a thin relay that points at the canonical entry.
Rules
- No cycles. A symlink must not resolve to its own directory or an
ancestor of it. Such a link makes a recursive
**/SKILL.mdscanner that follows symlinks (opencode’s skill loader does) re-enter the directory until an internal depth cap, yielding looped paths likeskills/x/x/x/.../SKILL.md. This rule is convention-agnostic — it names no agent dir, so a new one needs no change here. - Relay correctness. For a
magpie-<skill>link under an<agent>/skills/directory: the canonical entry under.agents/skills/must point at../../skills/<skill>, and every other agent dir’s relay must point at../../.agents/skills/magpie-<skill>(through the canonical, not straight at source). Catches relays that bypass.agents/— acyclic, so rule 1 alone would miss them.
Dangling / unresolvable links are skipped, never flagged: an adopter’s
canonical links legitimately dangle until the gitignored .apache-magpie/
snapshot is installed. Broken-target detection is setup verify’s job.
Prerequisites
- Runtime: Python 3.11+ (stdlib only) — no third-party dependencies; run it directly with
python3, or as the console scriptsymlink-lint. - CLIs:
git(optional) — locates the repo root viagit rev-parse --show-toplevel; falls back to the current working directory. - Credentials / auth: None.
- Network: None.
How to use
python3 tools/symlink-lint/src/symlink_lint/__init__.py
# or, once the workspace is synced:
uv run --project tools/symlink-lint symlink-lint
Exit 0 if clean; 1 otherwise, with each offender printed to stderr.
Wiring
Runs as the symlink-lint prek hook
(.pre-commit-config.yaml), fired on any staged symlink and always on
prek run --all-files (CI). Behaviour is locked by the pytest suite under
tests/, run by the workspace pytest hook + CI matrix.
Tests
uv run --project tools/symlink-lint pytest