Files
holocron/scripts/skill-size-check.sh
Defame1297 ef27c9751a refactor(gates): source the boundary resolver into skill-size-check
Why: scripts/skill-size-check.sh embedded a byte-identical 1,061-line copy
of the ADR-0020 boundary resolver only because it was also exported
through .pre-commit-hooks.yaml, whose consumers could not reach a file
inside the plugin. 4de5b6b retired that export, so the hook now runs only
in this repo and can source factory-audit's lib-boundary-resolver.sh like
validate.sh does. One copy removes the edit-one-paste-the-other hazard.

Implementation Notes:
- The hook's Python program is assembled from its own preamble, the
  library's resolver and its own checks, read from quoted here-docs. The
  assembled program matches the old one line for line except one comment,
  and the hook's stdout, stderr and exit code are identical over every
  corpus SKILL.md and the 26 differential-suite fixtures.
- The hook fails closed, naming the library, when it is missing or
  defines no resolver.
- test-adr0020-contract.sh assertion 1 now pins the single copy: one
  marker pair in the library, none in the hook, fail-closed on a missing
  or gutted library, and a sentinel planted in a copied library that must
  appear in the hook's output. 1a expects exactly one authority. 27 -> 29
  passes.
- ADR-0020 and ADR-0025 carry dated amendments; gates.md and the
  library, hook and mode-library comments no longer describe two copies.
- factory-audit is new on this branch, so the version-bump gate exempts
  it; kyberforge is already at 2.0.0 against main's 1.6.2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 13:25:16 +00:00

