diff --git a/docs/adr/0020-skill-description-and-body-context-contract.md b/docs/adr/0020-skill-description-and-body-context-contract.md index ff898f8..ee21d38 100644 --- a/docs/adr/0020-skill-description-and-body-context-contract.md +++ b/docs/adr/0020-skill-description-and-body-context-contract.md @@ -27,6 +27,14 @@ applies the body tiers on the skill path only. The two copies must still stay by plugin script cannot source the root one, which is why a second copy exists at all. Read every "three" below as the count at the time of writing. +**Amended again (2026-09-16): one copy.** `scripts/skill-size-check.sh` no longer embeds the +resolver. It sources `factory-audit/scripts/lib-boundary-resolver.sh` by path and fails closed if the +library is missing or defines no resolver. The embedded copy had been kept only because the hook was +also exported through `.pre-commit-hooks.yaml`, whose consumers could not reach a file inside the +plugin; `4de5b6b` retired that export (ADR-0014), so the hook runs only inside this repo. The +"byte-identical" sentence above is superseded: there is nothing left to keep identical, and +`tests/test-adr0020-contract.sh` assertion 1 now pins the single copy instead of hashing a pair. + ## Context Every `file:line` citation in this ADR is against the base commit the decision was taken on, diff --git a/docs/adr/0025-skill-audit-and-agent-audit-merge-into-factory-audit.md b/docs/adr/0025-skill-audit-and-agent-audit-merge-into-factory-audit.md index 9a8bb67..3de5f9c 100644 --- a/docs/adr/0025-skill-audit-and-agent-audit-merge-into-factory-audit.md +++ b/docs/adr/0025-skill-audit-and-agent-audit-merge-into-factory-audit.md @@ -10,6 +10,13 @@ below (both exported hook IDs survive) and the case 33 port no longer describe t deleted, and its one-plugin narrowing guard is now a property of case 32. See [ADR-0014's amendment](0014-vale-prefilter-ships-from-the-plugin.md#amendment-2026-09-16-the-external-hook-contract-is-retired). +**Amended (2026-09-16): the root hook sources the resolver.** Point 6 below records sourcing the +resolver into `scripts/skill-size-check.sh` as refuted, because the hook was consumed through +`.pre-commit-hooks.yaml`. That manifest is retired (above), so the reason no longer holds: the hook now +sources `lib-boundary-resolver.sh`, the repo holds one resolver copy, and the contract test's +assertion 1 pins that copy rather than hashing two. Assertion 1a's "exactly those two files" is now +exactly one. Point 6 is left as the record of the decision at the time. + ## Context Every figure below was measured against the worktree on 2026-09-15. Re-derive rather than quote; the diff --git a/docs/spec/gates.md b/docs/spec/gates.md index 1b16c77..0327c2d 100644 --- a/docs/spec/gates.md +++ b/docs/spec/gates.md @@ -410,14 +410,17 @@ skills. They are copied rather than imported because a cache-installed plugin's files outside their own plugin directory. `tests/test-skill-size-check.sh` asserts the copies agree, so drift fails CI rather than silently letting an audit bless a skill the commit hook then rejects. -**The shared boundary resolver is now two copies, not three** (ADR-0025). `scripts/skill-size-check.sh` -still carries it embedded between `BEGIN`/`END ADR-0020 SHARED BOUNDARY RESOLVER` markers; the two -plugin copies that used to sit inside `skill-audit`'s and `agent-audit`'s `validate.sh` collapsed -into the single `factory-audit/scripts/lib-boundary-resolver.sh`, sourced by that skill's scripts. -The two remaining copies must still stay byte-identical — a plugin script cannot source the root -one, which is the constraint that forces a copy to exist at all. +**The shared boundary resolver is one copy** (ADR-0025, then 2026-09-16). It lives between the +`BEGIN`/`END ADR-0020 SHARED BOUNDARY RESOLVER` markers in `factory-audit/scripts/lib-boundary-resolver.sh`. +ADR-0025 collapsed the two copies inside `skill-audit`'s and `agent-audit`'s `validate.sh` into that +file. `scripts/skill-size-check.sh` kept an embedded, byte-identical third copy while it was also +exported through `.pre-commit-hooks.yaml`, whose consumers could not reach a file inside the plugin. +`4de5b6b` retired that export (ADR-0014), so the hook now sources the library by path and fails closed +if the library is missing or defines no resolver. -`tests/test-adr0020-contract.sh` pins that arrangement, and one of its assertions was green on a +`tests/test-adr0020-contract.sh` pins that arrangement: the library carries the only marker pair, +the hook carries none, the hook fails closed without the library, and a sentinel planted in a copied +library proves the hook executes the library's text. One of its assertions was green on a defect it named. "`validate.sh` sources the resolver in **both mode branches**" was implemented as a file-wide `grep -Ec … -ge 2`, which cannot see a branch at all: delete the `agent)` arm's source line and duplicate the `skill)` arm's, and the file-wide count is still 2 and the assertion still passes, @@ -425,7 +428,8 @@ with the agent path running no resolver or some other one. It is now a **per-arm each arm of `validate.sh`'s `case "$MODE" in` block must carry exactly one `source` line inside its own body, and the file must carry exactly those two — with a mutation self-test that performs that exact count-preserving edit on a copy and requires the check to fail on it. The suite went 25 → 28 -cases. +cases. It stood at 27 before the 2026-09-16 change and 29 after it, which replaced the two-copy +hash and its line-count floor with the six one-copy assertions above. ### `python3` and PyYAML are hard requirements diff --git a/plugins/kyberforge/.apm/skills/factory-audit/scripts/lib-boundary-resolver.sh b/plugins/kyberforge/.apm/skills/factory-audit/scripts/lib-boundary-resolver.sh index 02705d7..834708f 100755 --- a/plugins/kyberforge/.apm/skills/factory-audit/scripts/lib-boundary-resolver.sh +++ b/plugins/kyberforge/.apm/skills/factory-audit/scripts/lib-boundary-resolver.sh @@ -1,19 +1,18 @@ #!/usr/bin/env bash # lib-boundary-resolver.sh — SOURCED, never executed. # -# The ADR-0020 shared boundary resolver, as ONE copy for this skill. Both of -# validate.sh's modes compose it into the Python program they run, so the -# skill-mode and agent-mode check suites resolve boundary targets through the -# same code rather than through two copies that can drift apart. +# The ADR-0020 shared boundary resolver — the ONE copy in the repo. Both of +# validate.sh's modes compose it into the Python program they run, and so does +# the repo-root hook scripts/skill-size-check.sh, so the audit and the commit +# hook resolve boundary targets through the same code rather than through +# copies that can drift apart. # # The resolver is Python, and bash cannot source Python, so the block is held # in a shell variable filled from a QUOTED here-doc: nothing inside it is -# expanded, substituted or rewritten, and the text between the two markers -# below is therefore byte-identical to the copy in scripts/skill-size-check.sh -# that tests/test-adr0020-contract.sh hashes. The markers stay on lines of -# their own, at column 0, exactly once each, so `sed -n '/^BEGIN$/,/^END$/p'` -# extracts the same span here as it does from the scripts the test already -# reads. Edit one copy, then paste it over the others. +# expanded, substituted or rewritten, and every consumer runs exactly the text +# between the two markers below. The markers stay on lines of their own, at +# column 0, exactly once each: tests/test-adr0020-contract.sh extracts the span +# with `sed -n '/^BEGIN$/,/^END$/p'`, and asserts no other file carries them. # # The here-doc is consumed by the `read` BUILTIN rather than by `$(cat <<...)`. # This file is sourced by validate.sh before the mode-specific python3/PyYAML @@ -26,7 +25,7 @@ # exactly ONE newline, never a run: blank lines at the end of a chunk are part # of the program text the entry script reassembles, and stripping every # trailing newline deleted them. The here-doc itself is unchanged: still -# QUOTED, still byte-identical between its markers. +# QUOTED, still verbatim between its markers. # # Self-containment (agentskills.io, skill-author/references/deployment-modes.md) # binds BETWEEN skills, not within one: a cache-installed plugin copies each @@ -34,21 +33,22 @@ # travels with the skill and is always readable. That is why this is sourced # here and duplicated across skill boundaries elsewhere. # -# Consumed by: validate.sh (both modes), via $KYBERFORGE_RESOLVER_PY. +# Consumed by: validate.sh (both modes) and scripts/skill-size-check.sh, via +# $KYBERFORGE_RESOLVER_PY. The root hook reaches into this plugin by path, which +# is safe only because it runs solely inside this repo — 4de5b6b retired the +# published hook manifest that once made it run elsewhere (ADR-0014). # shellcheck shell=bash # shellcheck disable=SC2034 IFS='' read -r -d '' KYBERFORGE_RESOLVER_PY <<'KYBERFORGE_ADR0020_RESOLVER_PY' || true # ===== BEGIN ADR-0020 SHARED BOUNDARY RESOLVER ===== -# ONE resolver, embedded VERBATIM in two scripts (ADR-0025 retired the third): -# scripts/skill-size-check.sh -# plugins/kyberforge/.apm/skills/factory-audit/scripts/lib-boundary-resolver.sh -# The block between these markers must stay byte-identical in both. It is copied -# rather than imported because a cache-installed plugin's scripts cannot read -# files outside their own plugin directory, and this repo-root hook is kept fit for -# a published hook manifest (retired; ADR-0014), where only entry[0] is rewritten -- -# so no single file is reachable by both (the same constraint that duplicates the -# ADR-0020 constants). Edit one copy, then paste it over the other. +# ONE resolver, and this is its only copy. Sourced from this file by: +# plugins/kyberforge/.apm/skills/factory-audit/scripts/validate.sh (both modes) +# scripts/skill-size-check.sh (the repo-root commit hook) +# ADR-0025 retired the copies in the two pre-merge audit skills, and the +# 2026-09-16 change retired the copy embedded in the root hook, which had been +# kept only while that hook was also exported through a published hook manifest +# (retired by 4de5b6b; ADR-0014). Edit it here; there is nothing to paste over. # # Requires: glob, os, re, yaml (imported by the host script; PyYAML is a hard # dependency, preflighted in bash before the interpreter starts). diff --git a/plugins/kyberforge/.apm/skills/factory-audit/scripts/lib-checks-agent.sh b/plugins/kyberforge/.apm/skills/factory-audit/scripts/lib-checks-agent.sh index bf51763..860a2bc 100755 --- a/plugins/kyberforge/.apm/skills/factory-audit/scripts/lib-checks-agent.sh +++ b/plugins/kyberforge/.apm/skills/factory-audit/scripts/lib-checks-agent.sh @@ -69,9 +69,9 @@ import yaml # 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. +# 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') @@ -145,9 +145,11 @@ COPILOT_BODY_LIMIT = 30000 # every session exactly like a skill's, so agents take the SAME description # gates. These two constants are DUPLICATED in three places: # scripts/skill-size-check.sh, lib-checks-skill.sh beside this file, and here. -# The repo-root hook's copy cannot be shared with this skill — a cache-installed -# plugin's scripts cannot read files outside their own plugin directory, and the -# hook cannot reach inside the plugin. The two copies INSIDE this skill could be +# The repo-root hook's copy cannot be sourced FROM this skill — a cache-installed +# plugin's scripts cannot read files outside their own plugin directory. (The +# hook could now read these from the plugin, as it already sources +# lib-boundary-resolver.sh, but they sit in its Python preamble; hoisting them +# is a separate change.) The two copies INSIDE this skill could be # shared (ADR-0025: two files in one skill may source a third), and are not only # because each mode library is a verbatim lift of the pre-merge suite whose # constants sit in its Python preamble; hoisting them is a separate change. diff --git a/plugins/kyberforge/.apm/skills/factory-audit/scripts/lib-checks-skill.sh b/plugins/kyberforge/.apm/skills/factory-audit/scripts/lib-checks-skill.sh index b10dd6e..ee3553f 100755 --- a/plugins/kyberforge/.apm/skills/factory-audit/scripts/lib-checks-skill.sh +++ b/plugins/kyberforge/.apm/skills/factory-audit/scripts/lib-checks-skill.sh @@ -68,9 +68,9 @@ import yaml # 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. +# 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') diff --git a/scripts/skill-size-check.sh b/scripts/skill-size-check.sh index f025fd1..88d3c5e 100755 --- a/scripts/skill-size-check.sh +++ b/scripts/skill-size-check.sh @@ -167,9 +167,20 @@ if ! python3 -c 'import yaml' > /dev/null 2>&1; then exit 1 fi -if ! python3 -u - \ - "$DESC_SUGGEST_CHARS" "$DESC_MAX_CHARS" \ - "$BODY_SUGGEST_WORDS" "$BODY_MAX_WORDS" "$MAX_WORDS" "$MAX_LINES" "$@" <<'PYTHON' +# 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 @@ -184,10 +195,10 @@ import yaml # 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: all three -# scripts in the family need this, but tests/test-adr0020-contract.sh hashes -# that block for byte-identity, so shared-looking edits belong beside it, not -# inside it. +# 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') @@ -225,1071 +236,14 @@ def info(msg): print("INFO: %s" % msg) -# Anything inside the BEGIN/END markers below is hashed byte-for-byte against -# the plugin copy by tests/test-adr0020-contract.sh. Never edit the marked span -# in one file alone -- including its comments -- or that test fails. Change both -# copies in one commit, keeping the span's line count intact. -# ===== BEGIN ADR-0020 SHARED BOUNDARY RESOLVER ===== -# ONE resolver, embedded VERBATIM in two scripts (ADR-0025 retired the third): -# scripts/skill-size-check.sh -# plugins/kyberforge/.apm/skills/factory-audit/scripts/lib-boundary-resolver.sh -# The block between these markers must stay byte-identical in both. It is copied -# rather than imported because a cache-installed plugin's scripts cannot read -# files outside their own plugin directory, and this repo-root hook is kept fit for -# a published hook manifest (retired; ADR-0014), where only entry[0] is rewritten -- -# so no single file is reachable by both (the same constraint that duplicates the -# ADR-0020 constants). Edit one copy, then paste it over the other. -# -# Requires: glob, os, re, yaml (imported by the host script; PyYAML is a hard -# dependency, preflighted in bash before the interpreter starts). - -# --- Input ---------------------------------------------------------------- -# Every file this resolver's callers read goes through read_text(), which pins -# UTF-8 explicitly instead of inheriting locale.getpreferredencoding(). Under -# LC_ALL=C that inherited encoding is ASCII, so a perfectly ordinary em dash in -# a SKILL.md aborted the run with a bare UnicodeDecodeError traceback — loud, -# but pointing at the interpreter rather than at the file or the fix. A file -# that genuinely is not UTF-8 still fails; it just says so. - - -class EncodingError(Exception): - pass - - -def read_text(path): - """File contents as text, UTF-8, with a diagnostic instead of a traceback.""" - try: - with open(path, encoding='utf-8') as fh: - return fh.read() - except UnicodeDecodeError as exc: - raise EncodingError( - "not valid UTF-8 (%s at byte %d) — re-save the file as UTF-8; " - "this gate does not guess at other encodings" - % (exc.reason, exc.start)) - - -# --- Universe ------------------------------------------------------------ -# The set of names a boundary clause may resolve against is derived from an -# AUTHORING ROOT found by walking up FROM THE TARGET FILE. It is NEVER derived -# from this script's own location: deriving it from ${BASH_SOURCE} leaked -# holocron's whole skill universe into every consumer repo that ran this hook -# through pre-commit, so a consumer skill routing to a holocron skill such as -# `factory-audit` resolved against a plugin it had never installed. -# -# An authoring root is the nearest ancestor holding plugins/*/.apm/skills/ or -# plugins/*/.apm/agents/ (a plugin monorepo), falling back to the nearest -# ancestor holding .git. When one is found the universe is: -# 1. every skill and agent under /plugins/*/ — sibling plugins resolve, -# which is what a monorepo means, -# 2. the target's own apm package, -# 3. the packages that package DECLARES in apm.yml dependencies.apm. -# Deployed .claude/ and .agents/ trees are deliberately NOT consulted when the -# root came from the plugins/ probe. They are `apm install` output, gitignored, -# and present only on a machine that has run it: four cross-plugin targets in -# this repo (gitea-branches -> git-branches, gitea-branches -> git-history, -# gitea-issues -> git-branches, gitea-workflow -> git-workflow) resolved through -# .claude/skills/ alone, so the same commit measured 2 dangling targets on a -# developer machine and 6 on a fresh clone. A gate shipping hot with no baseline -# cannot give two answers. -# -# Deployed trees ARE used when no plugin monorepo was found — whether the walk -# landed on a bare .git ancestor or on nothing at all. That is the consumer -# case: the file being checked lives in or beside a deployed tree, inside an -# ordinary git repo, with no monorepo to read. The two cases are told apart by -# which probe matched, never by how many names a root contributed; see -# known_targets(). - - -def _is_fs_root(path): - return os.path.dirname(path) == path - - -def _collect_package(pkg_dir, names): - """Add every skill/agent name a package directory exposes, any layout.""" - # glob.escape() the DIRECTORY only. A checkout path containing `[`, `]`, - # `*` or `?` — a worktree named `feature[2]`, say — otherwise turns the - # whole pattern into a character class that matches nothing, and the - # resolver degrades to the "DID NOT RUN" INFO with rc=0 across every file - # in the tree. The wildcards in `sub` are the intended ones and stay raw. - safe_dir = glob.escape(pkg_dir) - for sub in ('.apm/skills/*/', 'skills/*/'): - for path in glob.glob(os.path.join(safe_dir, sub)): - # A directory is a skill only if it HOLDS a SKILL.md. An empty - # leftover — a deleted skill whose directory survived, a scaffolding - # stub, an editor's stray mkdir — is untracked by git, so it exists - # on the machine that made it and nowhere else. Counting it made a - # boundary target resolve locally and dangle in a fresh clone: the - # same install-dependence the deployed-tree rule above exists to - # remove, arriving through a different door. - if os.path.isfile(os.path.join(path, 'SKILL.md')): - names.add(os.path.basename(path.rstrip('/')).lower()) - for sub in ('.apm/agents/*.md', 'agents/*.md'): - for path in glob.glob(os.path.join(safe_dir, sub)): - # The same rule one directory over, which until now had no - # counterpart here at all: the skills branch above tests for a - # SKILL.md, the agents branch took every glob hit on trust. A - # DIRECTORY named `ghost-agent.md` matches `*.md` and glob does not - # tell the two apart, so a leftover of that shape resolved a routing - # target on the machine holding it and dangled everywhere else — - # identical install-dependence, arriving through the one door - # nobody guarded. - if not os.path.isfile(path): - continue - base = os.path.basename(path) - if base.endswith('.agent.md'): - base = base[:-len('.agent.md')] - else: - base = base[:-len('.md')] - names.add(base.lower()) - - -def _apm_package_root(start_dir): - """Nearest ancestor that is an apm package root (apm.yml or .apm/). - - The filesystem root is never a candidate: a stray /.apm/skills/ — a - scaffolding test's leftover, say, and one really does exist on at least one - machine here — would otherwise become the package root of every path on it. - Capped at ten levels so a pathological path can't become a filesystem - crawl; that covers every real layout by a wide margin. - """ - current = os.path.abspath(start_dir) - for _ in range(10): - if _is_fs_root(current): - return None - if (os.path.isfile(os.path.join(current, 'apm.yml')) - or os.path.isdir(os.path.join(current, '.apm'))): - return current - current = os.path.dirname(current) - return None - - -def _authoring_root(start_dir): - """Nearest ancestor that is a plugin monorepo, else the nearest .git tree. - - Returns (root, matched_plugins_probe). The flag reports WHICH probe - matched: True for the plugins/*/.apm/{skills,agents} glob, False for the - .git fallback and for no match at all. known_targets() needs that - distinction — only a real plugins/ root makes the deployed trees - redundant, and a name-count delta cannot tell the two apart. - - Two passes, not one interleaved walk: a nested .git (a submodule, a - worktree of a sub-package) must not win over a real plugins/ root further - up. Both passes stop before the filesystem root for the same reason - _apm_package_root does. - """ - probes = ( - lambda d: bool(glob.glob(os.path.join(glob.escape(d), 'plugins', '*', '.apm', 'skills')) - or glob.glob(os.path.join(glob.escape(d), 'plugins', '*', '.apm', 'agents'))), - lambda d: os.path.exists(os.path.join(d, '.git'))) - for index, probe in enumerate(probes): - current = os.path.abspath(start_dir) - for _ in range(12): - if _is_fs_root(current): - break - if probe(current): - return current, index == 0 - current = os.path.dirname(current) - return None, False - - -def _collect_authoring_root(root, names): - """Every plugin in the monorepo contributes its names.""" - for pkg in glob.glob(os.path.join(glob.escape(root), 'plugins', '*')): - if os.path.isdir(pkg): - _collect_package(pkg, names) - - -def _declared_dependency_dirs(pkg_dir): - """Directories of the apm packages pkg_dir's manifest DECLARES. - - Reads dependencies.apm and resolves each entry to a directory on disk: - a monorepo-relative `path:` (against the package root and the nearest - ancestor manifest, which is the monorepo root) or an installed - apm_modules//. Entries that resolve to nothing are skipped — an - undeployed dependency contributes no names rather than an error. - """ - manifest = os.path.join(pkg_dir, 'apm.yml') - if not os.path.isfile(manifest): - return [] - try: - data = yaml.safe_load(read_text(manifest)) or {} - except Exception: - return [] - if not isinstance(data, dict): - return [] - deps = data.get('dependencies') - deps = deps.get('apm') if isinstance(deps, dict) else None - if not isinstance(deps, list): - return [] - - roots = [pkg_dir] - ancestor = os.path.dirname(os.path.abspath(pkg_dir)) - for _ in range(10): - if _is_fs_root(ancestor): - break - if os.path.isfile(os.path.join(ancestor, 'apm.yml')): - roots.append(ancestor) - break - ancestor = os.path.dirname(ancestor) - - found = [] - for entry in deps: - candidates = [] - if isinstance(entry, dict): - rel = entry.get('path') - name = entry.get('name') - if not name and rel: - name = os.path.basename(str(rel).rstrip('/')) - if rel: - candidates.extend(os.path.join(r, str(rel)) for r in roots) - if name: - candidates.append(os.path.join(pkg_dir, 'apm_modules', str(name))) - elif isinstance(entry, str): - name = re.split(r'[#@]', entry)[0].strip().rstrip('/').split('/')[-1] - if name: - candidates.append(os.path.join(pkg_dir, 'apm_modules', name)) - candidates.extend(os.path.join(r, 'plugins', name) for r in roots) - for candidate in candidates: - if os.path.isdir(candidate): - found.append(candidate) - return found - - -def _deployed_roots(start_dir): - """.claude/ and .agents/ trees above start_dir — what a host really sees. - - Consulted ONLY when no plugin monorepo root was found; see the header. The - filesystem root is skipped for the same reason _apm_package_root skips it: - a stray /.claude/skills/ must not join every path's universe. - """ - found = [] - current = os.path.abspath(start_dir) - for _ in range(10): - if _is_fs_root(current): - break - for name in ('.claude', '.agents'): - base = os.path.join(current, name) - if os.path.isdir(base): - found.append(base) - current = os.path.dirname(current) - return found - - -def known_targets(start_dir): - """Every skill/agent name a boundary clause in start_dir may name.""" - names = set() - start = os.path.abspath(start_dir) - - # Siblings: a cache-installed plugin and a deployed .claude/skills/ tree - # both put peers one level up, with no plugins/ directory above them. The - # grandparent is guarded against the filesystem root exactly like the two - # walk-up loops above — for a start dir of /skills/ the grandparent is - # `/`, and collecting there picks up this machine's stray /.apm/skills/. - parent = os.path.dirname(start) - grandparent = os.path.dirname(parent) - if (os.path.basename(parent) in ('skills', 'agents') - and os.path.isdir(parent) and not _is_fs_root(grandparent)): - _collect_package(grandparent, names) - - package = _apm_package_root(start) - if package: - _collect_package(package, names) - for dep_dir in _declared_dependency_dirs(package): - _collect_package(dep_dir, names) - - # A .git ancestor is an authoring root only if it actually holds plugins. - # _authoring_root() falls back to the nearest .git, so it is truthy in ANY - # git repo; without the distinction that fallback wins in every consumer - # checkout, _collect_authoring_root() contributes nothing, and the deployed - # branch below is dead code in the exact case it exists for. So condition - # on WHICH probe matched, which _authoring_root() reports directly. A - # name-count delta looks equivalent and is not: _collect_authoring_root() - # re-collects the checked file's own plugin, whose names the blocks above - # already added, so a one-plugin monorepo shows a delta of zero and would - # wrongly reach for the deployed trees — including the user's global - # ~/.claude/skills, making the verdict depend on what happens to be - # installed (ADR-0020 lines 118-127). - root, root_has_plugins = _authoring_root(start) - if root: - _collect_authoring_root(root, names) - if not root_has_plugins: - for base in _deployed_roots(start): - _collect_package(base, names) - return names - - -# --- Extraction ----------------------------------------------------------- -# False positives are the design constraint here, not recall. The rules: -# * A BARE target must be hyphenated AND sit in a boundary sentence (one -# carrying "do not"/"instead"/"rather than"/"not for"). Without the second -# condition, pc-run's "run pre-commit hooks" reads as a route to a -# non-existent `pre-commit` skill. -# * A BARE arrow target counts only in ADR-0020's compressed boundary form, -# `Not -> `. The example that motivated it is gone: -# diagnose's process chain "fix -> regression-test", which without the -# gate read as a route to a non-existent `regression-test` skill, was cut -# when issue #99 retrofitted that description. So the gate is currently -# UNEXERCISED — gating and not gating produce the same verdict corpus-wide. -# Keep it anyway. It is a false-positive guard against prose no one has -# written yet, and any new process chain re-arms it. Unexercised is not the -# same as unnecessary, and the branch it guards is still load-bearing: the -# bare-arrow rule is the sole extractor for three real targets in -# kyberforge (factory-audit -> skill-author, factory-audit -> -# agent-author, apm-orchestrate -> apm-install), all written unbackticked. -# * A backticked hyphenated token counts only inside a boundary sentence. -# Unconditionally, `pre-push` or `commit-msg` in a TRIGGER clause is a hard -# FAIL with no escape hatch. Gating it costs nothing (measured over this -# corpus: 54 targets before and after); DELETING it costs 7 real targets -# across three gitea skills, so it is gated, not removed. -# * SINGLE-WORD targets are deliberately NOT matchable bare — `research`, -# `triage`, `forge`, `prototype` and `tdd` are all real skill names and all -# ordinary English, so a bare-word rule would flag most of the corpus. A -# single-word target must be written `` `forge` `` or /forge to be seen. -# That is a known recall limitation, accepted over the false positives. -# Tool names (Read/Write/Edit) are excluded by the lowercase-only pattern; MCP -# tool names (issue_write) by its rejection of underscores; file names by its -# rejection of dots and slashes. -# -# ATTRIBUTIVE USE. The boundary-sentence gate above does NOT solve the -# `pre-commit` false positive, and the comment that claimed it did was wrong: -# "instead", "rather than", "do not" and "not for" are exactly the words a -# boundary clause uses, so the gate is open precisely where the risk is. All of -# these were hard dangling FAILs with no suppression: -# Use pre-commit hooks instead of ad-hoc scripts. -# Invoke the pull-request template instead of writing one by hand. -# Use conventional-commits formatting rather than free-form messages. -# Composes label-resolution logic instead of duplicating it. -# Do not use for X — run the `pre-push` hooks instead. -# What separates every one of them from a real route is grammar, not marking: -# the hyphenated token is a compound MODIFIER of the noun that follows it -# ("pre-commit hooks", "pull-request template"), where a route target is -# terminal — followed by punctuation, a conjunction, or a boundary word. So a -# target whose next token is an ordinary lowercase noun is CONFIRM-ONLY: it -# still resolves and still counts as a route when the name exists, but it can -# never raise a dangling error on its own. -# -# This is deliberately NOT the simpler "only marked targets may dangle" rule, -# which would have been wrong here: BOTH live true positives in this corpus are -# BARE — research's "(use neuledge-context)" and gitea-issues' "Composes -# gitea-labels-\n milestones", where the `>` fold yields "gitea-labels- -# milestones" and the trailing hyphen is what keeps it terminal. Marking is a -# poor proxy, so the follower token is the signal, and it is applied to -# backticked targets too. -# -# TERMINAL IS NOT ENOUGH — IN-SENTENCE CORROBORATION. The follower test clears -# `pre-push` in the example above only because that example happens to be -# followed by the noun "hooks". Move the same token into terminal position and -# it was a hard FAIL again, with no suppression mechanism anywhere in this gate: -# Do not use for running hooks — run `pre-commit` instead. -# Do not use for the commit message — see `commit-msg`. -# Do not use for type errors — run `type-check` first. -# Instead, use `semantic-release`. -# Do not use for the old flow — use the clean-up instead. -# Do not run end-to-end, run unit-tests. -# Every one of those is grammatically identical to a genuinely broken route: -# "route verb + hyphenated name + terminal" is also exactly how prose cites a -# tool, a hook, a file format or an English compound. Nothing local separates -# them, and the skills most exposed are the ones this contract sends authors -# back to rewrite first — pc-run, pc-author, vale-run, vale-config and the apm-* -# family are all ABOUT hyphenated tools. -# -# So the confidence to BLOCK a commit comes from the sentence, not the token: a -# prose-form target may raise a hard error only when its own sentence names at -# least one OTHER target that RESOLVES. A routing sentence proves itself by -# routing somewhere real; a lone unresolvable name proves nothing. That is not a -# rule fitted to the fixtures — it is the shape of both live true positives, -# which sit beside `write-docs` and `gitea-labels-milestones` respectively, and -# it changes this corpus's verdict by exactly nothing. -# -# An uncorroborated unresolvable target is NOT discarded: every caller reports -# it at its SUGGESTION tier, naming the target. The finding stays visible on -# every run; only the power to block a commit is withdrawn, which is the part -# that had no escape hatch. -# -# EXPLICIT ROUTE NOTATION is exempt from corroboration and always blocks: -# ADR-0020's compressed arrow (`Not -> `) and Claude Code's -# invocation form (`/`). Neither is ever how English cites a tool — nobody -# writes `-> pre-commit` or `/pre-commit` to mean the hook — so there is no -# ambiguity to resolve, and an author who wants a route checked unconditionally -# has two ways to say so. -# -# BOTH FORMS ARE SWEPT FOR ON THEIR OWN, and that is a repair of the promise -# above rather than a widening of it. Until the sweeps existed, notation was -# only ever seen as the OBJECT OF A ROUTE VERB (`use -# /name`) or as the tail of a `not ... ->` clause with no `;` or sentence end in -# between. Every one of these therefore exited 0 in total silence — no ERROR, no -# SUGGESTION, not even the target's name: -# Do not use for Y — /no-such-skill instead. -# Do not use for Y; /no-such-skill handles that. -# Do not use for Y (/no-such-skill covers it). -# Do not use for Y — that is /no-such-skill's job. -# Do not use for Y — defer to /no-such-skill. -# Do not use for Y — /no-such-skill. -# Do not use for Y; -> no-such-skill covers it. -# For W, /no-such-skill is the right entry point. -# The target was never EXTRACTED, so the notation-first rule in _add() had -# nothing to apply itself to and the "always blocks" promise was false for the -# ordinary way an author writes the thing. The SUGGESTION tier made it worse -# than a gap: its printed remedy tells the author to "write it as `/name` or -# `-> name` and it will be checked properly", and taking that advice turned a -# visible SUGGESTION into silence — the gate teaching the one edit that blinds -# it. -# -# THE TWO SWEEPS ARE GATED DIFFERENTLY, and the asymmetry is the whole point. -# `/name` is Claude Code's invocation syntax and nothing else — no English -# sentence contains one by accident — so the ADR-0020 amendment and -# docs/spec/gates.md both promise it blocks UNCONDITIONALLY, for any name. So -# NOTATION_SLASH is swept over every sentence, boundary marker or not. Gating it -# on BOUNDARY_MARKER made that promise false for the last sentence of -# Do not use for Z — use /real-skill instead. -# For W, /no-such-skill is the right entry point. -# which exited 0 in total silence: the boundary clause is one sentence up, so -# the sweep never looked at the sentence carrying the broken route. Extraction is -# per-sentence by design (corroboration is scoped to one sentence), which is -# exactly what made the gap invisible. -# -# NOTATION_ARROW stays gated on BOUNDARY_MARKER, and so does the backtick sweep. -# Neither form is unambiguous: `-> name` is also how a process chain is written -# ("reproduce -> minimise -> regression-test") and a code span is how a tool, a -# file and a skill are all cited. Ungating either would fire on prose that -# carries no routing intent at all — the false-positive class this whole -# extractor is tuned against. -# -# BOTH `/name` PATTERNS REFUSE A TOKEN THAT IS PART OF A PATH: a following `/`, -# or a `.` followed by a non-space, means `references/foo.md`, `docs/a/b.md` or -# `https://x/y`, not a route. A sentence's closing `.` is not followed by a -# non-space, so `— /no-such-skill.` still counts. -# -# THAT GUARD IS WRITTEN `(?![\w-])` AND NOT `\b`, because `\b` is not a guard at -# all here: it holds after a hyphen, so when the trailing lookahead rejected the -# full segment the engine simply backtracked to a shorter hyphen-terminated -# prefix and reported THAT as a route. Every one of these was a hard blocking -# ERROR naming a skill nobody had written: -# the config lives at /opt-tools/bin/thing. -> 'opt' -# see /api-docs/v2.md for the schema. -> 'api' AND 'api-docs' -# the file /no-such-skill.md documents it. -> 'no-such' -# `(?![\w-])` forbids the shortened prefix outright, so the whole segment is -# rejected as the path it is. MARKED_TARGET carries the same guard: it had no -# trailing lookahead whatsoever, so `see /api-docs/v2.md` raised the second of -# the two errors above through the route-verb path rather than the sweep. -# -# NAMESPACE: `plugin:skill` is live in this repo (native user-scope installs -# still resolve `gitea:gitea-prs`), so the patterns admit an optional -# `:` prefix and normalize_target() strips it before resolution. -NS = r"(?:[a-z0-9]+(?:-[a-z0-9]+)*:)?" -NAME_ANY = NS + r"[a-z0-9]+(?:-[a-z0-9]+)*" -NAME_HYPH = NS + r"[a-z0-9]+(?:-[a-z0-9]+)+" -ROUTE_VERB = (r"(?:use|uses|using|run|runs|invoke|invokes|invoking|try|see" - r"|that'?s|compose|composes|call|calls" - r"|routes?\s+to|delegates?\s+to|prefers?|switch(?:es)?\s+to" - r"|hands?\s+off\s+to)") -MARKED_TARGET = (r"(?:`/?(%s)`|(?|→)\s*%s" % MARKED_TARGET, re.I) -# The two EXPLICIT ROUTE NOTATION sweeps. NOTATION_SLASH runs over EVERY -# sentence; NOTATION_ARROW is scoped to a boundary sentence by its caller (see -# the asymmetry note in the header). NOTATION_SLASH is deliberately not a reuse -# of MARKED_TARGET's `/name` alternative: that one only ever runs behind a route -# verb or an arrow, and it may match a namespaced or path-adjacent token in -# positions this free-standing sweep must refuse. -# NOTATION_ARROW is ARROW_BOUNDARY minus its leading `\bnot\b%s*?`, which is -# what made `Do not use for Y; -> no-such-skill covers it.` invisible: -# CLAUSE_BODY cannot cross the `;`, so the clause's own punctuation disarmed the -# check. Dropping that prefix costs the one false positive the bare-arrow bullet -# above names — a process chain ending in a hyphenated word, `Instead, reproduce -# -> minimise -> regression-test.` — and costs it only in a sentence that already -# carries a BOUNDARY_MARKER. That exposure is neither new nor larger: the same -# chain written `Do not use for X — reproduce -> regression-test.` was already a -# hard ERROR under ARROW_BOUNDARY, so this changes which boundary words reach the -# arrow, not whether prose can. An author who means the chain and not a route -# writes it in its own sentence, where neither pattern looks. -NOTATION_SLASH = re.compile( - r"(?|→)\s*(%s)\b" % NAME_HYPH, re.I) -# CLAUSE_BODY is what may sit between `Not` and the arrow, and it is NOT -# `[^.;]`. That class cannot cross a `.`, so every boundary clause naming a -# DOTTED FILENAME between the two — `.pre-commit-config.yaml`, `AGENTS.md`, -# `.vale.ini` — was invisible to both patterns below, and the two resulting -# failures were different sizes (issue #110): -# * with a BACKTICKED target the clause was MISDIAGNOSED. The backtick sweep -# still extracted the target, so the route was checked, but the gate -# reported "no boundary clause" on a clause that was present and working. -# Three authors in two retrofit waves reworded a correct clause to satisfy -# the regex, one of them stripping the very filename that discriminates the -# skill from its neighbour. -# * with a BARE target the clause was UNCHECKED. ARROW_BOUNDARY is the only -# extractor for a bare arrow target, so `Not AGENTS.md -> no-such-skill` -# produced no target, no dangling report and no missing-clause SUGGESTION. -# Silence, not noise — the worse of the two failure modes. -# A dot inside a filename is followed by a non-space; a sentence-ending dot is -# followed by whitespace or by end of string. So the class admits a `.` only -# when the next character is not whitespace, which crosses `AGENTS.md` and -# still stops at a real sentence end. -CLAUSE_BODY = r"(?:[^.;]|\.(?=\S))" -ARROW_BOUNDARY = re.compile( - r"\bnot\b%s*?(?:->|→)\s*(%s)\b" % (CLAUSE_BODY, NAME_HYPH), re.I) -BACKTICK = re.compile(r"`(%s)`" % NAME_HYPH, re.I) -# A boundary clause takes two shapes and BOTH count: the prose markers, and -# ADR-0020's compressed arrow form `Not -> `. -BOUNDARY_MARKER = re.compile(r"\b(?:do\s+not|instead|rather\s+than|not\s+for)\b", re.I) -BOUNDARY_ARROW = re.compile(r"\bnot\b%s*?(?:->|→)" % CLAUSE_BODY, re.I) -# Sentence boundaries decide the CORROBORATION scope above, so getting one wrong -# is not cosmetic — it moves a target between SUGGESTION and blocking ERROR. Two -# shapes common in these descriptions defeat the naive "period, space, capital" -# rule, in OPPOSITE directions: -# OVER-SPLIT. `e.g. "set up the manifest"` ends no sentence, but the quote -# looks like one starting. The clause is cut in half, the corroborating -# target lands on the far side of the cut, and a genuinely dangling target -# silently demotes to SUGGESTION — the gate takes a measurement and then -# throws it away, which is the vacuous-green shape this file exists to stop. -# UNDER-SPLIT. A real sentence opening with a code span or a lowercase skill -# name ("... Composes it. `gitea-prs` also uses it.") is not seen as a start -# at all, so two sentences merge and a resolving target vouches for an -# unresolvable one it never stood beside — a hard FAIL with no escape hatch, -# which is exactly the failure the corroboration rule was added to prevent. -# Both are closed here: the five abbreviations that actually occur in routing -# prose are excluded as sentence ends, and the opener class admits a backtick or -# a lowercase letter. Verified zero-delta on the current corpus (37 ERROR / 58 -# SUGGESTION / 2 dangling before and after) — this protects the descriptions -# issue #99 is about to rewrite, not the ones already measured. -# re.I here too, and NOT as a tidy-up: this was the one pattern in the file -# built without it, contradicting the uniformity note on CONT_*/ARROW_* above. -# Without the flag `E.g.` and `I.e.` — the sentence-initial spellings, which is -# where an abbreviation most often lands — matched none of the lookbehinds, so -# the clause split at the abbreviation, the corroborating target was stranded on -# the far side of the cut, and a genuinely dangling target silently demoted from -# blocking ERROR to SUGGESTION. That is the OVER-SPLIT failure described -# directly above, still live for exactly the capitalised half of the input. -SENTENCE_SPLIT = re.compile( - u'(? name` (ADR-0020's compressed boundary - form, passed in by the caller that matched the arrow). A backticked name - does NOT qualify — a code span is how a tool, a file and a skill are all - cited, so it carries no intent the follower test hasn't already read. - """ - return arrow or (start > 0 and text[start - 1] == '/') - - -def _add(out, text, name, start, end, strict=None, arrow=False): - """Record one target as (name, may_dangle, notation). - - NOTATION IS DECIDED FIRST, and when it is set the follower test is skipped. - The header above promises that route notation "always blocks", and for the - `/name` form that was false: `-> name` reached this function with - strict=True from its two call sites, but `/name` did not, so it fell to - _terminal() and a follower outside FOLLOWER_OK set may_dangle=False. The - target then reached unresolved_targets() unblockable — and, before the - companion fix there, unreported as well. `... use /no-such-skill - afterwards.` exited 0 in total silence, on the one form ADR-0020 offers an - author who wants a route checked unconditionally. - """ - if not name: - return - notation = _notation(text, start, arrow) - if strict is None and notation: - strict = True - out.append((name, - _terminal(text, end) if strict is None else strict, - notation)) - - -def _scan(text, route_re, cont_re, out): - for match in route_re.finditer(text): - name, start, end = _first(match) - if not name: - continue - _add(out, text, name, start, end) - # "use git-history or git-branches instead" / "use gitea-issues / - # gitea-prs" — keep consuming conjoined targets after the first. - pos = match.end() - while True: - cont = cont_re.match(text, pos) - if not cont: - break - _add(out, text, *_first(cont)) - pos = cont.end() - - -def _extract_sentence(sentence): - """[(name, may_dangle, notation)] for the routing targets in ONE sentence. - - Kept separate from _extract() because corroboration is scoped to a single - sentence: a target's evidence is what stands beside it, not what the rest of - the description happens to mention. - """ - out = [] - boundary = bool(BOUNDARY_MARKER.search(sentence)) - _scan(sentence, - ROUTE_ANY if boundary else ROUTE_MARKED, - CONT_ANY if boundary else CONT_MARKED, - out) - for match in ARROW_MARKED.finditer(sentence): - # `-> name` and `-> /name` are route notation, not prose: nothing - # reads as a compound modifier after an arrow, so no follower test. - _add(out, sentence, *_first(match), strict=True, arrow=True) - for match in ARROW_BOUNDARY.finditer(sentence): - _add(out, sentence, match.group(1), match.start(1), match.end(1), - strict=True, arrow=True) - # `/name` wherever it sits, in ANY sentence — not only where a route verb or - # an arrow happens to precede it, and NOT only inside a boundary sentence. - # See the EXPLICIT ROUTE NOTATION note in the header for the eight phrasings - # this recovers and for why silence was the failure mode. The sweep takes no - # follower test: _add() reads the notation first and marks it. - for match in NOTATION_SLASH.finditer(sentence): - _add(out, sentence, match.group(1), match.start(1), match.end(1)) - if boundary: - # The arrow and backtick forms are ambiguous in ordinary prose, so they - # stay scoped to a sentence that carries a boundary marker. - for match in NOTATION_ARROW.finditer(sentence): - _add(out, sentence, match.group(1), match.start(1), match.end(1), - strict=True, arrow=True) - for match in BACKTICK.finditer(sentence): - _add(out, sentence, match.group(1), match.start(1), match.end(1)) - return out - - -def _extract(description): - """[(name, may_dangle, notation)] for every routing target.""" - out = [] - for sentence in SENTENCE_SPLIT.split(description): - out.extend(_extract_sentence(sentence)) - return out - - -def boundary_targets(description): - """Every routing target, for reporting and for confirming a route.""" - return sorted({name for name, _, _ in _extract(description)}) - - -def _arrow_targets(description): - """Names extracted from ARROW notation specifically. - - Kept apart from boundary_targets() because the arrow form is the one shape - that ALWAYS names a target: ADR-0020's `Not -> `. A clause - written that way from which nothing could be extracted is a parse failure - that deserves its own message, and telling it apart needs the arrow targets - alone rather than every target in the description. - """ - out = [] - for sentence in SENTENCE_SPLIT.split(description): - for match in ARROW_MARKED.finditer(sentence): - name, _, _ = _first(match) - if name: - out.append(name) - for match in ARROW_BOUNDARY.finditer(sentence): - out.append(match.group(1)) - return out - - -def boundary_clause_status(description): - """'absent', 'unparsed' or 'present' — three outcomes, not two. - - Issue #110's standing request: the gate must distinguish "no boundary - clause" from "boundary clause I could not parse". Reporting the first for - the second sends the author hunting for a problem that is not there, and - three of them reworded a correct clause to satisfy a regex instead. - - 'unparsed' is the narrow, certain case: an ADR-0020 arrow clause was - detected and NO target came out of it. The arrow form always names one, so - zero targets means the name is written in a shape the extractor cannot see - — a single-word bare target (`Not X -> forge`, which has to be written - `` `forge` `` or `/forge`) is the live example, since single-word names are - deliberately not matchable bare. - - A PROSE clause yielding no target is NOT reported: "Do not use for anything - else" is a complete and legitimate boundary clause that names nowhere to go. - """ - if BOUNDARY_ARROW.search(description) and not _arrow_targets(description): - return 'unparsed' - if has_boundary_clause(description): - return 'present' - return 'absent' - - -def multi_target_arrow_clauses(description): - """[(first, second)] for arrow clauses naming more than one target. - - Issue #107: only the FIRST target after an arrow is resolved. The - conjunction continuation (CONT_*) is wired to the prose route verbs and - never to arrows, so `Not X -> a or b` resolved `a`, left `b` neither - resolved nor reported, and then printed "1 of 1 boundary target(s) resolve" - on a clause naming two — a gate under-reporting its own coverage, which is - the one failure mode ADR-0020 says a gate must not have. - - The clause is REJECTED rather than the arrow scan extended. Extending it - would widen the resolver's deliberately conservative false-positive tuning - across every arrow in the corpus; rejecting costs nothing and makes the - one-arrow-per-target convention — already what every retrofitted gitea - skill does in practice — explicit instead of folkloric. The caller emits a - SUGGESTION telling the author to split. - """ - hits = [] - for sentence in SENTENCE_SPLIT.split(description): - matches = (list(ARROW_MARKED.finditer(sentence)) - + list(ARROW_BOUNDARY.finditer(sentence))) - for match in matches: - first, _, _ = _first(match) - if not first: - continue - cont = CONT_ANY.match(sentence, match.end()) - if not cont: - continue - second, _, _ = _first(cont) - if second: - hits.append((first, second)) - return hits - - -def unresolved_targets(description, known): - """Targets resolving to nothing, split into (blocking, reported). - - `blocking` earns a hard error; `reported` is SUGGESTION tier — named on - every run, never fatal. Three conditions gate the promotion, and all of them - are documented at length in the ATTRIBUTIVE USE and CORROBORATION notes - above: - - 1. the target must be terminal, not a compound modifier ("pre-commit - hooks" is prose about a tool, not a route), - 2. it must be written in route notation (`/name`, `-> name`), OR - 3. its own sentence must name another target that DOES resolve. - - Everything else is reported and left alone. `known` is the resolved - universe from known_targets(); passing an empty set is not meaningful — - callers check for that first and decline out loud instead. - - A NON-TERMINAL target is reported, never dropped. FOLLOWER_OK is a closed - whitelist of maybe eighty words, so the follower rule says "this token is - outside a list I keep" and not "this is prose" — and the old `continue` - turned that into invisibility at every tier. The gate then failed OPEN on - its own unfamiliarity: any target followed by a word nobody thought to - enumerate was neither blocked nor mentioned, so the check that did not run - said nothing about not running. The follower rule may withdraw the power to - BLOCK a commit — that is what it was added for, and the ATTRIBUTIVE USE note - above is the argument for it — but it may not withdraw visibility, which is - the same rule the corroboration tier already follows. - """ - blocking, reported = set(), set() - for sentence in SENTENCE_SPLIT.split(description): - found = _extract_sentence(sentence) - resolved = {normalize_target(name) for name, _, _ in found - if normalize_target(name) in known} - for name, may_dangle, notation in found: - key = normalize_target(name) - if key in known: - continue - if not may_dangle: - reported.add(name) - continue - if notation or (resolved - {key}): - blocking.add(name) - else: - reported.add(name) - return sorted(blocking), sorted(reported - blocking) - -# --- Frontmatter ---------------------------------------------------------- -# Tolerant on the way in, HARD-FAILING on the way out. A UTF-8 BOM, a leading -# blank line, trailing whitespace after either `---`, or CRLF line endings all -# defeated the old `^---\n(.*?)\n---`, and the miss was SILENT: every ADR-0020 -# check was skipped and the file reported green (measured: a 550-character -# description with a 1,000-word body exited 0 behind a BOM). A file that cannot -# be measured must never report green, so every caller of these two ERRORs on a -# miss instead of moving on. -# -# The CLOSING marker is anchored at column 0 — deliberately NOT `[ \t]*---`. -# YAML block-scalar content must be indented deeper than its key, so an -# indented `---` inside a folded description is CONTENT; letting it close the -# frontmatter truncated the description mid-value and silently reclassified the -# rest as body, which is a vacuous green in both directions at once. Leading -# whitespace is still tolerated on the OPENING marker, where no such content -# can exist. -FRONTMATTER_RE = re.compile( - r'^[ \t\r\n]*---[ \t]*\r?\n(.*?)\r?\n---[ \t]*(?:\r?\n|\Z)', re.DOTALL) - - -def strip_bom(text): - return text[1:] if text.startswith(u'') else text - - -class FrontmatterError(Exception): - pass - - -def description_value(fm_text): - """The description VALUE, with YAML folding resolved. - - PyYAML is a HARD requirement, preflighted in bash. The hand-rolled fallback - this replaced diverged from a real parser across the FAIL boundary — one - corpus description measured 270 characters parsed and 412 unparsed, and a - quoted `"description"` key or an explicit `description: null` returned empty - from it, silently skipping the description AND routing checks. A gate that - disagrees with itself depending on which reader ran is worse than no gate. - - This is the ONLY reader any of the three scripts may use to decide whether a - description is present. A line regex cannot: `description:` with no value - followed by `model: sonnet` lets `\\s*` cross the newline and captures the - NEXT key, which reads as a non-empty description, skips the "missing or - empty" failure, and then early-returns out of every ADR-0020 gate on the - genuinely empty folded value. That combination exited 0 with zero output on - a BLOCKING pre-push gate. - """ - try: - data = yaml.safe_load(fm_text) - except Exception as exc: - # Every FrontmatterError message is a COMPLETE clause, never a detail a - # caller wraps in one. Callers used to prefix a hard-coded "frontmatter - # is not valid YAML (...)", which is true only of this branch: the two - # type failures below come from frontmatter that parsed fine, and - # telling their author the YAML is invalid sends them hunting for a - # syntax error that is not there — on a blocking gate with no baseline. - raise FrontmatterError('frontmatter is not valid YAML (%s)' - % re.sub(r'\s+', ' ', str(exc)).strip()) - if not isinstance(data, dict): - raise FrontmatterError('frontmatter is not a YAML mapping') - value = data.get('description') - if value is None: - return '' - if not isinstance(value, str): - # NOT str()-coerced. `description: true` became the 4-character "True" - # and sailed through the 400-character gate; a list or mapping was - # measured as its Python repr. Neither is a description a host can - # preload, so this is a parse failure, reported as one. - raise FrontmatterError( - 'description is a %s, not a string' % type(value).__name__) - return re.sub(r'\s+', ' ', value).strip() - - -def hand_invoked(fm_text): - """True when the frontmatter marks this file as reached only by hand. - - `disable-model-invocation: true` removes a skill from the model-visible - listing entirely — it is not preloaded, and the Skill tool refuses to call - it — so its description is never matched against user intent. ADR-0020 and - skill-author's contract give such a skill ONE plain human-facing sentence: - no trigger list, no boundary clause. No validator knew the field existed - (issue #108), so the boundary-clause SUGGESTION fired on exactly the shape - the contract mandates, and its remedy — "add a boundary clause so the router - knows where NOT to send this skill" — was addressed to a router that cannot - see the skill at all. An author who followed the advice made the file worse. - - Only the ROUTING rules are lifted. The body word budget still applies: the - body is loaded on invocation like any other, and competes with the caller's - live conversation the same way. So does the 400-character description FAIL — - a hand-invoked description is not preloaded, but it is still the one line - the user reads when choosing from the `/` menu, and the ceiling is the - outlier stop rather than the style target. - - A parse failure returns False rather than raising. This is a MODIFIER on - other checks, not a check of its own: the frontmatter's validity is decided, - and failed, by description_value() on the same text, and raising a second - exception here would report one broken file twice with two different - diagnoses. - """ - try: - data = yaml.safe_load(fm_text) - except Exception: - return False - if not isinstance(data, dict): - return False - value = data.get('disable-model-invocation') - if isinstance(value, str): - # PyYAML already resolves the unquoted YAML 1.1 booleans, so this only - # catches a QUOTED "true" — which a host reads as truthy and which no - # gate should treat as opting back in to the routing rules. - return value.strip().lower() in ('true', 'yes', 'on') - return value is True - - -# --- Body-shape checks (skills only; agents have no references/ dir) ------- -# Deterministic and countable, so they are enforced here. Whether a given -# gotcha is WARRANTED is semantic and stays the auditor's judgment, which is why -# both gotcha checks are SUGGESTION tier. A missing reference file is not a -# style opinion — it is a broken pointer — so that one is ERROR tier. -# -# Both read a FENCE-MASKED copy of the body. Scanning the raw body made a -# ```-fenced example a hard ERROR — and the skills most likely to carry one are -# skill-author and factory-audit, which DOCUMENT the references/ convention — and -# let a `## Gotchas` heading inside a fenced block stand in for the real -# section. Masking preserves every byte offset (content becomes spaces, -# newlines stay), so a span found in the mask slices the original. -GOTCHA_MAX_ENTRIES = 5 -GOTCHA_MAX_BODY_FRACTION = 0.25 -# The heading has to BE "Gotchas", not merely contain the word: `## Gotcha -# handling` and `## Why gotchas matter` are prose sections, and treating one as -# the Gotchas section measured a span that was never a gotcha list. -GOTCHA_HEADING = re.compile(r'^(#{1,6})[ \t]+(?:[^\n]*?[ \t])?gotchas?[ \t]*:?[ \t]*$', - re.I | re.M) -# Column 0 only. `^[ \t]{0,3}` counted a two-space-indented CHILD bullet as a -# top-level entry, so a five-entry section with sub-bullets reported nine. -GOTCHA_ENTRY = re.compile(r'^(?:[-*+]|\d+[.)])[ \t]+', re.M) -FENCE_OPEN = re.compile(r'^[ \t]{0,3}(`{3,}|~{3,})') -REFERENCE_POINTER = re.compile( - r'(?= len(fence) - and not stripped.strip()[len(marker):].strip()): - fence = None - # An UNCLOSED fence has no cost-free answer, only a choice of which way to - # be wrong. Masking to end-of-body blanks the rest of the body, silently - # disabling the ERROR-tier references/ check and the gotcha counts. - # Returning the raw text instead exposes the unclosed example's own - # content, so a fenced example naming a nonexistent references/ file - # becomes a hard ERROR it would not have been had the fence been closed — - # confirmed, not hypothetical. The loud-false-positive direction is the one - # chosen: this script's rule is that a file it cannot measure must never - # report green, and masking-onward is exactly that failure. Both outcomes - # need an already-malformed file, and the false positive costs one fence. - if fence is not None: - return text - return ''.join(out) - - -def gotcha_stats(body): - """(entry count, section word count) for the first Gotchas section, or None. - - The section runs to the next heading at the same level or shallower. - Entries are top-level list items; a section written as subheadings instead - of a list counts those. Headings and entries are read from the fence mask; - the word count is taken from the original slice, because fenced lines are - real body words and the fraction is measured against the whole body. - """ - masked = mask_fenced(body) - match = GOTCHA_HEADING.search(masked) - if not match: - return None - level = len(match.group(1)) - rest = masked[match.end():] - nxt = re.search(r'^#{1,%d}[ \t]+' % level, rest, re.M) - end = match.end() + (nxt.start() if nxt else len(rest)) - section = masked[match.end():end] - entries = len(GOTCHA_ENTRY.findall(section)) - if entries == 0 and level < 6: - entries = len(re.findall(r'^#{%d,6}[ \t]+' % (level + 1), section, re.M)) - return entries, len(body[match.end():end].split()) - - -def missing_reference_pointers(body, skill_dir): - """references/.md named in the body but absent from disk.""" - masked = mask_fenced(body) - missing = set() - for match in REFERENCE_POINTER.finditer(masked): - start = masked.rfind('\n', 0, match.start()) + 1 - end = masked.find('\n', match.end()) - if end < 0: - end = len(masked) - # The pointer's OWN SPAN is excised before the sweep. Run over the - # whole line, the past-tense test matched the very path it was judging, - # so a file exempted itself by its NAME: `references/deprecated-api.md`, - # `references/removed-flags.md` and `references/gone.md` produced no - # ERROR at all, while `references/missing.md` — an identical break — - # errored. The exemption is about what the SENTENCE says about the - # pointer, never about what the pointer is called. - line = masked[start:match.start()] + masked[match.end():end] - if REFERENCE_PAST.search(line): - continue - if REFERENCE_QUALIFIER.search(masked[start:match.start()]): - continue - if not os.path.isfile(os.path.join(skill_dir, 'references', match.group(1))): - missing.add('references/' + match.group(1)) - return sorted(missing) -# ===== END ADR-0020 SHARED BOUNDARY RESOLVER ===== +# 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 ------------------------------------------------------ @@ -1516,7 +470,38 @@ for path in files: % (path, ", ".join(targets))) sys.exit(1 if failed else 0) -PYTHON +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 diff --git a/tests/test-adr0020-contract.sh b/tests/test-adr0020-contract.sh index 2292554..8be0c1d 100755 --- a/tests/test-adr0020-contract.sh +++ b/tests/test-adr0020-contract.sh @@ -3,22 +3,20 @@ # itself. None of them was pinned anywhere before this file, and each one fails # silently — which is the whole reason they need a test rather than a comment: # -# 1. "ONE resolver, embedded VERBATIM in two scripts." The block between the -# BEGIN/END markers is copied, not imported, because a cache-installed -# plugin's scripts cannot read files outside their own plugin directory. -# ADR-0025 merged skill-audit and agent-audit, which dropped the count from -# three copies to two: factory-audit now holds ONE copy in a sourced -# lib-boundary-resolver.sh, and scripts/skill-size-check.sh keeps its -# embedded copy because it is a repo-root hook kept fit for a published -# hook manifest (retired; ADR-0014), where entry[0] is the only token -# pre-commit rewrites — it could not reach a file inside the plugin at a -# path any consumer has. Nothing but this file asserts the two copies are still identical, and -# a one-line edit to a single copy is invisible: every constant-agreement -# assertion in tests/test-skill-size-check.sh still passes, because the -# CONSTANTS are not what drifted. -# 1a. The resolver's two copies are the ONLY two, and validate.sh sources -# factory-audit's in both mode branches — the same authority checks 1b -# makes for the parser, which byte-identity alone cannot make. +# 1. "ONE resolver, and both of its consumers run it." The ADR-0020 boundary +# resolver has exactly one copy, factory-audit's sourced +# lib-boundary-resolver.sh. scripts/skill-size-check.sh used to embed a +# second, byte-identical copy, because it was also exported through a +# published hook manifest whose consumers could not reach a file inside the +# plugin; 4de5b6b retired that export (ADR-0014), so the hook now sources +# the library too. What must not fail silently: the hook growing its own +# copy back, the library being gutted, or the hook no longer running the +# library's text at all. So this asserts the library is real content, the +# hook carries no marker pair, the hook fails closed without the library, +# and — by a sentinel planted in a copied library — that the text the hook +# executes IS the library's. +# 1a. The library is the ONLY authority, and validate.sh sources it in both +# mode branches — the same authority checks 1b makes for the parser. # 1b. The same claim, one directory over, for the Contributing-files parser. # That one was worse: it was embedded in both validate-provenance.sh copies, # the agent-audit copy's docstring ASSERTED it was kept behaviourally @@ -49,8 +47,8 @@ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" HOOK="$REPO_ROOT/scripts/skill-size-check.sh" FACTORY_AUDIT="$REPO_ROOT/plugins/kyberforge/.apm/skills/factory-audit" # ADR-0025: one auto-detecting entry point, and one sourced copy of the resolver -# behind it. The entry point is what the preflight assertions run; the library is -# what the byte-identity assertion hashes. +# behind it, which the root hook sources as well. The entry point is what the +# preflight assertions run; the library is what assertion 1 inspects. FACTORY_VALIDATE="$FACTORY_AUDIT/scripts/validate.sh" FACTORY_RESOLVER="$FACTORY_AUDIT/scripts/lib-boundary-resolver.sh" PASS=0 @@ -66,70 +64,111 @@ BEGIN_MARKER='# ===== BEGIN ADR-0020 SHARED BOUNDARY RESOLVER =====' END_MARKER='# ===== END ADR-0020 SHARED BOUNDARY RESOLVER =====' # --------------------------------------------------------------------------- -# 1. The shared resolver block is byte-identical in both scripts +# 1. The resolver has one copy, and the root hook runs it # --------------------------------------------------------------------------- -# Two copies, not three, since ADR-0025 merged skill-audit and agent-audit: -# scripts/skill-size-check.sh (embedded — sourcing the plugin's copy was -# considered and refuted, see the header) and factory-audit's single sourced -# lib-boundary-resolver.sh. The claim is unchanged and so is the check: the -# span between the markers must be byte-identical wherever it appears. +# One copy since the 2026-09-16 change: factory-audit's lib-boundary-resolver.sh. +# scripts/skill-size-check.sh sources it rather than embedding a second copy, so +# there is no pair left to hash. What replaces the hash is the set of ways a +# single sourced copy can still fail quietly. echo "" -echo "--- the ADR-0020 shared resolver block is byte-identical in both scripts ---" +echo "--- the ADR-0020 resolver has one copy, and skill-size-check.sh runs it ---" -# Marker discipline first. An unbalanced or duplicated marker pair makes the -# extraction below silently measure the wrong span — a sed range that never -# closes swallows the rest of the file, and one that opens twice concatenates -# two spans. Both would still compare "equal" if both were mangled the -# same way, so the shape is asserted before the contents. -MARKERS_OK=true -for f in "$HOOK" "$FACTORY_RESOLVER"; do - if [[ ! -f "$f" ]]; then - fail "script not found: $f" - MARKERS_OK=false - continue - fi - b="$(grep -cFx "$BEGIN_MARKER" "$f" || true)" - e="$(grep -cFx "$END_MARKER" "$f" || true)" - if [[ "$b" == "1" && "$e" == "1" ]]; then - pass "${f#"$REPO_ROOT/"} carries exactly one BEGIN and one END marker" - else - fail "${f#"$REPO_ROOT/"} has $b BEGIN and $e END markers, expected 1 and 1" - MARKERS_OK=false - fi -done - -if ! $MARKERS_OK; then - fail "skipping the byte-identity comparison — the marker pairs are not well-formed, so any extraction would measure the wrong span" +# (i) The library carries exactly one well-formed marker pair around real +# content. An unbalanced pair makes every extraction measure the wrong span, and +# an emptied-out block would still "load" while enforcing nothing. The resolver +# is ~1,060 lines; 100 is a floor low enough never to need maintenance and high +# enough that a gutted block cannot sneak past. +if [[ ! -f "$FACTORY_RESOLVER" ]]; then + fail "resolver library not found: ${FACTORY_RESOLVER#"$REPO_ROOT/"}" else - HASHES=() - LINECOUNTS=() - for f in "$HOOK" "$FACTORY_RESOLVER"; do - out="$TMPDIR_T/block-$(echo "$f" | md5sum | cut -c1-8).txt" - sed -n "/^${BEGIN_MARKER}\$/,/^${END_MARKER}\$/p" "$f" > "$out" - HASHES+=("$(md5sum < "$out" | cut -d' ' -f1)") - LINECOUNTS+=("$(wc -l < "$out" | tr -d ' ')") - done - if [[ "${HASHES[0]}" == "${HASHES[1]}" ]]; then - pass "both copies hash to ${HASHES[0]} (${LINECOUNTS[0]} lines) — agreement by construction, not by coincidence" + b="$(grep -cFx "$BEGIN_MARKER" "$FACTORY_RESOLVER" || true)" + e="$(grep -cFx "$END_MARKER" "$FACTORY_RESOLVER" || true)" + if [[ "$b" == "1" && "$e" == "1" ]]; then + pass "${FACTORY_RESOLVER#"$REPO_ROOT/"} carries exactly one BEGIN and one END marker" + span="$(sed -n "/^${BEGIN_MARKER}\$/,/^${END_MARKER}\$/p" "$FACTORY_RESOLVER" | wc -l | tr -d ' ')" + if [[ "$span" -gt 100 ]]; then + pass "the resolver span is $span lines — real content, not an empty block" + else + fail "the resolver span is only $span lines — a gutted block would load and resolve nothing" + fi else - fail "the shared resolver has DRIFTED: skill-size-check=${HASHES[0]} (${LINECOUNTS[0]} lines), factory-audit/scripts/lib-boundary-resolver.sh=${HASHES[1]} (${LINECOUNTS[1]} lines). Edit one copy, then paste it over the other." + fail "${FACTORY_RESOLVER#"$REPO_ROOT/"} has $b BEGIN and $e END markers, expected 1 and 1" fi - # A block that has been emptied out would hash equal in both and pass the - # comparison above while enforcing nothing. The resolver is ~1,060 lines; 100 - # is a floor low enough never to need maintenance and high enough that a - # gutted block cannot sneak past. - if [[ "${LINECOUNTS[0]}" -gt 100 ]]; then - pass "the extracted block is ${LINECOUNTS[0]} lines — the comparison is over real content, not an empty span" +fi + +# (ii) The hook carries no copy of its own. A column-0 marker line in the hook +# is the shape the old embedded copy had, and the shape a paste-back would have. +if [[ ! -f "$HOOK" ]]; then + fail "hook not found: ${HOOK#"$REPO_ROOT/"}" +else + hb="$(grep -cFx "$BEGIN_MARKER" "$HOOK" || true)" + he="$(grep -cFx "$END_MARKER" "$HOOK" || true)" + if [[ "$hb" == "0" && "$he" == "0" ]]; then + pass "${HOOK#"$REPO_ROOT/"} carries no resolver marker lines — it has not grown its own copy back" else - fail "the extracted shared block is only ${LINECOUNTS[0]} lines — two identical empty spans would compare equal and assert nothing" + fail "${HOOK#"$REPO_ROOT/"} carries $hb BEGIN and $he END marker lines — a second copy of the resolver is back in the hook" + fi +fi + +# (iii) and (iv) run the hook from a scratch tree that mirrors the two paths it +# depends on, so the real library is never touched. The scratch hook is a copy +# of the real one; its library is either absent, gutted, or the real library +# with a sentinel planted inside the resolver block. +SSC_TREE="$TMPDIR_T/ssc-tree" +SSC_LIB_DIR="$SSC_TREE/plugins/kyberforge/.apm/skills/factory-audit/scripts" +mkdir -p "$SSC_TREE/scripts" "$SSC_LIB_DIR" "$TMPDIR_T/ssc-skill/probe-skill" +cp "$HOOK" "$SSC_TREE/scripts/skill-size-check.sh" +printf -- '---\nname: probe-skill\ndescription: Use when probing the resolver wiring.\nmetadata:\n version: "0.1.0"\n---\n\n## Step 1\n\nDo the thing.\n' \ + > "$TMPDIR_T/ssc-skill/probe-skill/SKILL.md" +PROBE="$TMPDIR_T/ssc-skill/probe-skill/SKILL.md" + +run_scratch_hook() { + local rc=0 + SSC_OUT="$(bash "$SSC_TREE/scripts/skill-size-check.sh" "$PROBE" 2>&1)" || rc=$? + SSC_RC=$rc +} + +# (iii) Fail closed: no library, then a library that defines nothing. +rm -f "$SSC_LIB_DIR/lib-boundary-resolver.sh" +run_scratch_hook +if [[ "$SSC_RC" -ne 0 && "$SSC_OUT" == *"boundary resolver library was not found"* ]]; then + pass "with the library missing, the hook exits $SSC_RC and names the missing library — not a vacuous pass" +else + fail "with the library missing, the hook exited $SSC_RC without naming it: $SSC_OUT" +fi +printf '# gutted\n' > "$SSC_LIB_DIR/lib-boundary-resolver.sh" +run_scratch_hook +if [[ "$SSC_RC" -ne 0 && "$SSC_OUT" == *"did not define the ADR-0020 boundary resolver"* ]]; then + pass "with a library that defines no resolver, the hook exits $SSC_RC and says so" +else + fail "with a gutted library, the hook exited $SSC_RC without saying so: $SSC_OUT" +fi + +# (iv) The text the hook executes IS the library's. A sentinel print planted +# just after the BEGIN marker of a copied library must appear in the hook's +# output. Without this, a hook that sourced the library but ran some other +# program would pass (i)-(iii). +SENTINEL="ADR0020-RESOLVER-SENTINEL-$$" +if [[ -f "$FACTORY_RESOLVER" ]]; then + awk -v m="$BEGIN_MARKER" -v s="$SENTINEL" '{ print } $0 == m { print "print(\"" s "\")" }' \ + "$FACTORY_RESOLVER" > "$SSC_LIB_DIR/lib-boundary-resolver.sh" + if [[ "$(grep -cF "$SENTINEL" "$SSC_LIB_DIR/lib-boundary-resolver.sh" || true)" -ne 1 ]]; then + fail "fixture check: the sentinel was not planted exactly once in the copied library — the case below would prove nothing" + else + run_scratch_hook + if [[ "$SSC_RC" -eq 0 && "$SSC_OUT" == *"$SENTINEL"* ]]; then + pass "a sentinel planted in the library's resolver block runs inside the hook — the hook executes the library's text" + else + fail "the hook did not run the library's resolver text (rc=$SSC_RC, sentinel absent from output): $SSC_OUT" + fi fi fi # --------------------------------------------------------------------------- -# 1a. The resolver copies are the ONLY two, and validate.sh sources its one +# 1a. The resolver library is the ONLY authority, and validate.sh sources it # --------------------------------------------------------------------------- -# Byte-identity between two named files says nothing about a THIRD copy, and -# nothing about whether factory-audit's copy is the one that runs. Assertion 1b +# Assertion 1 says nothing about a copy somewhere else in the tree, and nothing +# about whether validate.sh runs the library. Assertion 1b # pins both of those for the Contributing-files parser; the resolver is the same # defect class and gets the same two checks: # @@ -137,16 +176,14 @@ fi # branches — asserted inside each arm of `case "$MODE" in`, not by counting # source lines file-wide, because a count cannot see a branch. A library # that is identical, unique and never sourced is a copy that has quietly -# been replaced by an inline one — and the byte-identity check above would -# stay green over it. +# been replaced by an inline one — and assertion 1 would stay green over it. # b. Nothing has re-inlined it. The BEGIN marker and a def unique to the -# resolver (`_authoring_root`) appear in exactly the two authorities — -# scripts/skill-size-check.sh and lib-boundary-resolver.sh — and nowhere -# else under the tree. A mode library that grows a "just this once" copy -# would otherwise escape assertion 1 entirely, because 1 hashes only the -# two files it names. +# resolver (`_authoring_root`) appear in exactly one file, +# lib-boundary-resolver.sh, and nowhere else under the tree. A mode library +# or a root script that grows a "just this once" copy would otherwise +# escape assertion 1 entirely, because 1 inspects only the files it names. echo "" -echo "--- the ADR-0020 resolver has exactly two authorities, and validate.sh sources factory-audit's ---" +echo "--- the ADR-0020 resolver has exactly one authority, and validate.sh sources it ---" # Deployed and vendored trees are generated copies, not authorities: .claude/ is # apm install output, apm_modules/ is resolved dependencies, build/ is release @@ -303,18 +340,18 @@ PY fi fi -# (b) Exactly the two authorities, for both spellings of a copy. -EXPECTED_RESOLVERS="$(printf '%s\n' "$HOOK" "$FACTORY_RESOLVER" | sort)" +# (b) Exactly the one authority, for both spellings of a copy. +EXPECTED_RESOLVERS="$FACTORY_RESOLVER" check_resolver_authorities() { local label="$1" needle="$2" local found found="$(tree_scan "$needle")" if [[ "$found" == "$EXPECTED_RESOLVERS" ]]; then - pass "$label appears in exactly the two resolver authorities and nowhere else" + pass "$label appears in exactly the one resolver authority and nowhere else" elif [[ -z "$found" ]]; then fail "$label was found in NO file at all — the scan is looking for the wrong text" else - fail "$label appears in an unexpected set of files, so the resolver has been re-inlined or lost: $(echo "$found" | tr '\n' ' ')— expected exactly ${HOOK#"$REPO_ROOT/"} and ${FACTORY_RESOLVER#"$REPO_ROOT/"}" + fail "$label appears in an unexpected set of files, so the resolver has been re-inlined or lost: $(echo "$found" | tr '\n' ' ')— expected exactly ${FACTORY_RESOLVER#"$REPO_ROOT/"}" fi } check_resolver_authorities "the resolver's BEGIN marker" "$BEGIN_MARKER" @@ -325,7 +362,7 @@ check_resolver_authorities "a 'def _authoring_root' definition" "def _authoring_ # --------------------------------------------------------------------------- # Same defect class, one directory over. parse_contributing_files() used to be # embedded in both validate-provenance.sh copies for the same reason the resolver -# is embedded twice, and until this assertion existed the agent-audit copy's +# was once embedded in several scripts, and until this assertion existed the agent-audit copy's # docstring merely CLAIMED it was "kept behaviourally identical to skill-audit's # copy" — an invariant nothing checked, and the two did drift into different # spellings of the bullet loop at 484357a. That drift happened to be diff --git a/tests/test-adr0020-frontmatter.sh b/tests/test-adr0020-frontmatter.sh index 4129ba3..4d06d26 100755 --- a/tests/test-adr0020-frontmatter.sh +++ b/tests/test-adr0020-frontmatter.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash # Regression test for the two ways an ADR-0020 gate can be made to check NOTHING # while still exiting 0. Both were live defects, both were silent, and both sit -# in the shared resolver block that all three scripts embed verbatim — so every -# case below runs against all three. +# in the shared resolver block that all three entry points run — one sourced +# copy since 2026-09-16 — so every case below runs against all three. # # 1. THE FRONTMATTER BLOCKER. The frontmatter matcher used to be `^---\n`. A # UTF-8 BOM, a leading blank line, a trailing space after either marker, or