From ef27c9751a28b95b2a1858a0acdd09cb1d66bed8 Mon Sep 17 00:00:00 2001 From: Defame1297 Date: Wed, 16 Sep 2026 13:25:16 +0000 Subject: [PATCH] refactor(gates): source the boundary resolver into skill-size-check Why: scripts/skill-size-check.sh embedded a byte-identical 1,061-line copy of the ADR-0020 boundary resolver only because it was also exported through .pre-commit-hooks.yaml, whose consumers could not reach a file inside the plugin. 4de5b6b retired that export, so the hook now runs only in this repo and can source factory-audit's lib-boundary-resolver.sh like validate.sh does. One copy removes the edit-one-paste-the-other hazard. Implementation Notes: - The hook's Python program is assembled from its own preamble, the library's resolver and its own checks, read from quoted here-docs. The assembled program matches the old one line for line except one comment, and the hook's stdout, stderr and exit code are identical over every corpus SKILL.md and the 26 differential-suite fixtures. - The hook fails closed, naming the library, when it is missing or defines no resolver. - test-adr0020-contract.sh assertion 1 now pins the single copy: one marker pair in the library, none in the hook, fail-closed on a missing or gutted library, and a sentinel planted in a copied library that must appear in the hook's output. 1a expects exactly one authority. 27 -> 29 passes. - ADR-0020 and ADR-0025 carry dated amendments; gates.md and the library, hook and mode-library comments no longer describe two copies. - factory-audit is new on this branch, so the version-bump gate exempts it; kyberforge is already at 2.0.0 against main's 1.6.2. Co-Authored-By: Claude Opus 5 (1M context) --- ...l-description-and-body-context-contract.md | 8 + ...nd-agent-audit-merge-into-factory-audit.md | 7 + docs/spec/gates.md | 20 +- .../scripts/lib-boundary-resolver.sh | 42 +- .../factory-audit/scripts/lib-checks-agent.sh | 14 +- .../factory-audit/scripts/lib-checks-skill.sh | 6 +- scripts/skill-size-check.sh | 1131 +---------------- tests/test-adr0020-contract.sh | 203 +-- tests/test-adr0020-frontmatter.sh | 4 +- 9 files changed, 239 insertions(+), 1196 deletions(-) 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