510 lines
27 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
# factory-audit/scripts/lib-checks-skill.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
# factory-audit/scripts/lib-checks-skill.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).
#
# THE MEASUREMENT BASIS, stated because the previous re-measure drifted onto a
# different one and the numbers moved without the prose noticing: characters
# per word is len(text) / len(text.split()) over the WHOLE FILE, whitespace
# included, on plugins/*/.apm/skills/*/SKILL.md. Counting only non-whitespace
# characters gives a materially lower figure (4.90 / 5.52 / 5.54 / 6.19 today)
# and is not the basis MAX_WORDS is calibrated against.
#
# Measured over this repo's 39 in-scope SKILL.md files (2026-08-31, after the
# ADR-0020 retrofit), characters per word runs min 5.93 / median 6.67 / mean
# 6.63 / max 7.34. At the standard ~4-characters-per-token English
# approximation that is 1.48 / 1.67 / 1.66 / 1.84 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.34 chars/word is ~20,300
# characters, or ~5,090 tokens at the 4-characters-per-token approximation. So
# what this gate guarantees is "about 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 spend ~5,320 tokens. A
# median-density file at 2770 words spends ~4,620 tokens, so typical prose
# gives up ~140 words of headroom to close that gap. Densest file today:
# git-commits at 7.34 chars/word.
#
# THE CORPUS IS NOWHERE NEAR THIS CEILING ANY MORE, and the note that used to
# stand here — "a gate two files have already grown into" — described the
# pre-retrofit corpus and is now wrong by a factor of three. The largest
# SKILL.md is write-docs at 914 whole-file words, then vale-run at 874;
# skill-author, the old high-water mark at 2,760, is down to 661. MAX_WORDS is
# a spec-conformance backstop with roughly 1,850 words of slack, and the gate
# that actually bites is ADR-0020's 900-word body budget below it. Do not read
# the two as redundant: they measure different spans, and a file can sit well
# inside one while failing the other.
#
# 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, on the basis
# stated above, 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
# factory-audit/scripts/lib-checks-skill.sh (Python) rather than shared from one
# file: this script is a standalone bash pre-commit hook, that one is an
# in-skill Python check library invoked in a different context.
# 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: factory-audit/scripts/lib-checks-skill.sh holds a second copy of
# DESC_SUGGEST_CHARS / DESC_MAX_CHARS / BODY_SUGGEST_WORDS / BODY_MAX_WORDS,
# and factory-audit/scripts/lib-checks-agent.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). ADR-0025's merge put those two libraries
# in one directory but did NOT collapse the copies — the two flows gate
# different spans — so the drift risk is unchanged: if they diverge, the 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
# ZERO ARGUMENTS IS A USAGE ERROR, exit 2 — not a clean run.
#
# This hook is `pass_filenames: true` in .pre-commit-config.yaml, and
# pre-commit skips a filename-passing hook entirely when nothing matches its
# `files:` pattern, so it never invokes this script with an empty argument list. Every no-argument invocation therefore comes from
# somewhere else — a hand-run command, a wrapper, or a `files:` pattern edited
# into matching nothing — and printing nothing and exiting 0 made all three
# indistinguishable from a clean corpus. A mis-scoped pattern would have
# silently disabled the whole ADR-0020 gate family while every hook reported
# green.
#
# Exit 2, not 1, for the same reason a8cd5e8 split validate-adapter.sh's usage
# exits out: {0,1} are this script's verdict codes (clean / findings), and a
# caller that reads a non-zero exit as "the SKILL.md needs editing" must be able
# to tell a broken invocation from a real finding.
if [[ $# -eq 0 ]]; then
echo "usage: skill-size-check.sh <SKILL.md> [SKILL.md ...]" >&2
echo " Measures the agentskills.io spec ceilings and the ADR-0020 context" >&2
echo " budget for each SKILL.md named on the command line." >&2
echo " No paths were given. This is a usage error, not a clean run: a hook" >&2
echo " whose files: pattern matches nothing would otherwise be" >&2
echo " indistinguishable from a corpus with no findings." >&2
exit 2
fi
# An unreadable path — a broken symlink named SKILL.md is storable in git, and a
# directory named SKILL.md reaches this hook the same way — is diagnosed ONCE,
# in the Python per-file loop below. There used to be a bash pre-loop here doing
# exactly the same stat dance and printing exactly the same sentence, so every
# such path was reported twice with two ERROR lines for one broken file. It is
# still NOT a silent skip; the diagnosis simply lives where the file is read.
#
# The MAX_LINES / MAX_WORDS ceilings are not measured in bash either. 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
# factory-audit/scripts/lib-checks-skill.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.
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
# The Python program is assembled from three chunks: the hook's own preamble,
# the ADR-0020 shared boundary resolver, and the hook's own per-file checks. The
# resolver is not embedded here. It has exactly one copy, in factory-audit's
# lib-boundary-resolver.sh, which this hook and factory-audit's validate.sh
# both source, so the two cannot drift apart. It used to be embedded verbatim
# because this hook was also exported through a published .pre-commit-hooks.yaml;
# 4de5b6b retired that export (ADR-0014), so this script now only ever runs
# inside this repo, where the plugin path always exists.
#
# Both chunks are read with the `read` builtin from QUOTED here-docs, so nothing
# inside them is expanded, and exactly one trailing newline is stripped from each
# — the same rule lib-boundary-resolver.sh documents — so the reassembled
# program is line-for-line the program this script used to run.
IFS='' read -r -d '' SSC_PREAMBLE_PY <<'SSC_PREAMBLE_PY' || true
import glob
import os
import re
import sys
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. read_text() in the
# shared resolver block below pins the reads; this pins the writes.
#
# Deliberately OUTSIDE the ADR-0020 shared boundary resolver block: every
# consumer of that block needs this, but the block is one sourced copy shared
# with factory-audit, so each consumer's own startup belongs in its own
# preamble, not inside the block.
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding='utf-8')
except AttributeError: # pragma: no cover — Python < 3.7
pass
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)
# The ADR-0020 shared boundary resolver is spliced in HERE, between this
# chunk and the next, from factory-audit's lib-boundary-resolver.sh — the one
# copy of it in the repo. See the composition step after the two here-docs.
# Keep this note at four lines: the program's line numbers match the old one.
SSC_PREAMBLE_PY
SSC_PREAMBLE_PY="${SSC_PREAMBLE_PY%$'\n'}"
IFS='' read -r -d '' SSC_CHECKS_PY <<'SSC_CHECKS_PY' || true
# --- 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. This is
# the ONLY place that diagnosis is made; the bash pre-loop that used to
# duplicate it printed a second ERROR line for the same broken file.
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 factory-audit/scripts/lib-checks-skill.sh uses for these
# two constants — see the note in bash 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
# Required-field presence (folded in from the former standalone
# `skill-frontmatter` hook). description_value() above already proved the
# frontmatter is valid YAML and a mapping, so a second yaml.safe_load here
# cannot raise.
fm_data = yaml.safe_load(fm_match.group(1)) or {}
name_val = fm_data.get('name')
if not isinstance(name_val, str) or not name_val.strip():
error("%s: name field is missing or empty (required frontmatter field)."
% path)
version_val = None
metadata_val = fm_data.get('metadata')
if isinstance(metadata_val, dict):
version_val = metadata_val.get('version')
if version_val is None:
error("%s: metadata.version field is missing (required frontmatter "
"field, e.g. \"1.0.0\")." % path)
# Same shape as check-skill-version-bump.sh: ASCII digits, at most nine per
# part (bash arithmetic), no leading zero (semver 2.0.0 item 2).
elif not re.fullmatch(r'(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})',
str(version_val).strip().strip('\'"')):
error("%s: metadata.version is malformed (%r) -- expected a "
"three-part semver, e.g. \"1.0.0\"." % (path, version_val))
body = content[fm_match.end():]
skill_dir = os.path.dirname(os.path.abspath(path))
# ADR-0020's hand-invocation carve-out. See hand_invoked() for what it lifts
# and, more importantly, what it does not (issue #108).
by_hand = hand_invoked(fm_match.group(1))
# 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 and not by_hand:
# The 250-character TARGET is a routing-quality budget: it exists to
# keep the preloaded listing small and the trigger clause sharp. A
# hand-invoked description is in no listing, so there is no budget to
# spend and no shape to enforce. The 400-character FAIL above still
# applies — see hand_invoked().
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)))
# 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.
#
# THREE outcomes, not two. Reporting "no boundary clause" for a clause that
# is present and merely unparsed is a wrong finding, not a strict one, and
# it cost three authors a reworded clause before it was diagnosed (#110).
#
# Skipped entirely for a hand-invoked skill: the contract gives it one plain
# sentence with no boundary clause, so the finding is wrong and its remedy
# names a router that cannot see the skill (#108).
if desc and not by_hand:
status = boundary_clause_status(desc)
if status == 'absent':
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)
elif status == 'unparsed':
suggest("%s: 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 "
"(ADR-0020). The clause is present — this is a PARSE failure, not a "
"missing clause. 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." % path)
# One arrow, one target. A second name after the arrow is resolved by
# nothing and reported by nothing, so the clause claims coverage it does
# not have (#107).
for first, second in multi_target_arrow_clauses(desc):
suggest("%s: an arrow boundary clause names more than one target ('%s', then "
"'%s'), and only the first is resolved — the second is checked by "
"nothing (ADR-0020). Split it into one arrow per target: "
"\"Not X -> %s. Not Y -> %s.\"" % (path, first, second, first, second))
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)
SSC_CHECKS_PY
SSC_CHECKS_PY="${SSC_CHECKS_PY%$'\n'}"
# The resolver library's absence is a hard failure, never a skip: without it
# the boundary-target gate has nothing to run, and a hook that exits 0 having
# checked nothing looks exactly like a clean pass in pre-commit's output.
# Located by parameter expansion rather than `dirname`, so no external command
# runs between the python3 preflight above and the gates below.
SSC_DIR="${BASH_SOURCE[0]%/*}"
[[ "$SSC_DIR" == "${BASH_SOURCE[0]}" ]] && SSC_DIR=.
RESOLVER_LIB="$SSC_DIR/../plugins/kyberforge/.apm/skills/factory-audit/scripts/lib-boundary-resolver.sh"
if [[ ! -f "$RESOLVER_LIB" ]]; then
echo "ERROR: the ADR-0020 boundary resolver library was not found at $RESOLVER_LIB." >&2
echo " Why: this hook sources its boundary resolver from factory-audit rather than carrying a copy, so without it no description, body or boundary-target gate can run — and skipping them would be a vacuous pass." >&2
echo " Fix: factory-audit's scripts/ directory has moved or been renamed. Update RESOLVER_LIB in this script to wherever lib-boundary-resolver.sh now lives." >&2
exit 1
fi
KYBERFORGE_RESOLVER_PY=''
# shellcheck source=../plugins/kyberforge/.apm/skills/factory-audit/scripts/lib-boundary-resolver.sh
. "$RESOLVER_LIB"
if [[ "$KYBERFORGE_RESOLVER_PY" != *'BEGIN ADR-0020 SHARED BOUNDARY RESOLVER'* ]]; then
echo "ERROR: $RESOLVER_LIB did not define the ADR-0020 boundary resolver (KYBERFORGE_RESOLVER_PY is empty or has lost its BEGIN marker)." >&2
echo " Why: running the gates without it would either crash on an undefined name or, worse, pass having resolved nothing." >&2
echo " Fix: restore the resolver here-doc in lib-boundary-resolver.sh." >&2
exit 1
fi
if ! python3 -u - \
"$DESC_SUGGEST_CHARS" "$DESC_MAX_CHARS" \
"$BODY_SUGGEST_WORDS" "$BODY_MAX_WORDS" "$MAX_WORDS" "$MAX_LINES" "$@" <<< "$SSC_PREAMBLE_PY
$KYBERFORGE_RESOLVER_PY
$SSC_CHECKS_PY"
then
FAIL=1
fi
exit $FAIL