252 lines
8.6 KiB
Python
Executable File
252 lines
8.6 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Repo-specific lints: skill frontmatter, internal path references, and
|
|
drift between the two installers (bin/link.sh and nix/home.nix).
|
|
|
|
Stdlib only, so it runs on any machine without a toolchain. Generic linters
|
|
(shellcheck, ruff, jq) live in bin/lint.sh instead.
|
|
"""
|
|
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
REPO = Path(__file__).resolve().parent.parent
|
|
|
|
# Upstream copies. They follow their own conventions and are replaced wholesale
|
|
# by a re-vendor, so linting them only produces noise we cannot act on.
|
|
VENDORED = ("skills/impeccable", "skills/humanizer")
|
|
|
|
TOP_DIRS = ("skills", "bin", "hooks", "scripts", "commands", "claude-md", "entry", "systemd", "nix")
|
|
EXTS = "md|sh|py|ts|mjs|js|json|nix|service|timer|yaml"
|
|
|
|
# Frontmatter keys the agents actually read. An unknown key is almost always a
|
|
# typo, and a typo'd key is ignored silently rather than reported.
|
|
KNOWN_KEYS = {
|
|
"name",
|
|
"description",
|
|
"user-invocable",
|
|
"disable-model-invocation",
|
|
"args",
|
|
"argument-hint",
|
|
"allowed-tools",
|
|
"license",
|
|
"metadata",
|
|
"version",
|
|
}
|
|
|
|
problems = []
|
|
|
|
|
|
def report(path, msg):
|
|
problems.append(f"{path}: {msg}")
|
|
|
|
|
|
def vendored(rel):
|
|
return any(str(rel).startswith(v) for v in VENDORED)
|
|
|
|
|
|
def repo_files(*globs):
|
|
for g in globs:
|
|
for p in sorted(REPO.glob(g)):
|
|
rel = p.relative_to(REPO)
|
|
if not vendored(rel):
|
|
yield p, rel
|
|
|
|
|
|
def read(p):
|
|
return p.read_text(encoding="utf-8", errors="replace")
|
|
|
|
|
|
# --- skill frontmatter -------------------------------------------------------
|
|
|
|
|
|
def frontmatter(text):
|
|
if not text.startswith("---\n"):
|
|
return None
|
|
end = text.find("\n---", 3)
|
|
if end == -1:
|
|
return None
|
|
return text[4 : end + 1]
|
|
|
|
|
|
def check_skills():
|
|
names = {}
|
|
for d in sorted((REPO / "skills").iterdir()):
|
|
if not d.is_dir() or vendored(d.relative_to(REPO)):
|
|
continue
|
|
skill = d / "SKILL.md"
|
|
if not skill.exists():
|
|
# *-common dirs are shared fragments pulled in by real skills.
|
|
if not (d / "COMMON.md").exists():
|
|
report(f"skills/{d.name}", "has neither SKILL.md nor COMMON.md")
|
|
continue
|
|
|
|
rel = skill.relative_to(REPO)
|
|
block = frontmatter(read(skill))
|
|
if block is None:
|
|
report(rel, "missing YAML frontmatter (--- ... ---)")
|
|
continue
|
|
|
|
keys = re.findall(r"(?m)^([A-Za-z][A-Za-z0-9_-]*):", block)
|
|
for k in keys:
|
|
if k not in KNOWN_KEYS:
|
|
report(rel, f"unknown frontmatter key `{k}`")
|
|
for dup in {k for k in keys if keys.count(k) > 1}:
|
|
report(rel, f"duplicate frontmatter key `{dup}`")
|
|
|
|
m = re.search(r"(?m)^name:[ \t]*(.*)$", block)
|
|
if not m:
|
|
report(rel, "frontmatter has no `name`")
|
|
else:
|
|
name = m.group(1).strip().strip("\"'")
|
|
if name != d.name:
|
|
report(rel, f"name `{name}` does not match directory `{d.name}`")
|
|
if name in names:
|
|
report(rel, f"name `{name}` already used by {names[name]}")
|
|
names[name] = rel
|
|
|
|
m = re.search(r"(?m)^description:[ \t]*(.*)$", block)
|
|
if not m:
|
|
report(rel, "frontmatter has no `description`")
|
|
elif not m.group(1).strip().strip("|>").strip():
|
|
# Block scalar: the text is on the following indented lines.
|
|
rest = block[m.end() :]
|
|
if not re.match(r"(?:\n[ \t]+\S)", rest):
|
|
report(rel, "`description` is empty")
|
|
|
|
|
|
# --- internal path references ------------------------------------------------
|
|
|
|
REF_RE = re.compile(
|
|
r"<skills-root>/(?P<sr>[A-Za-z0-9_./-]*?\.(?:" + EXTS + r"))(?![A-Za-z0-9_-])"
|
|
r"|(?<![\w/.~-])(?P<rp>(?:"
|
|
+ "|".join(TOP_DIRS)
|
|
+ r")/[A-Za-z0-9_./-]*?\.(?:"
|
|
+ EXTS
|
|
+ r"))(?![A-Za-z0-9_-])"
|
|
)
|
|
|
|
LINK_RE = re.compile(r"\]\((?!https?:|mailto:|#)([^)\s#]+)\)")
|
|
|
|
|
|
def check_refs():
|
|
for p, rel in repo_files("*.md", "*/*.md", "*/*/*.md", "*/*/*/*.md", "*/*.sh", "*/*.py", "*/*/*/*.py", "*/*.nix", "*/*.service"):
|
|
text = read(p)
|
|
for m in REF_RE.finditer(text):
|
|
if m.group("sr"):
|
|
target = REPO / "skills" / m.group("sr")
|
|
shown = "<skills-root>/" + m.group("sr")
|
|
else:
|
|
shown = m.group("rp")
|
|
# Same string can be repo-relative or relative to the skill dir:
|
|
# a SKILL.md naming a data file next to it means the latter.
|
|
bases = [REPO, p.parent]
|
|
if rel.parts[0] == "skills" and len(rel.parts) > 1:
|
|
bases.append(REPO / "skills" / rel.parts[1])
|
|
target = next((b / shown for b in bases if (b / shown).exists()), REPO / shown)
|
|
if not target.exists():
|
|
report(rel, f"references missing path `{shown}`")
|
|
|
|
if p.suffix == ".md":
|
|
for m in LINK_RE.finditer(text):
|
|
link = m.group(1)
|
|
if any(c in link for c in "<>$*~") or link.startswith("/"):
|
|
continue
|
|
if not (p.parent / link).exists():
|
|
report(rel, f"broken relative link `{link}`")
|
|
|
|
|
|
# --- installer drift ---------------------------------------------------------
|
|
|
|
# systemd/: link.sh links the unit files into ~/.config/systemd/user, home.nix
|
|
# declares equivalent units natively. Same result, different mechanism.
|
|
# hooks/README.md: docs, not a hook; harmless whether or not it is installed.
|
|
DRIFT_ALLOWED = ("systemd/", "hooks/README.md")
|
|
|
|
|
|
def expand(spec):
|
|
"""Repo-relative glob (dirs walked to their files) -> set of files."""
|
|
out = set()
|
|
for p in REPO.glob(spec.strip('";/ \t')):
|
|
if p.is_dir():
|
|
out |= {q.relative_to(REPO) for q in p.rglob("*") if q.is_file()}
|
|
elif p.is_file():
|
|
out.add(p.relative_to(REPO))
|
|
# Vendored trees move wholesale; __pycache__ is not tracked.
|
|
return {f for f in out if not vendored(f) and "__pycache__" not in f.parts}
|
|
|
|
|
|
def installed_by(path, var_re):
|
|
files = set()
|
|
for m in var_re.finditer(read(path)):
|
|
spec = m.group(1)
|
|
spec = re.sub(r"\$\{?[A-Za-z_][A-Za-z0-9_.:${}-]*\}?", "*", spec)
|
|
files |= expand(spec)
|
|
return files
|
|
|
|
|
|
def check_drift():
|
|
sh = installed_by(REPO / "bin/link.sh", re.compile(r'\$REPO"?/([^"\s]+)'))
|
|
nix = installed_by(REPO / "nix/home.nix", re.compile(r'\$\{agent-skills\}/([^"\s]+)'))
|
|
|
|
def ignored(f):
|
|
return str(f).startswith(DRIFT_ALLOWED)
|
|
|
|
for f in sorted(sh - nix):
|
|
if not ignored(f):
|
|
report("nix/home.nix", f"bin/link.sh installs `{f}`, this does not")
|
|
for f in sorted(nix - sh):
|
|
if not ignored(f):
|
|
report("bin/link.sh", f"nix/home.nix installs `{f}`, this does not")
|
|
|
|
|
|
# --- entry-file imports resolve ----------------------------------------------
|
|
|
|
|
|
def check_entry_imports():
|
|
"""`@~/.claude/x.md` in an entry file only resolves if both installers put
|
|
x.md there. A missing one makes every session start with a failed import."""
|
|
nix_dest = set(re.findall(r'"\.claude/([^"/]+\.md)"\.source', read(REPO / "nix/home.nix")))
|
|
|
|
link_sh = read(REPO / "bin/link.sh")
|
|
sh_dest = set(re.findall(r'"\$CLAUDE_HOME/([^"]+)"', link_sh))
|
|
if '"$CLAUDE_HOME/$(basename "$f")"' in link_sh:
|
|
sh_dest.discard('$(basename "$f")')
|
|
skipped = set(re.findall(r'basename "\$f"\)" = "([^"]+)"', link_sh))
|
|
sh_dest |= {p.name for _, p in repo_files("claude-md/*.md")} - skipped
|
|
|
|
for p, rel in repo_files("entry/*.md"):
|
|
for name in re.findall(r"@~/\.claude/([A-Za-z0-9_.-]+\.md)", read(p)):
|
|
if name not in sh_dest:
|
|
report("bin/link.sh", f"{rel} imports ~/.claude/{name}, which it never creates")
|
|
if name not in nix_dest:
|
|
report("nix/home.nix", f"{rel} imports ~/.claude/{name}, which it never creates")
|
|
|
|
|
|
# --- unit ExecStart paths ----------------------------------------------------
|
|
|
|
UNIT_PATH_RE = re.compile(r"(?:%h/tea/(?:yolo/)?agent-skills|\$\{repo\})/([A-Za-z0-9_./-]+)")
|
|
|
|
|
|
def check_unit_paths():
|
|
for p, rel in repo_files("systemd/*.service", "nix/home.nix"):
|
|
for m in UNIT_PATH_RE.finditer(read(p)):
|
|
if not (REPO / m.group(1)).exists():
|
|
report(rel, f"unit points at missing `{m.group(1)}`")
|
|
|
|
|
|
def main():
|
|
for check in (check_skills, check_refs, check_drift, check_entry_imports, check_unit_paths):
|
|
check()
|
|
for line in problems:
|
|
print(line)
|
|
if problems:
|
|
print(f"\n{len(problems)} problem(s).")
|
|
return 1
|
|
print("lint-repo: ok")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|