Files
holocron/scripts/skill-size-check.sh
Defame1297 e4ed343d64 docs(gates): cite the reachable squash commit for the exit-2 split
a8cd5e8 was squashed into 598a7c3 (#129) and is reachable from no
branch, so the comments now cite the commit that exists on main.

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

510 lines
28 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 598a7c3 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 a references/ file."
% (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