Files
holocron/plugins/kyberforge/.apm/skills/factory-audit/scripts/lib-checks-skill.sh
Defame1297 1d40544075 fix(gates): hold skill versions above main's tip as well as the merge-base
Why: two branches that both bump a skill 1.0.0 -> 1.0.1 with different
content merge without a conflict, and each passed the gate against its own
merge-base, so main could ship two changes under one version.

Implementation Notes:
- check-skill-version-bump requires the pushed version to exceed both the
  merge-base and the main tip; failures name the baseline they missed.
- Presence is read from the tree, so a blob missing from a partial clone is
  a read failure instead of a silently exempt "new" skill.
- A leading UTF-8 BOM no longer reads as a missing version.
- Version parts reject leading zeros in all three validators
  (check-skill-version-bump, skill-size-check, factory-audit).
- New tests cover equal bumps, moved files, major/minor ordering, bad refs,
  unreadable blobs, mode-only changes, symlinks and tag peeling.

Impact: ADR-0022 amended (reverses "not main's current tip"); gates.md
updated to match, including pre-commit 4.6.1's exact ref selection.

ADR: 0022
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 11:24:08 +00:00

628 lines
31 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 copies print findings, skill-size-check.sh has its own top-level
# equivalent, and tests/test-adr0020-contract.sh hashes that block for
# byte-identity across all three.
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 README.md")
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.
if desc:
routing_targets = boundary_targets(desc)
known = known_targets(skill_dir) if routing_targets else set()
if routing_targets and not known:
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(routing_targets)}")
elif 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)'}")
# 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'}"