Review of the ADR-0020 gate found four ways it could exit 0 without measuring, and one way it hard-failed a repo it had no business failing. On a gate shipping hot with no baseline, a silent pass is the worst outcome available and a false block is the second worst. Consumer resolution was the blocker. _authoring_root() fell back to the nearest .git, so it returned truthy in ANY git repo; _collect_authoring_root() then contributed nothing and the deployed-tree branch was dead code in precisely the consumer case it exists for. A consumer repo routing to an installed sibling got an unblockable ERROR, and deleting .git "fixed" it. It now keys on which of the two walk-up passes matched. A name-count delta was tried first and is wrong: a single-plugin monorepo re-collects its own package and adds no new name, so the delta reads zero and drags the deployed trees — including a global ~/.claude — back into the universe. That reintroduces the install-dependence ADR-0020 forbids, one layer down. The three silent passes: an indented `---` inside a block scalar truncated the frontmatter and reclassified the rest of the description as body; a non-string description was str()-coerced, so `description: true` measured as the four-character "True"; and an unterminated fence blanked the rest of the body, disabling the ERROR-tier references/ check and the gotcha counts. Two measurement defects came with them. The awk line/word counts discarded awk's exit status, so an unreadable file passed both spec ceilings in total silence, and awk NR/NF disagreed with the audit script's splitlines()/split() on Unicode whitespace — the "fix one gate, get blocked by the other" bug, on the two axes the differential test deliberately excluded. Both counts now run in the Python block that already reads the file. A type error also no longer reports itself as a syntax error. Also: glob metacharacters in the checkout path silently disabled the resolver; re.I was applied to some extraction patterns and not others; agent-audit missed `tools:` written as a YAML block sequence, the shape Copilot files use; and a nonexistent agent file raised a bare FileNotFoundError instead of a diagnostic. The shared resolver block stays byte-identical across all three scripts. Corpus output is unchanged — 26 description FAIL, 9 body FAIL, 2 dangling, 0 missing references, 58 SUGGESTIONs — so no documented count moves. Refs: #99 ADR: 0020 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015W3iwF9ncfRZddGBxsMCYi
1089 lines
53 KiB
Bash
Executable File
1089 lines
53 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
set -euo pipefail
|
||
|
||
# Two independent gate families live in this script. Do not conflate them.
|
||
#
|
||
# 1. SPEC CONFORMANCE (MAX_LINES / MAX_WORDS, unchanged). Enforces
|
||
# agentskills.io's skill-authoring.md guidance: keep SKILL.md within 500
|
||
# lines and roughly 5,000 tokens, so the full body doesn't crowd out
|
||
# conversation history and other active skills once loaded into context.
|
||
# MAX_WORDS counts the WHOLE FILE, frontmatter included.
|
||
#
|
||
# 2. CONTEXT BUDGET (ADR-0020: description chars, body-only words, resolvable
|
||
# boundary targets). A skill's name + description is preloaded into every
|
||
# agent's context every session whether or not the skill is ever invoked;
|
||
# the body is loaded only on invocation, and then competes with the caller's
|
||
# live conversation. Those are different costs with different ceilings, so
|
||
# they get their own numbers and their own measurements.
|
||
#
|
||
# The two families measure different things on purpose and neither replaces the
|
||
# other: 2,770 whole-file words is a conformance backstop, 900 body-only words
|
||
# is a quality gate, and a file can sit well inside one while failing the other.
|
||
#
|
||
# Vale can't express any of this (its checks operate on text patterns, not raw
|
||
# file size), so this is a plain script instead of a Vale rule.
|
||
#
|
||
# Both spec ceilings are inclusive: a file at exactly MAX_LINES or MAX_WORDS
|
||
# passes, and only one past it fails. That matches
|
||
# skill-audit/scripts/validate.sh, which has always used `line_count <= 500` as
|
||
# its pass condition — the two previously disagreed at exactly 500 lines, so a
|
||
# SKILL.md could pass its own audit and still be blocked by the commit hook.
|
||
# The ADR-0020 ceilings are inclusive the same way.
|
||
#
|
||
# Token counts aren't computed exactly here — a whitespace word count is used
|
||
# as a proxy (Python's str.split(), the same primitive
|
||
# skill-audit/scripts/validate.sh applies to these two constants; `wc -w`
|
||
# disagrees with it on Unicode separators, which is why the awk pass that used
|
||
# to live in the loop below is gone). Measured over this repo's 39 in-scope SKILL.md files, characters per
|
||
# word runs min 5.97 / median 6.79 / mean 6.77 / max 7.22. At the standard
|
||
# ~4-characters-per-token English approximation that is 1.49 / 1.70 / 1.69 /
|
||
# 1.81 tokens per word.
|
||
#
|
||
# MAX_WORDS=2770 is therefore calibrated to the corpus WORST case rather than
|
||
# its median: 2770 words at the densest observed 7.22 chars/word is ~20,000
|
||
# characters, or ~5,000 tokens at the 4-characters-per-token approximation. So
|
||
# what this gate guarantees is "under 5,000 tokens even for the densest prose
|
||
# the corpus has produced" — the earlier median-calibrated MAX_WORDS=2900 let
|
||
# such a file sit at exactly the ceiling and still spend ~5,240 tokens. A
|
||
# median-density file at 2770 words spends ~4,700 tokens, so typical prose
|
||
# gives up ~130 words of headroom to close that gap. The largest SKILL.md in
|
||
# the repo is 2,760 words whole-file (skill-author), twelve words under the
|
||
# ceiling — this is a gate two files have already grown into, not headroom.
|
||
#
|
||
# It is a one-sided proxy in the useful direction — nothing under the word
|
||
# ceiling is wildly over the token ceiling — but it is not exact BPE
|
||
# tokenization and does not replace one. Re-measure the corpus before treating
|
||
# any of these numbers as still current.
|
||
#
|
||
# python3 AND PyYAML are required for the ADR-0020 half, and both are hard
|
||
# dependencies rather than best-effort: python3 because pre-commit (which is how
|
||
# this script runs) is itself a Python application, and PyYAML because the
|
||
# hand-rolled folding reader that used to cover its absence disagreed with a
|
||
# real parser across the FAIL boundary. Two readers that measure the same
|
||
# description differently is worse than one reader that refuses to start.
|
||
|
||
# These constants are intentionally duplicated in
|
||
# skill-audit/scripts/validate.sh (Python) rather than shared from one file:
|
||
# this script is a standalone bash pre-commit hook, that one is an in-skill
|
||
# Python validator invoked in a different context (same rationale as
|
||
# vale-wrap.sh's per-plugin duplication — see its own header comment).
|
||
# tests/test-skill-size-check.sh asserts both files agree on these values, so
|
||
# drift between them fails CI rather than silently diverging.
|
||
#
|
||
# The ADR-0020 constants below are duplicated the same way and carry the same
|
||
# warning: skill-audit/scripts/validate.sh holds a second copy of
|
||
# DESC_SUGGEST_CHARS / DESC_MAX_CHARS / BODY_SUGGEST_WORDS / BODY_MAX_WORDS,
|
||
# and agent-audit/scripts/validate.sh holds a third copy of the two
|
||
# description constants (agents take the description gates and, per ADR-0020,
|
||
# deliberately take NO body word gate). If they drift, this audit reports a
|
||
# skill ready to ship that the commit hook then rejects.
|
||
MAX_LINES=500
|
||
MAX_WORDS=2770
|
||
|
||
# ADR-0020 context-budget gates. SUGGESTION does not fail; FAIL does.
|
||
DESC_SUGGEST_CHARS=250
|
||
DESC_MAX_CHARS=400
|
||
BODY_SUGGEST_WORDS=600
|
||
BODY_MAX_WORDS=900
|
||
|
||
FAIL=0
|
||
|
||
for f in "$@"; do
|
||
# NOT a silent skip — see the matching note on the Python side. A broken
|
||
# symlink named SKILL.md is storable in git and a directory named SKILL.md
|
||
# reaches this hook the same way; both used to make the whole run exit 0 with
|
||
# no output at all, which is the one thing this script must never do.
|
||
if [[ ! -f "$f" ]]; then
|
||
if [[ -d "$f" ]]; then
|
||
why="is a directory, not a file"
|
||
elif [[ -L "$f" ]]; then
|
||
why="is a symlink that does not resolve to a file"
|
||
elif [[ -e "$f" ]]; then
|
||
why="is not a regular file"
|
||
else
|
||
why="does not exist"
|
||
fi
|
||
echo "ERROR: $f $why, so the line and word ceilings could not be measured. A path this hook was handed and could not read does not get to pass in silence." >&2
|
||
FAIL=1
|
||
continue
|
||
fi
|
||
|
||
# The MAX_LINES / MAX_WORDS ceilings are NOT measured here. They used to be,
|
||
# in a single awk pass, and that pass was wrong twice over:
|
||
# * `read -r lines words <<< "$(awk ...)"` discarded awk's exit status, so a
|
||
# file awk could not read yielded empty variables, bash arithmetic read
|
||
# them as 0, and both ceilings passed in total silence — the one outcome
|
||
# this script forbids itself.
|
||
# * awk's NR/NF do not agree with the Python splitlines()/split() that
|
||
# skill-audit/scripts/validate.sh uses for the SAME two constants.
|
||
# splitlines() also breaks on \x0b \x0c \x1c \x1d \x1e \x85 U+2028 U+2029
|
||
# and split() on every Unicode space, so a body padded with U+2028 read as
|
||
# 6 lines here and 606 lines there — hook green, audit FAIL.
|
||
# One implementation now owns both: the Python block below already reads every
|
||
# file (with a real diagnostic on failure), so it counts there.
|
||
done
|
||
|
||
if ! command -v python3 > /dev/null 2>&1; then
|
||
echo "ERROR: python3 is required for the ADR-0020 description/body/boundary-target gates but was not found on PATH." >&2
|
||
echo " Why: skipping them would be a vacuous pass — the hook would go green having checked only the spec ceilings." >&2
|
||
echo " Fix: install python3 (pre-commit itself is a Python application, so it is almost certainly already present)." >&2
|
||
exit 1
|
||
fi
|
||
|
||
if ! python3 -c 'import yaml' > /dev/null 2>&1; then
|
||
echo "ERROR: PyYAML is required for the ADR-0020 description/body/boundary-target gates but is not importable by python3." >&2
|
||
echo " Why: the description VALUE has to be measured after YAML folding is resolved, and the hand-rolled reader that used to stand in for PyYAML disagreed with it across the 400-character FAIL boundary. Falling back would make the verdict depend on which reader ran." >&2
|
||
echo " Fix: python3 -m pip install PyYAML (or your distro's python3-yaml package)." >&2
|
||
exit 1
|
||
fi
|
||
|
||
if ! python3 -u - \
|
||
"$DESC_SUGGEST_CHARS" "$DESC_MAX_CHARS" \
|
||
"$BODY_SUGGEST_WORDS" "$BODY_MAX_WORDS" "$MAX_WORDS" "$MAX_LINES" "$@" <<'PYTHON'
|
||
import glob
|
||
import os
|
||
import re
|
||
import sys
|
||
|
||
import yaml
|
||
|
||
DESC_SUGGEST_CHARS = int(sys.argv[1])
|
||
DESC_MAX_CHARS = int(sys.argv[2])
|
||
BODY_SUGGEST_WORDS = int(sys.argv[3])
|
||
BODY_MAX_WORDS = int(sys.argv[4])
|
||
MAX_WORDS = int(sys.argv[5])
|
||
MAX_LINES = int(sys.argv[6])
|
||
files = sys.argv[7:]
|
||
|
||
failed = False
|
||
|
||
|
||
def error(msg):
|
||
global failed
|
||
failed = True
|
||
print("ERROR: %s" % msg, file=sys.stderr)
|
||
|
||
|
||
def suggest(msg):
|
||
# stdout, not stderr, and never touches the exit code. The hook is declared
|
||
# `verbose: true` in .pre-commit-config.yaml so this actually reaches a
|
||
# human — pre-commit prints nothing at all for a passing hook otherwise,
|
||
# which is the exact way ADR-0013 records Vale warnings going invisible.
|
||
print("SUGGESTION: %s" % msg)
|
||
|
||
|
||
def info(msg):
|
||
# A check that DECLINED to run says so out loud. The one thing this script
|
||
# must never do is stay quiet about a measurement it did not take.
|
||
print("INFO: %s" % msg)
|
||
|
||
|
||
# ===== BEGIN ADR-0020 SHARED BOUNDARY RESOLVER =====
|
||
# ONE resolver, embedded VERBATIM in three scripts:
|
||
# scripts/skill-size-check.sh
|
||
# plugins/kyberforge/.apm/skills/skill-audit/scripts/validate.sh
|
||
# plugins/kyberforge/.apm/skills/agent-audit/scripts/validate.sh
|
||
# The block between these markers must stay byte-identical in all three. It is
|
||
# copied rather than imported because a cache-installed plugin's scripts cannot
|
||
# read files outside their own plugin directory, so there is no single file all
|
||
# three can share (same constraint that forces the ADR-0020 constants to be
|
||
# duplicated). Edit one copy, then paste it over the other two.
|
||
#
|
||
# Requires: glob, os, re, yaml (imported by the host script; PyYAML is a hard
|
||
# dependency, preflighted in bash before the interpreter starts).
|
||
|
||
# --- Input ----------------------------------------------------------------
|
||
# Every file this resolver's callers read goes through read_text(), which pins
|
||
# UTF-8 explicitly instead of inheriting locale.getpreferredencoding(). Under
|
||
# LC_ALL=C that inherited encoding is ASCII, so a perfectly ordinary em dash in
|
||
# a SKILL.md aborted the run with a bare UnicodeDecodeError traceback — loud,
|
||
# but pointing at the interpreter rather than at the file or the fix. A file
|
||
# that genuinely is not UTF-8 still fails; it just says so.
|
||
|
||
|
||
class EncodingError(Exception):
|
||
pass
|
||
|
||
|
||
def read_text(path):
|
||
"""File contents as text, UTF-8, with a diagnostic instead of a traceback."""
|
||
try:
|
||
with open(path, encoding='utf-8') as fh:
|
||
return fh.read()
|
||
except UnicodeDecodeError as exc:
|
||
raise EncodingError(
|
||
"not valid UTF-8 (%s at byte %d) — re-save the file as UTF-8; "
|
||
"this gate does not guess at other encodings"
|
||
% (exc.reason, exc.start))
|
||
|
||
|
||
# --- Universe ------------------------------------------------------------
|
||
# The set of names a boundary clause may resolve against is derived from an
|
||
# AUTHORING ROOT found by walking up FROM THE TARGET FILE. It is NEVER derived
|
||
# from this script's own location: deriving it from ${BASH_SOURCE} leaked
|
||
# holocron's 39-skill universe into every consumer repo that ran this hook
|
||
# through pre-commit, so a consumer skill routing to `skill-audit` resolved
|
||
# against a plugin it had never installed.
|
||
#
|
||
# An authoring root is the nearest ancestor holding plugins/*/.apm/skills/ or
|
||
# plugins/*/.apm/agents/ (a plugin monorepo), falling back to the nearest
|
||
# ancestor holding .git. When one is found the universe is:
|
||
# 1. every skill and agent under <root>/plugins/*/ — sibling plugins resolve,
|
||
# which is what a monorepo means,
|
||
# 2. the target's own apm package,
|
||
# 3. the packages that package DECLARES in apm.yml dependencies.apm.
|
||
# Deployed .claude/ and .agents/ trees are deliberately NOT consulted when the
|
||
# root came from the plugins/ probe. They are `apm install` output, gitignored,
|
||
# and present only on a machine that has run it: four cross-plugin targets in
|
||
# this repo (gitea-branches -> git-branches, gitea-branches -> git-history,
|
||
# gitea-issues -> git-branches, gitea-workflow -> git-workflow) resolved through
|
||
# .claude/skills/ alone, so the same commit measured 2 dangling targets on a
|
||
# developer machine and 6 on a fresh clone. A gate shipping hot with no baseline
|
||
# cannot give two answers.
|
||
#
|
||
# Deployed trees ARE used when no plugin monorepo was found — whether the walk
|
||
# landed on a bare .git ancestor or on nothing at all. That is the consumer
|
||
# case: the file being checked lives in or beside a deployed tree, inside an
|
||
# ordinary git repo, with no monorepo to read. The two cases are told apart by
|
||
# which probe matched, never by how many names a root contributed; see
|
||
# known_targets().
|
||
|
||
|
||
def _is_fs_root(path):
|
||
return os.path.dirname(path) == path
|
||
|
||
|
||
def _collect_package(pkg_dir, names):
|
||
"""Add every skill/agent name a package directory exposes, any layout."""
|
||
# glob.escape() the DIRECTORY only. A checkout path containing `[`, `]`,
|
||
# `*` or `?` — a worktree named `feature[2]`, say — otherwise turns the
|
||
# whole pattern into a character class that matches nothing, and the
|
||
# resolver degrades to the "DID NOT RUN" INFO with rc=0 across every file
|
||
# in the tree. The wildcards in `sub` are the intended ones and stay raw.
|
||
safe_dir = glob.escape(pkg_dir)
|
||
for sub in ('.apm/skills/*/', 'skills/*/'):
|
||
for path in glob.glob(os.path.join(safe_dir, sub)):
|
||
names.add(os.path.basename(path.rstrip('/')).lower())
|
||
for sub in ('.apm/agents/*.md', 'agents/*.md'):
|
||
for path in glob.glob(os.path.join(safe_dir, sub)):
|
||
base = os.path.basename(path)
|
||
if base.endswith('.agent.md'):
|
||
base = base[:-len('.agent.md')]
|
||
else:
|
||
base = base[:-len('.md')]
|
||
names.add(base.lower())
|
||
|
||
|
||
def _apm_package_root(start_dir):
|
||
"""Nearest ancestor that is an apm package root (apm.yml or .apm/).
|
||
|
||
The filesystem root is never a candidate: a stray /.apm/skills/ — a
|
||
scaffolding test's leftover, say, and one really does exist on at least one
|
||
machine here — would otherwise become the package root of every path on it.
|
||
Capped at ten levels so a pathological path can't become a filesystem
|
||
crawl; that covers every real layout by a wide margin.
|
||
"""
|
||
current = os.path.abspath(start_dir)
|
||
for _ in range(10):
|
||
if _is_fs_root(current):
|
||
return None
|
||
if (os.path.isfile(os.path.join(current, 'apm.yml'))
|
||
or os.path.isdir(os.path.join(current, '.apm'))):
|
||
return current
|
||
current = os.path.dirname(current)
|
||
return None
|
||
|
||
|
||
def _authoring_root(start_dir):
|
||
"""Nearest ancestor that is a plugin monorepo, else the nearest .git tree.
|
||
|
||
Returns (root, matched_plugins_probe). The flag reports WHICH probe
|
||
matched: True for the plugins/*/.apm/{skills,agents} glob, False for the
|
||
.git fallback and for no match at all. known_targets() needs that
|
||
distinction — only a real plugins/ root makes the deployed trees
|
||
redundant, and a name-count delta cannot tell the two apart.
|
||
|
||
Two passes, not one interleaved walk: a nested .git (a submodule, a
|
||
worktree of a sub-package) must not win over a real plugins/ root further
|
||
up. Both passes stop before the filesystem root for the same reason
|
||
_apm_package_root does.
|
||
"""
|
||
probes = (
|
||
lambda d: bool(glob.glob(os.path.join(glob.escape(d), 'plugins', '*', '.apm', 'skills'))
|
||
or glob.glob(os.path.join(glob.escape(d), 'plugins', '*', '.apm', 'agents'))),
|
||
lambda d: os.path.exists(os.path.join(d, '.git')))
|
||
for index, probe in enumerate(probes):
|
||
current = os.path.abspath(start_dir)
|
||
for _ in range(12):
|
||
if _is_fs_root(current):
|
||
break
|
||
if probe(current):
|
||
return current, index == 0
|
||
current = os.path.dirname(current)
|
||
return None, False
|
||
|
||
|
||
def _collect_authoring_root(root, names):
|
||
"""Every plugin in the monorepo contributes its names."""
|
||
for pkg in glob.glob(os.path.join(glob.escape(root), 'plugins', '*')):
|
||
if os.path.isdir(pkg):
|
||
_collect_package(pkg, names)
|
||
|
||
|
||
def _declared_dependency_dirs(pkg_dir):
|
||
"""Directories of the apm packages pkg_dir's manifest DECLARES.
|
||
|
||
Reads dependencies.apm and resolves each entry to a directory on disk:
|
||
a monorepo-relative `path:` (against the package root and the nearest
|
||
ancestor manifest, which is the monorepo root) or an installed
|
||
apm_modules/<name>/. Entries that resolve to nothing are skipped — an
|
||
undeployed dependency contributes no names rather than an error.
|
||
"""
|
||
manifest = os.path.join(pkg_dir, 'apm.yml')
|
||
if not os.path.isfile(manifest):
|
||
return []
|
||
try:
|
||
data = yaml.safe_load(read_text(manifest)) or {}
|
||
except Exception:
|
||
return []
|
||
if not isinstance(data, dict):
|
||
return []
|
||
deps = data.get('dependencies')
|
||
deps = deps.get('apm') if isinstance(deps, dict) else None
|
||
if not isinstance(deps, list):
|
||
return []
|
||
|
||
roots = [pkg_dir]
|
||
ancestor = os.path.dirname(os.path.abspath(pkg_dir))
|
||
for _ in range(10):
|
||
if _is_fs_root(ancestor):
|
||
break
|
||
if os.path.isfile(os.path.join(ancestor, 'apm.yml')):
|
||
roots.append(ancestor)
|
||
break
|
||
ancestor = os.path.dirname(ancestor)
|
||
|
||
found = []
|
||
for entry in deps:
|
||
candidates = []
|
||
if isinstance(entry, dict):
|
||
rel = entry.get('path')
|
||
name = entry.get('name')
|
||
if not name and rel:
|
||
name = os.path.basename(str(rel).rstrip('/'))
|
||
if rel:
|
||
candidates.extend(os.path.join(r, str(rel)) for r in roots)
|
||
if name:
|
||
candidates.append(os.path.join(pkg_dir, 'apm_modules', str(name)))
|
||
elif isinstance(entry, str):
|
||
name = re.split(r'[#@]', entry)[0].strip().rstrip('/').split('/')[-1]
|
||
if name:
|
||
candidates.append(os.path.join(pkg_dir, 'apm_modules', name))
|
||
candidates.extend(os.path.join(r, 'plugins', name) for r in roots)
|
||
for candidate in candidates:
|
||
if os.path.isdir(candidate):
|
||
found.append(candidate)
|
||
return found
|
||
|
||
|
||
def _deployed_roots(start_dir):
|
||
""".claude/ and .agents/ trees above start_dir — what a host really sees.
|
||
|
||
Consulted ONLY when no plugin monorepo root was found; see the header. The
|
||
filesystem root is skipped for the same reason _apm_package_root skips it:
|
||
a stray /.claude/skills/ must not join every path's universe.
|
||
"""
|
||
found = []
|
||
current = os.path.abspath(start_dir)
|
||
for _ in range(10):
|
||
if _is_fs_root(current):
|
||
break
|
||
for name in ('.claude', '.agents'):
|
||
base = os.path.join(current, name)
|
||
if os.path.isdir(base):
|
||
found.append(base)
|
||
current = os.path.dirname(current)
|
||
return found
|
||
|
||
|
||
def known_targets(start_dir):
|
||
"""Every skill/agent name a boundary clause in start_dir may name."""
|
||
names = set()
|
||
start = os.path.abspath(start_dir)
|
||
|
||
# Siblings: a cache-installed plugin and a deployed .claude/skills/ tree
|
||
# both put peers one level up, with no plugins/ directory above them. The
|
||
# grandparent is guarded against the filesystem root exactly like the two
|
||
# walk-up loops above — for a start dir of /skills/<x> the grandparent is
|
||
# `/`, and collecting there picks up this machine's stray /.apm/skills/.
|
||
parent = os.path.dirname(start)
|
||
grandparent = os.path.dirname(parent)
|
||
if (os.path.basename(parent) in ('skills', 'agents')
|
||
and os.path.isdir(parent) and not _is_fs_root(grandparent)):
|
||
_collect_package(grandparent, names)
|
||
|
||
package = _apm_package_root(start)
|
||
if package:
|
||
_collect_package(package, names)
|
||
for dep_dir in _declared_dependency_dirs(package):
|
||
_collect_package(dep_dir, names)
|
||
|
||
# A .git ancestor is an authoring root only if it actually holds plugins.
|
||
# _authoring_root() falls back to the nearest .git, so it is truthy in ANY
|
||
# git repo; without the distinction that fallback wins in every consumer
|
||
# checkout, _collect_authoring_root() contributes nothing, and the deployed
|
||
# branch below is dead code in the exact case it exists for. So condition
|
||
# on WHICH probe matched, which _authoring_root() reports directly. A
|
||
# name-count delta looks equivalent and is not: _collect_authoring_root()
|
||
# re-collects the checked file's own plugin, whose names the blocks above
|
||
# already added, so a one-plugin monorepo shows a delta of zero and would
|
||
# wrongly reach for the deployed trees — including the user's global
|
||
# ~/.claude/skills, making the verdict depend on what happens to be
|
||
# installed (ADR-0020 lines 118-127).
|
||
root, root_has_plugins = _authoring_root(start)
|
||
if root:
|
||
_collect_authoring_root(root, names)
|
||
if not root_has_plugins:
|
||
for base in _deployed_roots(start):
|
||
_collect_package(base, names)
|
||
return names
|
||
|
||
|
||
# --- Extraction -----------------------------------------------------------
|
||
# False positives are the design constraint here, not recall. The rules:
|
||
# * A BARE target must be hyphenated AND sit in a boundary sentence (one
|
||
# carrying "do not"/"instead"/"rather than"/"not for"). Without the second
|
||
# condition, pc-run's "run pre-commit hooks" reads as a route to a
|
||
# non-existent `pre-commit` skill.
|
||
# * A BARE arrow target counts only in ADR-0020's compressed boundary form,
|
||
# `Not <thing> -> <skill-name>`. Without that, diagnose's process chain
|
||
# "fix -> regression-test" reads as a route to `regression-test`.
|
||
# * A backticked hyphenated token counts only inside a boundary sentence.
|
||
# Unconditionally, `pre-push` or `commit-msg` in a TRIGGER clause is a hard
|
||
# FAIL with no escape hatch. Gating it costs nothing (measured over this
|
||
# corpus: 54 targets before and after); DELETING it costs 7 real targets
|
||
# across three gitea skills, so it is gated, not removed.
|
||
# * SINGLE-WORD targets are deliberately NOT matchable bare — `research`,
|
||
# `triage`, `forge`, `prototype` and `tdd` are all real skill names and all
|
||
# ordinary English, so a bare-word rule would flag most of the corpus. A
|
||
# single-word target must be written `` `forge` `` or /forge to be seen.
|
||
# That is a known recall limitation, accepted over the false positives.
|
||
# Tool names (Read/Write/Edit) are excluded by the lowercase-only pattern; MCP
|
||
# tool names (issue_write) by its rejection of underscores; file names by its
|
||
# rejection of dots and slashes.
|
||
#
|
||
# ATTRIBUTIVE USE. The boundary-sentence gate above does NOT solve the
|
||
# `pre-commit` false positive, and the comment that claimed it did was wrong:
|
||
# "instead", "rather than", "do not" and "not for" are exactly the words a
|
||
# boundary clause uses, so the gate is open precisely where the risk is. All of
|
||
# these were hard dangling FAILs with no suppression:
|
||
# Use pre-commit hooks instead of ad-hoc scripts.
|
||
# Invoke the pull-request template instead of writing one by hand.
|
||
# Use conventional-commits formatting rather than free-form messages.
|
||
# Composes label-resolution logic instead of duplicating it.
|
||
# Do not use for X — run the `pre-push` hooks instead.
|
||
# What separates every one of them from a real route is grammar, not marking:
|
||
# the hyphenated token is a compound MODIFIER of the noun that follows it
|
||
# ("pre-commit hooks", "pull-request template"), where a route target is
|
||
# terminal — followed by punctuation, a conjunction, or a boundary word. So a
|
||
# target whose next token is an ordinary lowercase noun is CONFIRM-ONLY: it
|
||
# still resolves and still counts as a route when the name exists, but it can
|
||
# never raise a dangling error on its own.
|
||
#
|
||
# This is deliberately NOT the simpler "only marked targets may dangle" rule,
|
||
# which would have been wrong here: BOTH live true positives in this corpus are
|
||
# BARE — research's "(use neuledge-context)" and gitea-issues' "Composes
|
||
# gitea-labels-\n milestones", where the `>` fold yields "gitea-labels-
|
||
# milestones" and the trailing hyphen is what keeps it terminal. Marking is a
|
||
# poor proxy, so the follower token is the signal, and it is applied to
|
||
# backticked targets too.
|
||
#
|
||
# TERMINAL IS NOT ENOUGH — IN-SENTENCE CORROBORATION. The follower test clears
|
||
# `pre-push` in the example above only because that example happens to be
|
||
# followed by the noun "hooks". Move the same token into terminal position and
|
||
# it was a hard FAIL again, with no suppression mechanism anywhere in this gate:
|
||
# Do not use for running hooks — run `pre-commit` instead.
|
||
# Do not use for the commit message — see `commit-msg`.
|
||
# Do not use for type errors — run `type-check` first.
|
||
# Instead, use `semantic-release`.
|
||
# Do not use for the old flow — use the clean-up instead.
|
||
# Do not run end-to-end, run unit-tests.
|
||
# Every one of those is grammatically identical to a genuinely broken route:
|
||
# "route verb + hyphenated name + terminal" is also exactly how prose cites a
|
||
# tool, a hook, a file format or an English compound. Nothing local separates
|
||
# them, and the skills most exposed are the ones this contract sends authors
|
||
# back to rewrite first — pc-run, pc-author, vale-run, vale-config and the apm-*
|
||
# family are all ABOUT hyphenated tools.
|
||
#
|
||
# So the confidence to BLOCK a commit comes from the sentence, not the token: a
|
||
# prose-form target may raise a hard error only when its own sentence names at
|
||
# least one OTHER target that RESOLVES. A routing sentence proves itself by
|
||
# routing somewhere real; a lone unresolvable name proves nothing. That is not a
|
||
# rule fitted to the fixtures — it is the shape of both live true positives,
|
||
# which sit beside `write-docs` and `gitea-labels-milestones` respectively, and
|
||
# it changes this corpus's verdict by exactly nothing.
|
||
#
|
||
# An uncorroborated unresolvable target is NOT discarded: every caller reports
|
||
# it at its SUGGESTION tier, naming the target. The finding stays visible on
|
||
# every run; only the power to block a commit is withdrawn, which is the part
|
||
# that had no escape hatch.
|
||
#
|
||
# EXPLICIT ROUTE NOTATION is exempt from corroboration and always blocks:
|
||
# ADR-0020's compressed arrow (`Not <thing> -> <name>`) and Claude Code's
|
||
# invocation form (`/<name>`). Neither is ever how English cites a tool — nobody
|
||
# writes `-> pre-commit` or `/pre-commit` to mean the hook — so there is no
|
||
# ambiguity to resolve, and an author who wants a route checked unconditionally
|
||
# has two ways to say so.
|
||
#
|
||
# NAMESPACE: `plugin:skill` is live in this repo (native user-scope installs
|
||
# still resolve `gitea:gitea-prs`), so the patterns admit an optional
|
||
# `<plugin>:` prefix and normalize_target() strips it before resolution.
|
||
NS = r"(?:[a-z0-9]+(?:-[a-z0-9]+)*:)?"
|
||
NAME_ANY = NS + r"[a-z0-9]+(?:-[a-z0-9]+)*"
|
||
NAME_HYPH = NS + r"[a-z0-9]+(?:-[a-z0-9]+)+"
|
||
ROUTE_VERB = (r"(?:use|uses|using|run|runs|invoke|invokes|invoking|try|see"
|
||
r"|that'?s|compose|composes|call|calls"
|
||
r"|routes?\s+to|delegates?\s+to|prefers?|switch(?:es)?\s+to"
|
||
r"|hands?\s+off\s+to)")
|
||
MARKED_TARGET = r"(?:`/?(%s)`|(?<![\w./*-])/(%s)\b)" % (NAME_ANY, NAME_ANY)
|
||
ANY_TARGET = r"(?:%s|(%s)\b)" % (MARKED_TARGET, NAME_HYPH)
|
||
ROUTE_MARKED = re.compile(r"\b%s\s+(?:the\s+|an?\s+)?%s" % (ROUTE_VERB, MARKED_TARGET), re.I)
|
||
ROUTE_ANY = re.compile(r"\b%s\s+(?:the\s+|an?\s+)?%s" % (ROUTE_VERB, ANY_TARGET), re.I)
|
||
# re.I on ALL of them, uniformly. The patterns are built from the same
|
||
# lowercase NAME_* fragments, so half of them carrying the flag and half not
|
||
# meant `Skill-Audit` at the start of a boundary sentence was extracted by
|
||
# ROUTE_ANY but invisible to BACKTICK — contradicting normalize_target()'s own
|
||
# docstring, which exists precisely because extraction is case-insensitive and
|
||
# the universe is not.
|
||
CONT_MARKED = re.compile(r"\s*(?:or|and|/|,)\s*%s" % MARKED_TARGET, re.I)
|
||
CONT_ANY = re.compile(r"\s*(?:or|and|/|,)\s*%s" % ANY_TARGET, re.I)
|
||
ARROW_MARKED = re.compile(r"(?:->|→)\s*%s" % MARKED_TARGET, re.I)
|
||
ARROW_BOUNDARY = re.compile(r"\bnot\b[^.;]*?(?:->|→)\s*(%s)\b" % NAME_HYPH, re.I)
|
||
BACKTICK = re.compile(r"`(%s)`" % NAME_HYPH, re.I)
|
||
# A boundary clause takes two shapes and BOTH count: the prose markers, and
|
||
# ADR-0020's compressed arrow form `Not <thing> -> <name>`.
|
||
BOUNDARY_MARKER = re.compile(r"\b(?:do\s+not|instead|rather\s+than|not\s+for)\b", re.I)
|
||
BOUNDARY_ARROW = re.compile(r"\bnot\b[^.;]*?(?:->|→)", re.I)
|
||
SENTENCE_SPLIT = re.compile(u'(?<=[.!?])\\s+(?=[A-Z"“(])')
|
||
|
||
# The token that may follow a route target without turning it into a compound
|
||
# modifier: punctuation, end of sentence, a conjunction, a boundary word, or a
|
||
# head noun that names the artifact itself ("the git-workflow skill"). Anything
|
||
# else — `hooks`, `template`, `formatting`, `logic` — is attributive prose.
|
||
FOLLOWER = re.compile(r"[`\s]*([a-z][a-z0-9]*)")
|
||
FOLLOWER_OK = frozenset("""
|
||
and or nor but for to when if unless while after before with from in on at by
|
||
of as than then instead rather directly first only always never also even
|
||
both either neither so because since per via plus alone here there this that
|
||
these those it its they them is are was were be been being has have had will
|
||
would can could should must may might does do did
|
||
skill skills agent agents plugin plugins command commands
|
||
""".split())
|
||
|
||
|
||
def normalize_target(target):
|
||
"""Comparison key: namespace stripped, lowercased.
|
||
|
||
Extraction is case-insensitive (re.I) but the universe is built from
|
||
lowercase directory names, so `Git-Commits` at the start of a sentence
|
||
resolved to nothing until this normalization existed.
|
||
"""
|
||
return target.split(':')[-1].lower()
|
||
|
||
|
||
def has_boundary_clause(description):
|
||
return bool(BOUNDARY_MARKER.search(description)
|
||
or BOUNDARY_ARROW.search(description))
|
||
|
||
|
||
def _first(match):
|
||
"""(name, start, end) offsets for the first group that matched."""
|
||
for index in range(1, (match.re.groups or 0) + 1):
|
||
if match.group(index):
|
||
return match.group(index), match.start(index), match.end(index)
|
||
return None, None, None
|
||
|
||
|
||
def _terminal(text, pos):
|
||
"""True if the token at pos does not make the preceding name a modifier."""
|
||
follower = FOLLOWER.match(text, pos)
|
||
return not follower or follower.group(1) in FOLLOWER_OK
|
||
|
||
|
||
def _notation(text, start, arrow):
|
||
"""True if the name is written in route NOTATION rather than in prose.
|
||
|
||
Two forms qualify: `/name` (Claude Code's invocation syntax, detected from
|
||
the character before the name) and `-> name` (ADR-0020's compressed boundary
|
||
form, passed in by the caller that matched the arrow). A backticked name
|
||
does NOT qualify — a code span is how a tool, a file and a skill are all
|
||
cited, so it carries no intent the follower test hasn't already read.
|
||
"""
|
||
return arrow or (start > 0 and text[start - 1] == '/')
|
||
|
||
|
||
def _add(out, text, name, start, end, strict=None, arrow=False):
|
||
if not name:
|
||
return
|
||
out.append((name,
|
||
_terminal(text, end) if strict is None else strict,
|
||
_notation(text, start, arrow)))
|
||
|
||
|
||
def _scan(text, route_re, cont_re, out):
|
||
for match in route_re.finditer(text):
|
||
name, start, end = _first(match)
|
||
if not name:
|
||
continue
|
||
_add(out, text, name, start, end)
|
||
# "use git-history or git-branches instead" / "use gitea-issues /
|
||
# gitea-prs" — keep consuming conjoined targets after the first.
|
||
pos = match.end()
|
||
while True:
|
||
cont = cont_re.match(text, pos)
|
||
if not cont:
|
||
break
|
||
_add(out, text, *_first(cont))
|
||
pos = cont.end()
|
||
|
||
|
||
def _extract_sentence(sentence):
|
||
"""[(name, may_dangle, notation)] for the routing targets in ONE sentence.
|
||
|
||
Kept separate from _extract() because corroboration is scoped to a single
|
||
sentence: a target's evidence is what stands beside it, not what the rest of
|
||
the description happens to mention.
|
||
"""
|
||
out = []
|
||
boundary = bool(BOUNDARY_MARKER.search(sentence))
|
||
_scan(sentence,
|
||
ROUTE_ANY if boundary else ROUTE_MARKED,
|
||
CONT_ANY if boundary else CONT_MARKED,
|
||
out)
|
||
for match in ARROW_MARKED.finditer(sentence):
|
||
# `-> name` and `-> /name` are route notation, not prose: nothing
|
||
# reads as a compound modifier after an arrow, so no follower test.
|
||
_add(out, sentence, *_first(match), strict=True, arrow=True)
|
||
for match in ARROW_BOUNDARY.finditer(sentence):
|
||
_add(out, sentence, match.group(1), match.start(1), match.end(1),
|
||
strict=True, arrow=True)
|
||
if boundary:
|
||
for match in BACKTICK.finditer(sentence):
|
||
_add(out, sentence, match.group(1), match.start(1), match.end(1))
|
||
return out
|
||
|
||
|
||
def _extract(description):
|
||
"""[(name, may_dangle, notation)] for every routing target."""
|
||
out = []
|
||
for sentence in SENTENCE_SPLIT.split(description):
|
||
out.extend(_extract_sentence(sentence))
|
||
return out
|
||
|
||
|
||
def boundary_targets(description):
|
||
"""Every routing target, for reporting and for confirming a route."""
|
||
return sorted({name for name, _, _ in _extract(description)})
|
||
|
||
|
||
def unresolved_targets(description, known):
|
||
"""Targets resolving to nothing, split into (blocking, reported).
|
||
|
||
`blocking` earns a hard error; `reported` is SUGGESTION tier — named on
|
||
every run, never fatal. Three conditions gate the promotion, and all of them
|
||
are documented at length in the ATTRIBUTIVE USE and CORROBORATION notes
|
||
above:
|
||
|
||
1. the target must be terminal, not a compound modifier ("pre-commit
|
||
hooks" is prose about a tool, not a route),
|
||
2. it must be written in route notation (`/name`, `-> name`), OR
|
||
3. its own sentence must name another target that DOES resolve.
|
||
|
||
Everything else is reported and left alone. `known` is the resolved
|
||
universe from known_targets(); passing an empty set is not meaningful —
|
||
callers check for that first and decline out loud instead.
|
||
"""
|
||
blocking, reported = set(), set()
|
||
for sentence in SENTENCE_SPLIT.split(description):
|
||
found = _extract_sentence(sentence)
|
||
resolved = {normalize_target(name) for name, _, _ in found
|
||
if normalize_target(name) in known}
|
||
for name, may_dangle, notation in found:
|
||
key = normalize_target(name)
|
||
if key in known or not may_dangle:
|
||
continue
|
||
if notation or (resolved - {key}):
|
||
blocking.add(name)
|
||
else:
|
||
reported.add(name)
|
||
return sorted(blocking), sorted(reported - blocking)
|
||
|
||
# --- Frontmatter ----------------------------------------------------------
|
||
# Tolerant on the way in, HARD-FAILING on the way out. A UTF-8 BOM, a leading
|
||
# blank line, trailing whitespace after either `---`, or CRLF line endings all
|
||
# defeated the old `^---\n(.*?)\n---`, and the miss was SILENT: every ADR-0020
|
||
# check was skipped and the file reported green (measured: a 550-character
|
||
# description with a 1,000-word body exited 0 behind a BOM). A file that cannot
|
||
# be measured must never report green, so every caller of these two ERRORs on a
|
||
# miss instead of moving on.
|
||
#
|
||
# The CLOSING marker is anchored at column 0 — deliberately NOT `[ \t]*---`.
|
||
# YAML block-scalar content must be indented deeper than its key, so an
|
||
# indented `---` inside a folded description is CONTENT; letting it close the
|
||
# frontmatter truncated the description mid-value and silently reclassified the
|
||
# rest as body, which is a vacuous green in both directions at once. Leading
|
||
# whitespace is still tolerated on the OPENING marker, where no such content
|
||
# can exist.
|
||
FRONTMATTER_RE = re.compile(
|
||
r'^[ \t\r\n]*---[ \t]*\r?\n(.*?)\r?\n---[ \t]*(?:\r?\n|\Z)', re.DOTALL)
|
||
|
||
|
||
def strip_bom(text):
|
||
return text[1:] if text.startswith(u'') else text
|
||
|
||
|
||
class FrontmatterError(Exception):
|
||
pass
|
||
|
||
|
||
def description_value(fm_text):
|
||
"""The description VALUE, with YAML folding resolved.
|
||
|
||
PyYAML is a HARD requirement, preflighted in bash. The hand-rolled fallback
|
||
this replaced diverged from a real parser across the FAIL boundary — one
|
||
corpus description measured 270 characters parsed and 412 unparsed, and a
|
||
quoted `"description"` key or an explicit `description: null` returned empty
|
||
from it, silently skipping the description AND routing checks. A gate that
|
||
disagrees with itself depending on which reader ran is worse than no gate.
|
||
|
||
This is the ONLY reader any of the three scripts may use to decide whether a
|
||
description is present. A line regex cannot: `description:` with no value
|
||
followed by `model: sonnet` lets `\\s*` cross the newline and captures the
|
||
NEXT key, which reads as a non-empty description, skips the "missing or
|
||
empty" failure, and then early-returns out of every ADR-0020 gate on the
|
||
genuinely empty folded value. That combination exited 0 with zero output on
|
||
a BLOCKING pre-push gate.
|
||
"""
|
||
try:
|
||
data = yaml.safe_load(fm_text)
|
||
except Exception as exc:
|
||
# Every FrontmatterError message is a COMPLETE clause, never a detail a
|
||
# caller wraps in one. Callers used to prefix a hard-coded "frontmatter
|
||
# is not valid YAML (...)", which is true only of this branch: the two
|
||
# type failures below come from frontmatter that parsed fine, and
|
||
# telling their author the YAML is invalid sends them hunting for a
|
||
# syntax error that is not there — on a blocking gate with no baseline.
|
||
raise FrontmatterError('frontmatter is not valid YAML (%s)'
|
||
% re.sub(r'\s+', ' ', str(exc)).strip())
|
||
if not isinstance(data, dict):
|
||
raise FrontmatterError('frontmatter is not a YAML mapping')
|
||
value = data.get('description')
|
||
if value is None:
|
||
return ''
|
||
if not isinstance(value, str):
|
||
# NOT str()-coerced. `description: true` became the 4-character "True"
|
||
# and sailed through the 400-character gate; a list or mapping was
|
||
# measured as its Python repr. Neither is a description a host can
|
||
# preload, so this is a parse failure, reported as one.
|
||
raise FrontmatterError(
|
||
'description is a %s, not a string' % type(value).__name__)
|
||
return re.sub(r'\s+', ' ', value).strip()
|
||
|
||
|
||
# --- Body-shape checks (skills only; agents have no references/ dir) -------
|
||
# Deterministic and countable, so they are enforced here. Whether a given
|
||
# gotcha is WARRANTED is semantic and stays the auditor's judgment, which is why
|
||
# both gotcha checks are SUGGESTION tier. A missing reference file is not a
|
||
# style opinion — it is a broken pointer — so that one is ERROR tier.
|
||
#
|
||
# Both read a FENCE-MASKED copy of the body. Scanning the raw body made a
|
||
# ```-fenced example a hard ERROR — and the skills most likely to carry one are
|
||
# skill-author and skill-audit, which DOCUMENT the references/ convention — and
|
||
# let a `## Gotchas` heading inside a fenced block stand in for the real
|
||
# section. Masking preserves every byte offset (content becomes spaces,
|
||
# newlines stay), so a span found in the mask slices the original.
|
||
GOTCHA_MAX_ENTRIES = 5
|
||
GOTCHA_MAX_BODY_FRACTION = 0.25
|
||
# The heading has to BE "Gotchas", not merely contain the word: `## Gotcha
|
||
# handling` and `## Why gotchas matter` are prose sections, and treating one as
|
||
# the Gotchas section measured a span that was never a gotcha list.
|
||
GOTCHA_HEADING = re.compile(r'^(#{1,6})[ \t]+(?:[^\n]*?[ \t])?gotchas?[ \t]*:?[ \t]*$',
|
||
re.I | re.M)
|
||
# Column 0 only. `^[ \t]{0,3}` counted a two-space-indented CHILD bullet as a
|
||
# top-level entry, so a five-entry section with sub-bullets reported nine.
|
||
GOTCHA_ENTRY = re.compile(r'^(?:[-*+]|\d+[.)])[ \t]+', re.M)
|
||
FENCE_OPEN = re.compile(r'^[ \t]{0,3}(`{3,}|~{3,})')
|
||
REFERENCE_POINTER = re.compile(
|
||
r'(?<![\w./-])(?:\./)?references/([A-Za-z0-9][A-Za-z0-9._/-]*\.md)')
|
||
# A pointer named in a sentence that says the file is GONE is a historical
|
||
# mention, not a dispatch entry: "the old `references/legacy.md` was removed in
|
||
# v2" is prose and must not be a hard ERROR. Narrow on purpose — a live
|
||
# dispatch table never describes its own target as removed, so this costs no
|
||
# recall.
|
||
REFERENCE_PAST = re.compile(
|
||
r'\b(?:removed|deleted|renamed|superseded|replaced|obsolete|deprecated'
|
||
r'|former|formerly|gone|no longer|used to)\b', re.I)
|
||
# A pointer QUALIFIED by another skill's name — "skill-audit's
|
||
# references/validation-scripts.md" — names a file that is deliberately NOT in
|
||
# this skill's directory. Requiring it on the local disk left NO legal spelling
|
||
# for a cross-skill reference at all: the only alternative, a full repo path
|
||
# (`plugins/kyberforge/.apm/skills/skill-audit/references/...`), is itself a
|
||
# FAIL under skill-audit's own file-structure rubric, because a path that climbs
|
||
# out of the skill directory stops resolving once the plugin is cache-installed.
|
||
# The possessive form is the sanctioned spelling, and it is skipped here. It is
|
||
# not checked further — this function has no way to locate another skill's
|
||
# directory, and inventing one would reintroduce exactly the cross-plugin path
|
||
# assumption the rubric forbids.
|
||
REFERENCE_QUALIFIER = re.compile(u"[A-Za-z0-9][A-Za-z0-9._-]*`?['’]s[ \t]+`?$")
|
||
|
||
|
||
def mask_fenced(text):
|
||
"""Body with fenced code blocks blanked out, byte offsets preserved."""
|
||
out = []
|
||
fence = None
|
||
for line in text.splitlines(keepends=True):
|
||
stripped = line.rstrip('\r\n')
|
||
opener = FENCE_OPEN.match(stripped)
|
||
marker = opener.group(1) if opener else None
|
||
if fence is None:
|
||
if marker:
|
||
fence = marker
|
||
out.append(' ' * len(stripped) + line[len(stripped):])
|
||
continue
|
||
out.append(line)
|
||
else:
|
||
out.append(' ' * len(stripped) + line[len(stripped):])
|
||
if (marker and marker[0] == fence[0] and len(marker) >= len(fence)
|
||
and not stripped.strip()[len(marker):].strip()):
|
||
fence = None
|
||
# An UNCLOSED fence has no cost-free answer, only a choice of which way to
|
||
# be wrong. Masking to end-of-body blanks the rest of the body, silently
|
||
# disabling the ERROR-tier references/ check and the gotcha counts.
|
||
# Returning the raw text instead exposes the unclosed example's own
|
||
# content, so a fenced example naming a nonexistent references/ file
|
||
# becomes a hard ERROR it would not have been had the fence been closed —
|
||
# confirmed, not hypothetical. The loud-false-positive direction is the one
|
||
# chosen: this script's rule is that a file it cannot measure must never
|
||
# report green, and masking-onward is exactly that failure. Both outcomes
|
||
# need an already-malformed file, and the false positive costs one fence.
|
||
if fence is not None:
|
||
return text
|
||
return ''.join(out)
|
||
|
||
|
||
def gotcha_stats(body):
|
||
"""(entry count, section word count) for the first Gotchas section, or None.
|
||
|
||
The section runs to the next heading at the same level or shallower.
|
||
Entries are top-level list items; a section written as subheadings instead
|
||
of a list counts those. Headings and entries are read from the fence mask;
|
||
the word count is taken from the original slice, because fenced lines are
|
||
real body words and the fraction is measured against the whole body.
|
||
"""
|
||
masked = mask_fenced(body)
|
||
match = GOTCHA_HEADING.search(masked)
|
||
if not match:
|
||
return None
|
||
level = len(match.group(1))
|
||
rest = masked[match.end():]
|
||
nxt = re.search(r'^#{1,%d}[ \t]+' % level, rest, re.M)
|
||
end = match.end() + (nxt.start() if nxt else len(rest))
|
||
section = masked[match.end():end]
|
||
entries = len(GOTCHA_ENTRY.findall(section))
|
||
if entries == 0 and level < 6:
|
||
entries = len(re.findall(r'^#{%d,6}[ \t]+' % (level + 1), section, re.M))
|
||
return entries, len(body[match.end():end].split())
|
||
|
||
|
||
def missing_reference_pointers(body, skill_dir):
|
||
"""references/<file>.md named in the body but absent from disk."""
|
||
masked = mask_fenced(body)
|
||
missing = set()
|
||
for match in REFERENCE_POINTER.finditer(masked):
|
||
start = masked.rfind('\n', 0, match.start()) + 1
|
||
end = masked.find('\n', match.end())
|
||
if end < 0:
|
||
end = len(masked)
|
||
if REFERENCE_PAST.search(masked[start:end]):
|
||
continue
|
||
if REFERENCE_QUALIFIER.search(masked[start:match.start()]):
|
||
continue
|
||
if not os.path.isfile(os.path.join(skill_dir, 'references', match.group(1))):
|
||
missing.add('references/' + match.group(1))
|
||
return sorted(missing)
|
||
# ===== END ADR-0020 SHARED BOUNDARY RESOLVER =====
|
||
|
||
|
||
# --- Per-file checks ------------------------------------------------------
|
||
for path in files:
|
||
if not os.path.isfile(path):
|
||
# NOT a silent skip. A broken symlink named SKILL.md is storable in git,
|
||
# so pre-commit really can hand one to this hook, and a directory named
|
||
# SKILL.md reaches it the same way — both used to exit 0 with zero
|
||
# output, which is precisely the "stay quiet about a measurement it did
|
||
# not take" failure this script forbids itself two screens up.
|
||
if os.path.isdir(path):
|
||
why = "is a directory, not a file"
|
||
elif os.path.islink(path):
|
||
why = "is a symlink that does not resolve to a file"
|
||
elif os.path.exists(path):
|
||
why = "is not a regular file"
|
||
else:
|
||
why = "does not exist"
|
||
error("%s: %s, so none of the ADR-0020 gates could run on it. A path "
|
||
"this gate was handed and could not read does not get to pass in "
|
||
"silence." % (path, why))
|
||
continue
|
||
try:
|
||
raw = read_text(path)
|
||
except EncodingError as exc:
|
||
error("%s: %s. Neither the spec line/word ceilings nor any of the "
|
||
"ADR-0020 gates could run on this file." % (path, exc))
|
||
continue
|
||
|
||
# SPEC CONFORMANCE (family 1). Whole file, frontmatter included, counted
|
||
# with the SAME primitives skill-audit/scripts/validate.sh uses for these
|
||
# two constants — see the note in the bash loop above for what the previous
|
||
# awk pass got wrong.
|
||
lines = len(raw.splitlines())
|
||
words = len(raw.split())
|
||
if lines > MAX_LINES:
|
||
error("%s has %d lines, exceeding the %d-line ceiling "
|
||
"(agentskills.io skill-authoring.md)" % (path, lines, MAX_LINES))
|
||
if words > MAX_WORDS:
|
||
error("%s has %d words (proxy for tokens), exceeding the %d-word ceiling "
|
||
"(~5,000 tokens, agentskills.io skill-authoring.md)"
|
||
% (path, words, MAX_WORDS))
|
||
|
||
content = strip_bom(raw)
|
||
|
||
fm_match = FRONTMATTER_RE.match(content)
|
||
if not fm_match:
|
||
error("%s: no parseable YAML frontmatter block. Expected a `---` line, "
|
||
"the fields, then a closing `---` line (a BOM, leading blank "
|
||
"lines, trailing spaces after either marker and CRLF endings are "
|
||
"all tolerated). None of the ADR-0020 gates could run on this "
|
||
"file — a file that cannot be measured does not get to pass."
|
||
% path)
|
||
continue
|
||
|
||
try:
|
||
desc = description_value(fm_match.group(1))
|
||
except FrontmatterError as exc:
|
||
# `exc` carries the whole clause — invalid YAML, a non-mapping block, or
|
||
# a description of the wrong type. Do not prefix a diagnosis here; the
|
||
# last one named a syntax error for two failures that have none.
|
||
error("%s: %s. None of the ADR-0020 gates could run on this file."
|
||
% (path, exc))
|
||
continue
|
||
|
||
body = content[fm_match.end():]
|
||
skill_dir = os.path.dirname(os.path.abspath(path))
|
||
|
||
# An absent or empty description is an ERROR here too, not a silent skip.
|
||
# All three ADR-0020 scripts have to agree on this input: the description is
|
||
# the one field preloaded into every session, so a SKILL.md that ships
|
||
# without one is the worst case the contract exists for, and a gate that
|
||
# merely declines to measure it reports green.
|
||
if not desc:
|
||
error("%s: description field is missing or empty. It is the only part of a "
|
||
"skill preloaded into every session, so a skill without one can never "
|
||
"be routed to — and none of the ADR-0020 description or boundary gates "
|
||
"have anything to measure." % path)
|
||
|
||
if desc:
|
||
dlen = len(desc)
|
||
if dlen > DESC_MAX_CHARS:
|
||
error("%s: description is %d characters, exceeding the %d-character ceiling "
|
||
"(ADR-0020). It is preloaded into every session. Keep a trigger clause, "
|
||
"at most one capability clause, and a boundary clause; move capability "
|
||
"enumeration, output-format detail, composition notes and implementation "
|
||
"detail to the body or README.md."
|
||
% (path, dlen, DESC_MAX_CHARS))
|
||
elif dlen > DESC_SUGGEST_CHARS:
|
||
suggest("%s: description is %d characters, over the %d-character target "
|
||
"(ADR-0020, hard fail at %d)."
|
||
% (path, dlen, DESC_SUGGEST_CHARS, DESC_MAX_CHARS))
|
||
|
||
body_words = len(body.split())
|
||
if body_words > BODY_MAX_WORDS:
|
||
error("%s: body is %d words, exceeding the %d-word ceiling (ADR-0020). This counts "
|
||
"the body ONLY — it is a separate measurement from the %d-word whole-file "
|
||
"spec ceiling above. Move lookup tables, spec restatements, output schemas, "
|
||
"templates and rationale prose to references/ behind an explicit "
|
||
"\"If X, read references/file.md\" trigger."
|
||
% (path, body_words, BODY_MAX_WORDS, MAX_WORDS))
|
||
elif body_words > BODY_SUGGEST_WORDS:
|
||
suggest("%s: body is %d words, over the %d-word target (ADR-0020, hard fail at %d)."
|
||
% (path, body_words, BODY_SUGGEST_WORDS, BODY_MAX_WORDS))
|
||
|
||
# Reference pointers must exist. ERROR, not SUGGESTION: a dispatch table
|
||
# naming a file that is not on disk is a hard break, and nothing else in
|
||
# the gate/audit/vale stack notices it.
|
||
for ref in missing_reference_pointers(body, skill_dir):
|
||
error("%s: body points at %s, which does not exist on disk. A dispatch "
|
||
"table or \"read X\" trigger naming a missing file sends the "
|
||
"agent nowhere." % (path, ref))
|
||
|
||
# Gotchas discipline. SUGGESTION on both counts: the measurement is
|
||
# deterministic, but whether a given gotcha earns its place is judgment.
|
||
stats = gotcha_stats(body)
|
||
if stats is not None:
|
||
entries, section_words = stats
|
||
if entries > GOTCHA_MAX_ENTRIES:
|
||
suggest("%s: Gotchas section has %d entries, over the %d-entry guideline. "
|
||
"A list that long is usually a missing references/ file or a design "
|
||
"problem written up as a warning."
|
||
% (path, entries, GOTCHA_MAX_ENTRIES))
|
||
if body_words and section_words > body_words * GOTCHA_MAX_BODY_FRACTION:
|
||
suggest("%s: Gotchas section is %d of %d body words (%d%%), over the %d%% "
|
||
"guideline. Move the durable parts to references/ and keep the "
|
||
"section for live traps."
|
||
% (path, section_words, body_words,
|
||
round(100.0 * section_words / body_words),
|
||
round(100.0 * GOTCHA_MAX_BODY_FRACTION)))
|
||
|
||
# Missing boundary clause. SUGGESTION, not ERROR: detecting the absence is
|
||
# deterministic, but whether this particular skill warrants one is the
|
||
# auditor's call. Both accepted shapes count — the prose markers and
|
||
# ADR-0020's compressed `Not <thing> -> <name>` arrow.
|
||
if desc and not has_boundary_clause(desc):
|
||
suggest("%s: description has no boundary clause (ADR-0020). Add the prose form "
|
||
"(\"Do not use for X — use `y` instead\") or the compressed form "
|
||
"(\"Not X -> y\") so the router knows where NOT to send this skill."
|
||
% path)
|
||
|
||
targets = boundary_targets(desc)
|
||
if targets:
|
||
known = known_targets(skill_dir)
|
||
if known:
|
||
blocking, reported = unresolved_targets(desc, known)
|
||
for target in blocking:
|
||
error("%s: description routes to '%s', which does not resolve to a skill "
|
||
"or agent in this monorepo, in this package, or in a package it "
|
||
"declares in apm.yml dependencies.apm (ADR-0020). A boundary clause "
|
||
"that names a non-existent target sends the router nowhere."
|
||
% (path, target))
|
||
for target in reported:
|
||
suggest("%s: description routes to '%s', which does not resolve to a skill "
|
||
"or agent in this monorepo, in this package, or in a package it "
|
||
"declares in apm.yml dependencies.apm (ADR-0020). SUGGESTION rather "
|
||
"than a hard failure because nothing else in the sentence resolves, "
|
||
"so this is equally likely to be a tool, a file format or an English "
|
||
"compound. If it IS a route, write it as `/%s` or `-> %s` and it will "
|
||
"be checked properly." % (path, target, target, target))
|
||
else:
|
||
info("%s: boundary-target resolution DID NOT RUN — no skill universe "
|
||
"could be determined for this path (no authoring root above it, no "
|
||
"apm package root, no declared apm dependencies, no deployed "
|
||
".claude/ or .agents/ tree). Unchecked target(s): %s"
|
||
% (path, ", ".join(targets)))
|
||
|
||
sys.exit(1 if failed else 0)
|
||
PYTHON
|
||
then
|
||
FAIL=1
|
||
fi
|
||
|
||
exit $FAIL
|