diff --git a/.github/workflows/skill-lint.yml b/.github/workflows/skill-lint.yml index a1c7760..a87050c 100644 --- a/.github/workflows/skill-lint.yml +++ b/.github/workflows/skill-lint.yml @@ -4,14 +4,26 @@ on: pull_request: paths: - "skills/**" + # The registration check reads these, so a change to any of them can + # break the lint without touching skills/ at all: dropping a + # CATEGORY_MAP entry, deleting a docs page, or removing a roster row. + - "lib/skill-types.ts" + - "docs/ai/skills/**" - "scripts/skill-lint.sh" + - "scripts/skill-registry-keys.py" - ".github/workflows/skill-lint.yml" push: branches: - main paths: - "skills/**" + # The registration check reads these, so a change to any of them can + # break the lint without touching skills/ at all: dropping a + # CATEGORY_MAP entry, deleting a docs page, or removing a roster row. + - "lib/skill-types.ts" + - "docs/ai/skills/**" - "scripts/skill-lint.sh" + - "scripts/skill-registry-keys.py" - ".github/workflows/skill-lint.yml" jobs: diff --git a/scripts/skill-lint.sh b/scripts/skill-lint.sh index d937a3a..d3d0382 100755 --- a/scripts/skill-lint.sh +++ b/scripts/skill-lint.sh @@ -22,6 +22,10 @@ CROSS_PACKAGE_PATH_ALLOWLIST=( CITATION_ALLOWLIST=( "skills/codebase-docs/eval.json:foo" ) +# Skills knowingly absent from the docs roster and the site registry. Every +# entry is a documented gap, not a pass: `enclave` shipped before this check +# existed and still has no docs/ai/skills/ page. +REGISTRATION_ALLOWLIST=(enclave) in_allowlist() { local needle="$1"; shift @@ -196,6 +200,37 @@ except Exception as e: done } +# --- 6. Skill registration ------------------------------------------------------ +# A skill directory is only half a skill: adding skills// without +# registering it leaves an orphan package in the docs tree and renders the +# skill degraded on the site. Nothing above notices, because skills are +# filesystem-discovered (lib/skills.ts) and every missing registry entry has a +# silent fallback -- a CATEGORY_MAP miss falls to "other", and a SKILL_PURPOSE +# miss falls back to a trigger phrase scraped from the description. Four +# registration points, checked per skill directory. +registry_keys() { + python3 scripts/skill-registry-keys.py "$1" +} + +check_skill_registration() { + local name category_keys purpose_keys + category_keys=$(registry_keys CATEGORY_MAP) || exit 1 + purpose_keys=$(registry_keys SKILL_PURPOSE) || exit 1 + + for name in $(find skills -mindepth 1 -maxdepth 1 -type d -exec basename {} \; | sort); do + in_allowlist "$name" "${REGISTRATION_ALLOWLIST[@]}" && continue + + [ -f "docs/ai/skills/$name.md" ] || + fail "skills/$name/: no docs/ai/skills/$name.md (orphan skill package)" + grep -q "(\./$name\.md)" docs/ai/skills/README.md || + fail "skills/$name/: not rostered in docs/ai/skills/README.md" + grep -qx "$name" <<<"$category_keys" || + fail "skills/$name/: no CATEGORY_MAP entry in lib/skill-types.ts (site renders it under \"other\")" + grep -qx "$name" <<<"$purpose_keys" || + fail "skills/$name/: no SKILL_PURPOSE entry in lib/skill-types.ts (site scrapes a trigger phrase instead)" + done +} + mentions_file="" main() { mentions_file=$(mktemp) @@ -209,6 +244,7 @@ main() { check_citations_resolve check_cross_package_paths check_eval_json + check_skill_registration if [ "${#violations[@]}" -gt 0 ]; then echo "skill-lint: ${#violations[@]} violation(s) found:" >&2 @@ -219,7 +255,7 @@ main() { exit 1 fi - echo "skill-lint: OK (frontmatter, size, references, citations, paths, eval.json across skills/)" + echo "skill-lint: OK (frontmatter, size, references, citations, paths, eval.json, registration across skills/)" } main "$@" diff --git a/scripts/skill-registry-keys.py b/scripts/skill-registry-keys.py new file mode 100644 index 0000000..7273851 --- /dev/null +++ b/scripts/skill-registry-keys.py @@ -0,0 +1,22 @@ +"""Print the keys of one Record literal in lib/skill-types.ts. + +skill-lint reads CATEGORY_MAP and SKILL_PURPOSE from it to check that every +skill directory is registered for the site. Keys are written both quoted +("delivery-ship") and bare (sync), so a grep for either form alone misses half +of them. +""" + +import re +import sys + +name = sys.argv[1] +src = open("lib/skill-types.ts").read() +block = re.search( + r"export const %s: Record = \{(.*?)\n\};" % re.escape(name), + src, + re.S, +) +if not block: + sys.exit("skill-registry-keys: cannot find %s in lib/skill-types.ts" % name) +for key in re.findall(r'^\s*"?([A-Za-z0-9_-]+)"?\s*:', block.group(1), re.M): + print(key)