Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/skill-lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
38 changes: 37 additions & 1 deletion scripts/skill-lint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -196,6 +200,37 @@ except Exception as e:
done
}

# --- 6. Skill registration ------------------------------------------------------
# A skill directory is only half a skill: adding skills/<name>/ 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)
Expand All @@ -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
Expand All @@ -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 "$@"
22 changes: 22 additions & 0 deletions scripts/skill-registry-keys.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""Print the keys of one Record<string, string> 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<string, string> = \{(.*?)\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)
Loading