The ADR-0020 boundary resolver (boundary_targets()/unresolved_targets()) only ever read a SKILL.md's description. A target named in the BODY -- a dispatch table row, a "run X" step, both routine in a 900-word procedure -- was checked by nothing. Two real instances shipped before either was caught by reading rather than by a gate: bin/write-docs routed twice to a deleted `to-prd` skill, and bin/triage told an agent to run a nonexistent `/setup-matt-pocock-skills` (both fixed in 03abcff; that fix was the symptom, this gate is the actual ask per #124). Added a separate, narrower extractor -- body_targets() / unresolved_body_targets() in the shared lib-boundary-resolver.sh -- rather than reusing the description resolver at wider scope. The description gate's sentence-level heuristics (BOUNDARY_MARKER, the follower test, in-sentence corroboration) are tuned for a one-to-three-sentence routing clause and misfire on dispatch-table/procedure prose in both directions, so the body gate reads only explicit route notation (`/name`, backticked-or-slash-prefixed `-> name` / `-> name`), already the description gate's own unconditionally-blocking tier. Three guards were added after running the extractor over the real 39-skill corpus and reading every hit rather than assuming the design was correct: - a target must be hyphenated, even in notation -- single-word citations like `/fork` (forge, citing Claude Code's own /fork command) and `/name` (skill-author, a placeholder) are not routes. - a bare hyphenated word after any arrow is not notation -- only ARROW_MARKED (backticked/slash-prefixed) is used, not NOTATION_ARROW's bare form, so ordinary process-chain prose ("prop -> new ref -> re-render", caveman) is not read as a route. - a name immediately preceded by `<` is a closing tag (`</what-to-do>`, grill-with-docs), not /name notation. Wired into both consumers that must agree by contract: scripts/ skill-size-check.sh (the pre-commit hook) and factory-audit's lib-checks-skill.sh (the audit). Verified identical findings across both over the whole corpus. tests/test-adr0020-targets.sh gains a dedicated section pinning the two live true positives and all three guards. docs/spec/gates.md and ADR-0020 get a matching amendment. Fixes: #124 ADR: 0020 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EGHFJextYtVQseaHPDDhxB
645 lines
32 KiB
Bash
Executable File
645 lines
32 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# lib-checks-skill.sh — SOURCED, never executed.
|
|
#
|
|
# skill-audit's structural check suite: everything in its validate.sh that is
|
|
# NOT the ADR-0020 shared boundary resolver, lifted verbatim and split at the
|
|
# resolver's markers. validate.sh reassembles
|
|
#
|
|
# $KYBERFORGE_SKILL_PREAMBLE_PY
|
|
# $KYBERFORGE_RESOLVER_PY (from lib-boundary-resolver.sh)
|
|
# $KYBERFORGE_SKILL_BODY_PY
|
|
#
|
|
# in that order — the order the resolver block sat in the original file — and
|
|
# feeds the result to python3, so every check runs against the same names it
|
|
# always did.
|
|
#
|
|
# The dimension vocabulary, the message wording and the PASS/FAIL/SUGGESTION/
|
|
# INFO tiers here are skill-audit's and are deliberately NOT reconciled with
|
|
# lib-checks-agent.sh's. The two suites disagree on purpose: a skill body is
|
|
# loaded into the caller's context, an agent body becomes the system prompt of
|
|
# a fresh one, so ADR-0020 gives skills a body word budget and agents none.
|
|
#
|
|
# Consumed by: validate.sh, skill mode.
|
|
# shellcheck shell=bash
|
|
# shellcheck disable=SC2034
|
|
|
|
kyberforge_skill_preflight() {
|
|
# PyYAML is a HARD dependency, not a nice-to-have. 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 — same description, two verdicts, depending on which reader ran.
|
|
# Refusing to start is the only honest option; the repo's jq / apm / vale
|
|
# dependencies are declared the same way.
|
|
# Check the interpreter separately from the library: `python3 -c` fails the same
|
|
# way whether python3 is missing or PyYAML is, and reporting the wrong missing
|
|
# dependency sends the reader to install the wrong thing.
|
|
if ! command -v python3 > /dev/null 2>&1; then
|
|
echo "Error: python3 is required but was not found on PATH." >&2
|
|
echo " Why: skipping the ADR-0020 description, body and boundary-target gates would be a vacuous pass." >&2
|
|
echo " Fix: install python3 (pre-commit itself is a Python application, so it is almost certainly already present)." >&2
|
|
# Exit 2, the never-ran tier: no check ran, so this is not a findings result.
|
|
# lib-provenance-*.sh has always exited 2 here; this matches it.
|
|
exit 2
|
|
fi
|
|
|
|
if ! python3 -c 'import yaml' > /dev/null 2>&1; then
|
|
echo "Error: PyYAML is required but is not importable by python3." >&2
|
|
echo " Why: skipping the ADR-0020 description, body and boundary-target gates would be a vacuous pass." >&2
|
|
echo " Fix: python3 -m pip install PyYAML (or your distro's python3-yaml package)." >&2
|
|
# Exit 2, the never-ran tier: a missing hard dependency is not a findings result.
|
|
exit 2
|
|
fi
|
|
}
|
|
|
|
IFS='' read -r -d '' KYBERFORGE_SKILL_PREAMBLE_PY <<'KYBERFORGE_SKILL_PREAMBLE' || true
|
|
import sys
|
|
import os
|
|
import re
|
|
import glob
|
|
|
|
import yaml
|
|
|
|
# Output is UTF-8 for the same reason input is: under LC_ALL=C the streams
|
|
# default to ASCII, and this script's own message text carries em dashes (the
|
|
# ADR-0020 boundary SUGGESTION is one). Pinning only the reads moved the crash
|
|
# from the read to the write — a UnicodeEncodeError raised while PRINTING, after
|
|
# every check has already run, which loses the whole report and (here) flips a
|
|
# clean exit 0 into a traceback and an exit 1. read_text() in the shared
|
|
# resolver block below pins the reads; this pins the writes.
|
|
#
|
|
# Deliberately OUTSIDE the ADR-0020 shared boundary resolver block: the two
|
|
# validate.sh modes print findings, skill-size-check.sh has its own top-level
|
|
# equivalent, and the block is one sourced copy all three share, so each
|
|
# consumer's own startup stays in its own preamble.
|
|
for _stream in (sys.stdout, sys.stderr):
|
|
try:
|
|
_stream.reconfigure(encoding='utf-8')
|
|
except AttributeError: # pragma: no cover — Python < 3.7
|
|
pass
|
|
|
|
skill_dir = os.path.abspath(sys.argv[1])
|
|
skill_md = os.path.join(skill_dir, "SKILL.md")
|
|
|
|
if not os.path.isfile(skill_md):
|
|
print(f"Error: '{skill_md}' not found.", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
failed = False
|
|
suggestions = []
|
|
|
|
def ok(msg):
|
|
print(f"PASS {msg}")
|
|
|
|
def fail(msg):
|
|
# stderr, matching scripts/skill-size-check.sh's ERROR routing. All three
|
|
# scripts in the ADR-0020 family now agree: findings that fail the run go to
|
|
# stderr, everything advisory (PASS / SUGGESTION / INFO) goes to stdout.
|
|
# Both repo callers capture `2>&1`, so nothing a human reads moves.
|
|
global failed
|
|
print(f"FAIL {msg}", file=sys.stderr)
|
|
failed = True
|
|
|
|
def suggest(msg):
|
|
# SUGGESTIONs are printed after every check and NEVER touch the exit code.
|
|
# factory-audit's SKILL.md Step 4 report counts them into its
|
|
# `PASS (N suggestions)` result line, which is what makes the ADR-0020 SUGGESTION tier visible
|
|
# rather than another silently-ignored warning (ADR-0013).
|
|
suggestions.append(msg)
|
|
|
|
def info(msg):
|
|
# A check that DECLINED to run says so out loud, rather than passing
|
|
# silently. Silence is what let a whole gate family go missing unnoticed.
|
|
print(f"INFO {msg}")
|
|
|
|
|
|
KYBERFORGE_SKILL_PREAMBLE
|
|
KYBERFORGE_SKILL_PREAMBLE_PY="${KYBERFORGE_SKILL_PREAMBLE_PY%$'\n'}"
|
|
|
|
IFS='' read -r -d '' KYBERFORGE_SKILL_BODY_PY <<'KYBERFORGE_SKILL_BODY' || true
|
|
|
|
|
|
# A leading BOM is stripped before anything is parsed or counted. It changes
|
|
# neither count below — it is not a line separator and str.split() does not
|
|
# treat it as whitespace — but it did defeat the frontmatter match.
|
|
try:
|
|
content = strip_bom(read_text(skill_md))
|
|
except EncodingError as exc:
|
|
fail(f"SKILL.md is {exc}. Nothing downstream can be measured, so this is a "
|
|
f"hard failure, not a skip")
|
|
print("One or more checks failed.")
|
|
sys.exit(1)
|
|
|
|
# --- Parse frontmatter ---
|
|
fm_match = FRONTMATTER_RE.match(content)
|
|
if not fm_match:
|
|
fail("No parseable YAML frontmatter block found. 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). Nothing "
|
|
"downstream can be measured, so this is a hard failure, not a skip")
|
|
print("One or more checks failed.")
|
|
sys.exit(1)
|
|
|
|
fm = fm_match.group(1)
|
|
body_start = fm_match.end()
|
|
|
|
# Extract name. The character class is `[ \t]`, never `\s`: under re.MULTILINE
|
|
# a `\s*` after the colon crosses the newline, so a valueless `name:` followed
|
|
# by `description: ...` captured the NEXT KEY as the name and reported a
|
|
# mismatch instead of an absence. Same class of bug as the `description:` one
|
|
# the shared resolver's description_value() docstring records.
|
|
name_m = re.search(r'^name:[ \t]*(\S+)', fm, re.MULTILINE)
|
|
name = name_m.group(1).strip('"\'') if name_m else ""
|
|
|
|
# Extract description — the VALUE, with YAML folding resolved. Most of this
|
|
# corpus writes descriptions as `>`-folded block scalars, so the raw lines
|
|
# carry indentation and newlines that are not part of the value: every length
|
|
# measurement below is wrong unless the scalar is folded first.
|
|
try:
|
|
desc = description_value(fm)
|
|
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.
|
|
fail(f"{exc}. Nothing downstream can be measured, so this is a hard "
|
|
f"failure, not a skip")
|
|
print("One or more checks failed.")
|
|
sys.exit(1)
|
|
|
|
dir_name = os.path.basename(skill_dir)
|
|
|
|
# ADR-0020's hand-invocation carve-out (issue #108). `disable-model-invocation:
|
|
# true` takes the skill out of the model-visible listing entirely, so the
|
|
# trigger/capability/boundary rules and the 250-character routing target do not
|
|
# apply to it — the audit's own references/skill-description-quality.md Step 0 says
|
|
# so, and until this line existed no check here knew the field existed. What the
|
|
# flag does NOT lift: the body word budget and the 400-character description
|
|
# ceiling. See the shared resolver's hand_invoked().
|
|
by_hand = hand_invoked(fm)
|
|
|
|
# --- Checks ---
|
|
|
|
# name present
|
|
if name:
|
|
ok(f"name present: '{name}'")
|
|
else:
|
|
fail("name field is missing or empty")
|
|
|
|
# name matches directory
|
|
if name and dir_name:
|
|
if name == dir_name:
|
|
ok(f"name '{name}' matches directory '{dir_name}'")
|
|
else:
|
|
fail(f"name '{name}' does not match directory '{dir_name}'")
|
|
|
|
# name length
|
|
if name:
|
|
if len(name) <= 64:
|
|
ok(f"name length {len(name)} chars (limit: 64)")
|
|
else:
|
|
fail(f"name '{name}' is {len(name)} chars — exceeds 64-character limit")
|
|
|
|
# name format
|
|
if name:
|
|
if re.match(r'^[a-z0-9]+(-[a-z0-9]+)*$', name):
|
|
ok("name format valid (kebab-case)")
|
|
else:
|
|
fail(f"name '{name}' is invalid — use lowercase letters, numbers, and hyphens only; no leading, trailing, or consecutive hyphens")
|
|
|
|
# description present
|
|
if desc:
|
|
ok("description present")
|
|
else:
|
|
fail("description field is missing or empty")
|
|
|
|
# description length — agentskills.io spec backstop. UNCHANGED by ADR-0020:
|
|
# 1024 is the specification's hard limit, and the ADR-0020 budget gate below
|
|
# sits underneath it rather than replacing it.
|
|
if desc:
|
|
dlen = len(desc)
|
|
if dlen <= 1024:
|
|
ok(f"description length {dlen} chars (agentskills.io spec limit: 1024)")
|
|
else:
|
|
fail(f"description length {dlen} chars — exceeds 1024-character limit")
|
|
|
|
# Unfilled placeholder detection — matches FILL IN: followed by actual content,
|
|
# but not backtick-quoted references like `FILL IN:` used in instructions.
|
|
PLACEHOLDER_RE = re.compile(r'(?<!`)FILL IN:[^`\n]')
|
|
|
|
# description contains unfilled placeholder
|
|
if desc and PLACEHOLDER_RE.search(desc):
|
|
fail("description still contains 'FILL IN:' placeholder — replace before shipping")
|
|
else:
|
|
if desc:
|
|
ok("description has no unfilled placeholders")
|
|
|
|
# --- ADR-0022: metadata.version is mandatory -------------------------------
|
|
# FAIL, not SUGGESTION, and the tier is set by the gate rather than by taste.
|
|
# `.pre-commit-config.yaml`'s `skill-size-check` hook REJECTS a SKILL.md with
|
|
# no `metadata.version`, and rejects a value that is not three-part semver:
|
|
# ASCII digits only, no leading zero (semver 2.0.0 item 2), at most nine digits
|
|
# per part (bash arithmetic in check-skill-version-bump.sh), whole-value match.
|
|
# skill-author's Step 4 says to run this audit and "resolve every FAIL", so any
|
|
# tier below FAIL lets that step report done on a skill the commit gate then
|
|
# refuses — the same audit-disagrees-with-the-gate failure the MAX_LINES note
|
|
# below warns about, arrived at from the other direction. Verified before this
|
|
# check existed: a SKILL.md with no `metadata:` block at all reported "All
|
|
# checks passed".
|
|
#
|
|
# The rule is DUPLICATED from that hook for the same cache-isolation reason as
|
|
# every other constant here — an installed plugin's scripts cannot read the
|
|
# repo-root config. Keep the two in step: this check must accept exactly what
|
|
# the hook accepts. Used with fullmatch(), never match() with ^...$ anchors:
|
|
# `$` also matches before a trailing newline, and `\d` also matches non-ASCII
|
|
# Unicode digits — both of which the hook rejects.
|
|
SEMVER_RE = re.compile(r'(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})')
|
|
|
|
try:
|
|
fm_data = yaml.safe_load(fm)
|
|
except Exception:
|
|
# Unreachable in practice: description_value() above parses the same text
|
|
# and hard-exits on a YAML error, so anything arriving here already parsed.
|
|
fm_data = None
|
|
metadata_block = fm_data.get('metadata') if isinstance(fm_data, dict) else None
|
|
|
|
if not isinstance(metadata_block, dict) or metadata_block.get('version') is None:
|
|
fail("frontmatter has no metadata.version — ADR-0022 makes it mandatory for "
|
|
"every skill, and the skill-size-check pre-commit hook rejects the file "
|
|
"without it. Add `metadata:` / ` version: \"1.0.0\"` (new skills start "
|
|
"at \"0.1.0\")")
|
|
else:
|
|
version_value = metadata_block['version']
|
|
# NOT str()-coerced blind: `version: 1.0` is a YAML float, and its "1.0"
|
|
# spelling is exactly the two-part value the hook rejects — coercing and
|
|
# then matching keeps this check and the hook agreeing on that case.
|
|
version_text = version_value if isinstance(version_value, str) else str(version_value)
|
|
# Same normalisation as the hook: surrounding whitespace, then quotes.
|
|
version_text = version_text.strip().strip('\'"')
|
|
if SEMVER_RE.fullmatch(version_text):
|
|
ok(f"metadata.version present: '{version_text}' (ADR-0022)")
|
|
else:
|
|
fail(f"metadata.version '{version_text}' is not three-part semver — the "
|
|
f"skill-size-check pre-commit hook rejects it. Use MAJOR.MINOR.PATCH "
|
|
f"with ASCII digits, no leading zeros and at most nine digits per "
|
|
f"part, e.g. \"1.0.0\"")
|
|
|
|
# SKILL.md size ceilings (agentskills.io skill-authoring.md: 500 lines,
|
|
# ~5,000 tokens). Both constants are DUPLICATED from the repo-root pre-commit
|
|
# hook scripts/skill-size-check.sh — a plugin skill's scripts cannot read files
|
|
# outside the plugin directory once the plugin is cache-installed, so there is
|
|
# no single source to share. Keep the two in sync by hand: if they drift, this
|
|
# audit will report a skill ready to ship that the commit hook then rejects.
|
|
MAX_LINES = 500
|
|
# Word-count proxy for the ~5,000-token ceiling, calibrated to the densest
|
|
# prose in the corpus (7.22 chars/word): 2770 words is ~20,000 characters,
|
|
# ~5,000 tokens at 4 characters per token. See skill-size-check.sh's header
|
|
# for the full measurement.
|
|
MAX_WORDS = 2770
|
|
|
|
# ADR-0020 context-budget gates. DUPLICATED from scripts/skill-size-check.sh
|
|
# for exactly the same cache-isolation reason as MAX_LINES/MAX_WORDS above, and
|
|
# carrying the same warning — tests/test-skill-size-check.sh asserts the copies
|
|
# agree, so drift fails CI instead of shipping an audit that disagrees with the
|
|
# commit hook. lib-checks-agent.sh, beside this file, holds a third copy of the
|
|
# two description constants; per ADR-0020 agents take the description gates and
|
|
# deliberately take NO body word gate, because an agent body becomes the system
|
|
# prompt of a fresh context rather than competing with a live conversation.
|
|
#
|
|
# These are NOT the same measurements as MAX_LINES/MAX_WORDS and must not be
|
|
# unified with them: MAX_WORDS counts the WHOLE FILE including frontmatter and
|
|
# is a spec-conformance backstop; BODY_MAX_WORDS counts the body ONLY and is a
|
|
# quality gate. Likewise the 1024-character description limit above is the
|
|
# agentskills.io spec ceiling and stays exactly as it is — DESC_MAX_CHARS sits
|
|
# underneath it.
|
|
DESC_SUGGEST_CHARS = 250
|
|
DESC_MAX_CHARS = 400
|
|
BODY_SUGGEST_WORDS = 600
|
|
BODY_MAX_WORDS = 900
|
|
|
|
line_count = len(content.splitlines())
|
|
if line_count <= MAX_LINES:
|
|
ok(f"SKILL.md line count {line_count} (limit: {MAX_LINES})")
|
|
else:
|
|
fail(f"SKILL.md line count {line_count} — exceeds {MAX_LINES}-line limit")
|
|
|
|
# str.split() with no argument splits on runs of whitespace, matching the
|
|
# `wc -w` the hook uses, and counts the whole file including frontmatter.
|
|
word_count = len(content.split())
|
|
if word_count <= MAX_WORDS:
|
|
ok(f"SKILL.md word count {word_count} (limit: {MAX_WORDS}, proxy for ~5,000 tokens)")
|
|
else:
|
|
fail(f"SKILL.md word count {word_count} — exceeds {MAX_WORDS}-word limit (proxy for ~5,000 tokens)")
|
|
|
|
body = content[body_start:]
|
|
|
|
# --- ADR-0020: description budget -----------------------------------------
|
|
if desc:
|
|
dlen = len(desc)
|
|
if dlen > DESC_MAX_CHARS:
|
|
fail(f"description is {dlen} chars — exceeds the {DESC_MAX_CHARS}-character "
|
|
f"ADR-0020 ceiling. It is preloaded into every session whether or not the "
|
|
f"skill is invoked. Keep a trigger clause, at most one capability clause, "
|
|
f"and a boundary clause; move capability enumeration, output-format detail, "
|
|
f"composition notes and implementation detail to the body or a references/ file")
|
|
elif dlen > DESC_SUGGEST_CHARS and not by_hand:
|
|
suggest(f"description is {dlen} chars — over the {DESC_SUGGEST_CHARS}-character "
|
|
f"ADR-0020 target (hard fail at {DESC_MAX_CHARS}). The SUGGESTION tier is "
|
|
f"what moves the corpus average; the FAIL tier only stops outliers")
|
|
elif by_hand:
|
|
ok(f"description length {dlen} chars (hand-invoked: the {DESC_SUGGEST_CHARS}-character "
|
|
f"routing target does not apply, the {DESC_MAX_CHARS}-character ceiling still does)")
|
|
else:
|
|
ok(f"description length {dlen} chars (ADR-0020 target: {DESC_SUGGEST_CHARS})")
|
|
|
|
# --- ADR-0020: body budget -------------------------------------------------
|
|
# Counts the BODY ONLY — everything after the closing --- of the frontmatter.
|
|
# This is a different measurement from MAX_WORDS above, which counts the whole
|
|
# file including frontmatter as a spec-conformance backstop. Both are reported.
|
|
body_word_count = len(body.split())
|
|
if body_word_count > BODY_MAX_WORDS:
|
|
fail(f"SKILL.md body is {body_word_count} words — exceeds the {BODY_MAX_WORDS}-word "
|
|
f"ADR-0020 ceiling (body only; separate from the {MAX_WORDS}-word whole-file "
|
|
f"limit above). Move lookup tables, spec restatements, output schemas, templates "
|
|
f"and rationale prose to references/ behind an explicit "
|
|
f"\"If X, read `references/file.md`\" trigger. At two or more mutually exclusive "
|
|
f"flows, dispatch is mandatory: the body carries the dispatch table and the gates "
|
|
f"common to every branch, each flow gets its own self-contained references/ file")
|
|
elif body_word_count > BODY_SUGGEST_WORDS:
|
|
suggest(f"SKILL.md body is {body_word_count} words — over the {BODY_SUGGEST_WORDS}-word "
|
|
f"ADR-0020 target (hard fail at {BODY_MAX_WORDS})")
|
|
else:
|
|
ok(f"SKILL.md body word count {body_word_count} (ADR-0020 target: {BODY_SUGGEST_WORDS})")
|
|
|
|
# --- Reference pointers must exist -----------------------------------------
|
|
# FAIL, not SUGGESTION: a dispatch table naming a references/ file that is not
|
|
# on disk is a hard break, and until this check existed nothing in the
|
|
# gate/audit/vale stack noticed it — all three exited 0.
|
|
missing_refs = missing_reference_pointers(body, skill_dir)
|
|
for ref in missing_refs:
|
|
fail(f"SKILL.md body points at {ref}, which does not exist on disk — a dispatch "
|
|
f"table or \"read X\" trigger naming a missing file sends the agent nowhere")
|
|
if not missing_refs:
|
|
ok("all referenced references/ files exist")
|
|
|
|
# --- Gotchas discipline -----------------------------------------------------
|
|
# SUGGESTION on both counts: the measurement is deterministic, but whether a
|
|
# given gotcha earns its place in the body is the auditor's judgment.
|
|
gotchas = gotcha_stats(body)
|
|
if gotchas is not None:
|
|
gotcha_entries, gotcha_words = gotchas
|
|
if gotcha_entries > GOTCHA_MAX_ENTRIES:
|
|
suggest(f"Gotchas section has {gotcha_entries} entries — over the "
|
|
f"{GOTCHA_MAX_ENTRIES}-entry guideline. A list that long is usually a "
|
|
f"missing references/ file or a design problem written up as a warning")
|
|
if body_word_count and gotcha_words > body_word_count * GOTCHA_MAX_BODY_FRACTION:
|
|
suggest(f"Gotchas section is {gotcha_words} of {body_word_count} body words "
|
|
f"({round(100.0 * gotcha_words / body_word_count)}%) — over the "
|
|
f"{round(100.0 * GOTCHA_MAX_BODY_FRACTION)}% guideline. Move the durable "
|
|
f"parts to references/ and keep the section for live traps")
|
|
|
|
# --- ADR-0020: boundary clause present -------------------------------------
|
|
# SUGGESTION, not FAIL: detecting the absence is deterministic, but whether
|
|
# this particular skill warrants a boundary clause is judgment. Both accepted
|
|
# shapes count — the prose markers and the compressed `Not <thing> -> <name>`.
|
|
#
|
|
# THREE outcomes, not two: "no boundary clause" and "boundary clause I could not
|
|
# parse" are different findings, and reporting the first for the second sends
|
|
# the author hunting for a problem that is not there (issue #110).
|
|
#
|
|
# Skipped entirely for a hand-invoked skill — the contract gives it one plain
|
|
# sentence with no boundary clause, so the finding would be wrong and its remedy
|
|
# names a router that cannot see the skill (issue #108).
|
|
if desc and by_hand:
|
|
ok("hand-invoked (disable-model-invocation) — the boundary-clause and trigger "
|
|
"rules do not apply; audited as one plain human-facing sentence")
|
|
elif desc:
|
|
status = boundary_clause_status(desc)
|
|
if status == 'present':
|
|
ok("description has a boundary clause")
|
|
elif status == 'absent':
|
|
suggest("description has no boundary clause — add the prose form (\"Do not use "
|
|
"for X — use `y` instead\") or ADR-0020's compressed form (\"Not X -> y\") "
|
|
"so the router knows where NOT to send this skill")
|
|
else:
|
|
suggest("description has an arrow boundary clause (\"Not X -> y\") from which no "
|
|
"target could be read, so the dangling-target check did not run on it — "
|
|
"the clause is PRESENT and unparsed, not missing. Most often the target is "
|
|
"a single word, which is deliberately not matchable bare because "
|
|
"`research`, `triage` and `forge` are all ordinary English: write it as "
|
|
"`name` or /name")
|
|
# One arrow, one target. A second name after the same arrow is resolved by
|
|
# nothing and reported by nothing, so the clause claims coverage it does not
|
|
# have and this script printed "1 of 1 boundary target(s) resolve" on a
|
|
# clause naming two (issue #107).
|
|
for first, second in multi_target_arrow_clauses(desc):
|
|
suggest(f"an arrow boundary clause names more than one target ('{first}', then "
|
|
f"'{second}') and only the first is resolved — the second is checked by "
|
|
f"nothing. Split it into one arrow per target: \"Not X -> {first}. "
|
|
f"Not Y -> {second}.\"")
|
|
|
|
# --- ADR-0020: resolvable boundary targets ---------------------------------
|
|
# The resolution universe comes from the SKILL's own location: the authoring
|
|
# root above it (every sibling plugin in the monorepo), its own apm package, and
|
|
# the packages that package declares in apm.yml dependencies.apm. It is never
|
|
# derived from this script's own path, and — when an authoring root exists — it
|
|
# never reads a deployed .claude/ tree, so a fresh clone and a machine that has
|
|
# run `apm install` return the same verdict. See the shared resolver's header.
|
|
routing_targets = boundary_targets(desc) if desc else []
|
|
# Body-level targets (issue #124): notation only (`/name`, `-> name`), so
|
|
# every hit is unconditionally blocking — see the shared resolver's
|
|
# body_targets() header for why the description gate's SUGGESTION tier has
|
|
# no counterpart here. Read regardless of `desc`: a body dispatch table can
|
|
# carry a broken route even when the description carries none.
|
|
body_routing_targets = body_targets(body)
|
|
if routing_targets or body_routing_targets:
|
|
known = known_targets(skill_dir)
|
|
if not known:
|
|
unchecked = sorted(set(routing_targets) | set(body_routing_targets))
|
|
info(f"boundary-target resolution DID NOT RUN — no skill universe could be "
|
|
f"determined for this path (no authoring root above it, no apm package "
|
|
f"root, no declared apm dependencies, no deployed .claude/ or .agents/ "
|
|
f"tree). Unchecked target(s): {', '.join(unchecked)}")
|
|
else:
|
|
if routing_targets:
|
|
# blocking vs reported: a target only earns a FAIL when it is written in
|
|
# route notation or its own sentence corroborates it by naming another
|
|
# target that resolves. See the shared resolver's CORROBORATION note.
|
|
unresolved, soft = unresolved_targets(desc, known)
|
|
for target in unresolved:
|
|
fail(f"description routes to '{target}', which resolves to no skill or agent "
|
|
f"in this monorepo, in this package, or in a package it declares in "
|
|
f"apm.yml dependencies.apm — a boundary clause naming a non-existent "
|
|
f"target sends the router nowhere")
|
|
for target in soft:
|
|
suggest(f"description routes to '{target}', which resolves to no skill or agent "
|
|
f"in this monorepo, in this package, or in a package it declares in "
|
|
f"apm.yml dependencies.apm — SUGGESTION rather than FAIL because nothing "
|
|
f"else in that sentence resolves, so it is equally likely to be a tool, a "
|
|
f"file format or an English compound. If it IS a route, write it as "
|
|
f"`/{target}` or `-> {target}` and it will be checked properly")
|
|
if not unresolved:
|
|
# Counts the targets that ACTUALLY resolve, not every target found:
|
|
# a confirm-only target (one used attributively — see the resolver's
|
|
# ATTRIBUTIVE USE note) is exempt from the failure above, so
|
|
# reporting it as resolved would be a false claim.
|
|
resolved = [t for t in routing_targets if normalize_target(t) in known]
|
|
ok(f"{len(resolved)} of {len(routing_targets)} boundary target(s) resolve: "
|
|
f"{', '.join(resolved) if resolved else '(none)'}")
|
|
unresolved_body = unresolved_body_targets(body, known)
|
|
for target in unresolved_body:
|
|
fail(f"body routes to '{target}' (`/{target}` or `-> {target}` notation), which "
|
|
f"resolves to no skill or agent in this monorepo, in this package, or in a "
|
|
f"package it declares in apm.yml dependencies.apm — a dispatch table or "
|
|
f"\"run X\" step naming a non-existent target sends the agent nowhere")
|
|
if body_routing_targets and not unresolved_body:
|
|
ok(f"{len(body_routing_targets)} of {len(body_routing_targets)} body routing "
|
|
f"target(s) resolve: {', '.join(body_routing_targets)}")
|
|
|
|
# Body unfilled placeholders
|
|
fill_matches = PLACEHOLDER_RE.findall(body)
|
|
if fill_matches:
|
|
fail(f"SKILL.md body contains {len(fill_matches)} unfilled 'FILL IN:' placeholder(s)")
|
|
else:
|
|
ok("SKILL.md body has no unfilled placeholders")
|
|
|
|
# Interactive prompt heuristic.
|
|
#
|
|
# A line-initial `read` only blocks an agent when its stdin is the terminal.
|
|
# These forms never touch a TTY and are ordinary data plumbing, so flagging
|
|
# them is a false positive — one that has already cost two authors a
|
|
# contorted rewrite of working source:
|
|
#
|
|
# read -r MODE ROOT <<< "$WALK_OUTPUT" here-string
|
|
# read -r X <<EOF here-doc
|
|
# read -r line < "$file" redirect from a file
|
|
# printf '%s' "$v" | piped stdin — the pipe ends the
|
|
# read -r X PREVIOUS line, not this one
|
|
#
|
|
# So a `read` is reported only when it has neither a stdin redirection on its
|
|
# own line nor a pipe terminating the previous logical line. `read -r ANSWER`,
|
|
# `read -p "..." X` and a bare `read` still fail, which is the case the check
|
|
# exists for.
|
|
def stdin_redirected(line, prev_line):
|
|
# Quoted spans are stripped first so a `<` inside a prompt string is not
|
|
# mistaken for a redirect: `read -p "enter <name>: " X` is interactive and
|
|
# must still fail.
|
|
unquoted = re.sub(r'"[^"]*"|\'[^\']*\'', '', line)
|
|
return '<' in unquoted or prev_line.rstrip().endswith('|')
|
|
|
|
# A here-doc body is DATA, not command position. Every script in this corpus
|
|
# carries a `usage() { cat <<EOF ... EOF; }`, and prose wrapped inside one puts
|
|
# ordinary English at the start of a line — "read is reported as an INFO ..."
|
|
# in this skill's own validate-provenance.sh, which made the skill audit
|
|
# hard-FAIL on its own script. Reflowing that one sentence would have cleared the finding
|
|
# and left the cause: every future usage text is one wrap away from the same
|
|
# false positive, and the remedy an author reaches for is contorting working
|
|
# source, which the note above records has already happened twice.
|
|
#
|
|
# Detection is deliberately conservative in the direction that matters. A
|
|
# here-doc body is skipped only when its terminator is actually found further
|
|
# down the file; an opener with no terminator — the shape a stray `<<` inside a
|
|
# string would produce — is ignored rather than allowed to swallow the tail,
|
|
# because swallowing the tail is a false NEGATIVE and this check exists to fail
|
|
# closed. `<<<` here-strings open nothing and are excluded by the lookbehind.
|
|
HEREDOC_START_RE = re.compile(r'(?<!<)<<-?\s*(["\']?)([A-Za-z_][A-Za-z0-9_]*)\1')
|
|
|
|
|
|
def heredoc_delimiter(line):
|
|
"""The here-doc terminator this line opens, or None."""
|
|
m = HEREDOC_START_RE.search(line)
|
|
return m.group(2) if m else None
|
|
|
|
|
|
def heredoc_body_indices(lines):
|
|
"""Line indices that are here-doc BODY (plus its terminator), not code."""
|
|
skip = set()
|
|
i, n = 0, len(lines)
|
|
while i < n:
|
|
stripped = lines[i].strip()
|
|
delim = None if stripped.startswith('#') else heredoc_delimiter(lines[i])
|
|
if delim:
|
|
# `<<-` allows an indented terminator, so compare stripped.
|
|
for j in range(i + 1, n):
|
|
if lines[j].strip() == delim:
|
|
skip.update(range(i + 1, j + 1))
|
|
i = j
|
|
break
|
|
i += 1
|
|
return skip
|
|
|
|
|
|
# The here-doc exemption applies to the `read` heuristic ONLY, and the
|
|
# asymmetry is the point. `read` is an ordinary English verb, so any prose a
|
|
# script prints is one line-wrap away from opening with it. `input(` is not a
|
|
# word — a line beginning `input(` inside a here-doc is an embedded Python
|
|
# program pausing for a keypress, which is exactly what this check is for, and
|
|
# these scripts embed Python in a here-doc as a matter of course. Exempting the
|
|
# whole body would have disarmed the check across every script in the corpus.
|
|
def interactive_reads(source):
|
|
hits = []
|
|
prev_line = ''
|
|
lines = source.splitlines()
|
|
in_heredoc = heredoc_body_indices(lines)
|
|
for idx, line in enumerate(lines):
|
|
stripped = line.strip()
|
|
if idx in in_heredoc:
|
|
if re.match(r'input\(', stripped):
|
|
hits.append(stripped)
|
|
continue
|
|
if re.match(r'read(\s|$)', stripped):
|
|
if not stdin_redirected(line, prev_line):
|
|
hits.append(stripped)
|
|
elif re.match(r'input\(', stripped):
|
|
hits.append(stripped)
|
|
# Blank lines and comments cannot carry the pipe that feeds a
|
|
# following `read`, so they never displace the previous line.
|
|
if stripped and not stripped.startswith('#'):
|
|
prev_line = line
|
|
return hits
|
|
|
|
# Scripts checks
|
|
scripts_dir = os.path.join(skill_dir, "scripts")
|
|
if os.path.isdir(scripts_dir):
|
|
scripts = [f for f in os.listdir(scripts_dir)
|
|
if os.path.isfile(os.path.join(scripts_dir, f)) and not f.endswith('.md')]
|
|
for fname in scripts:
|
|
fpath = os.path.join(scripts_dir, fname)
|
|
try:
|
|
sc = read_text(fpath)
|
|
except EncodingError as exc:
|
|
# The executable-bit check below still runs — one unreadable byte
|
|
# must not silently drop a second, independent check.
|
|
sc = None
|
|
fail(f"scripts/{fname}: {exc} — it could not be scanned for "
|
|
f"interactive prompts")
|
|
interactive = interactive_reads(sc) if sc is not None else []
|
|
if interactive:
|
|
fail(f"scripts/{fname}: may use interactive input "
|
|
f"(read/input from a terminal detected): {interactive[0]}")
|
|
elif sc is not None:
|
|
ok(f"scripts/{fname}: no interactive prompts detected")
|
|
# Executable bit
|
|
if os.access(fpath, os.X_OK):
|
|
ok(f"scripts/{fname}: is executable")
|
|
else:
|
|
fail(f"scripts/{fname}: not executable — run: chmod +x {fpath}")
|
|
|
|
# Summary
|
|
print()
|
|
for s in suggestions:
|
|
print(f"SUGGESTION {s}")
|
|
if suggestions:
|
|
print()
|
|
if not failed:
|
|
if suggestions:
|
|
# Feeds SKILL.md Step 4's `PASS (N suggestions)` result line. A
|
|
# SUGGESTION never changes the exit code — only a FAIL does.
|
|
print(f"All checks passed ({len(suggestions)} suggestion(s)).")
|
|
else:
|
|
print("All checks passed.")
|
|
sys.exit(0)
|
|
else:
|
|
print("One or more checks failed.")
|
|
sys.exit(1)
|
|
KYBERFORGE_SKILL_BODY
|
|
KYBERFORGE_SKILL_BODY_PY="${KYBERFORGE_SKILL_BODY_PY%$'\n'}"
|