Two defects in the routing-target resolver, both latent in the corpus but hot for anything written next. The free-standing `/name` sweep sat inside `if boundary:`, so route notation in a sentence carrying no boundary marker was never extracted at all — not an ERROR, not a SUGGESTION, not an INFO. That contradicted ADR-0020's amendment and gates.md, which both promise `/name` blocks unconditionally. The sweep now runs over every sentence. `-> name` and backticked forms stay gated deliberately: an arrow also writes a process chain and a code span cites tools, files and skills alike, so ungating either fires on ordinary prose. The path guard used `\b`, which still holds after a hyphen, so the engine backtracked to a shorter hyphen-terminated prefix whenever the lookahead rejected the full segment. `/api-docs/v2.md` in a boundary clause raised blocking ERRORs for 'api' and 'api-docs' — names no author wrote, with no corroboration escape. `(?![\w-])` forbids the shortened prefix outright; MARKED_TARGET, which had no trailing guard at all, gained one. Zero arguments now exits 2 rather than 0, so a mis-scoped `files:` pattern is no longer indistinguishable from a clean corpus. Both hook manifests pass filenames and pre-commit skips a filename-passing hook when nothing matches, so the hook never sees an empty argv — that contract is now asserted by a test rather than left in prose. Deleting the sweep entirely used to leave every suite green. It now kills eight assertions. The suite also gains its first slash-path and URL fixtures, in both directions. Refs: #107, #110, #124 ADR: 0020
1739 lines
87 KiB
Bash
Executable File
1739 lines
87 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
set -euo pipefail
|
||
|
||
usage() {
|
||
cat <<EOF
|
||
Usage: validate.sh <agent-file>
|
||
|
||
Validate an agent definition file against the agent definition spec.
|
||
|
||
At plugin/APM scope, <agent-file> is a single vendor-neutral
|
||
.apm/agents/<name>.agent.md file with no counterpart. Its frontmatter allowlist
|
||
is not restated here: it is read at load time from the apm-agent-allowlist
|
||
section of references/field-inventory.md, which is the authoritative list.
|
||
At project or user scope, <agent-file> is either half of a Claude Code .md /
|
||
Copilot .agent.md pair.
|
||
|
||
Arguments:
|
||
agent-file Path to the agent file (or either half of a project/user-scope pair).
|
||
|
||
Exit codes:
|
||
0 All checks passed (may include SUGGESTIONs)
|
||
1 One or more checks failed
|
||
2 Script error (unrecognized file extension or missing field-inventory.md)
|
||
EOF
|
||
}
|
||
|
||
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
|
||
usage
|
||
exit 0
|
||
fi
|
||
|
||
if [[ $# -lt 1 ]]; then
|
||
echo "Error: agent-file is required." >&2
|
||
echo "" >&2
|
||
usage >&2
|
||
exit 1
|
||
fi
|
||
|
||
# PyYAML is a HARD dependency, not a nice-to-have. The description VALUE has to
|
||
# be measured after YAML folding is resolved, and the hand-rolled reader that
|
||
# used to stand in for PyYAML disagreed with it across the 400-character FAIL
|
||
# boundary — same description, two verdicts, depending on which reader ran.
|
||
# Refusing to start is the only honest option; the repo's jq / apm / vale
|
||
# dependencies are declared the same way.
|
||
# Check the interpreter separately from the library: `python3 -c` fails the same
|
||
# way whether python3 is missing or PyYAML is, and reporting the wrong missing
|
||
# dependency sends the reader to install the wrong thing.
|
||
if ! command -v python3 > /dev/null 2>&1; then
|
||
echo "Error: python3 is required but was not found on PATH." >&2
|
||
echo " Why: skipping the ADR-0020 description and boundary-target gates would be a vacuous pass." >&2
|
||
echo " Fix: install python3 (pre-commit itself is a Python application, so it is almost certainly already present)." >&2
|
||
exit 1
|
||
fi
|
||
|
||
if ! python3 -c 'import yaml' > /dev/null 2>&1; then
|
||
echo "Error: PyYAML is required but is not importable by python3." >&2
|
||
echo " Why: skipping the ADR-0020 description and boundary-target gates would be a vacuous pass." >&2
|
||
echo " Fix: python3 -m pip install PyYAML (or your distro's python3-yaml package)." >&2
|
||
exit 1
|
||
fi
|
||
|
||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||
|
||
python3 -u - "$1" "$SCRIPT_DIR" <<'PYTHON'
|
||
import sys
|
||
import os
|
||
import re
|
||
import glob
|
||
|
||
import yaml
|
||
|
||
# Output is UTF-8 for the same reason input is: under LC_ALL=C the streams
|
||
# default to ASCII, and this script's own message text carries em dashes (the
|
||
# ADR-0020 boundary SUGGESTION is one). Pinning only the reads moved the crash
|
||
# from the read to the write — a UnicodeEncodeError raised while PRINTING, after
|
||
# every check has already run, which loses the whole report and (here) flips a
|
||
# clean exit 0 into a traceback and an exit 1. read_text() in the shared
|
||
# resolver block below pins the reads; this pins the writes.
|
||
#
|
||
# Deliberately OUTSIDE the ADR-0020 shared boundary resolver block: the two
|
||
# validate.sh copies print findings, skill-size-check.sh has its own top-level
|
||
# equivalent, and tests/test-adr0020-contract.sh hashes that block for
|
||
# byte-identity across all three.
|
||
for _stream in (sys.stdout, sys.stderr):
|
||
try:
|
||
_stream.reconfigure(encoding='utf-8')
|
||
except AttributeError: # pragma: no cover — Python < 3.7
|
||
pass
|
||
|
||
agent_file = os.path.abspath(sys.argv[1])
|
||
script_dir = sys.argv[2]
|
||
|
||
fname = os.path.basename(agent_file)
|
||
|
||
# --- Detect provider (check .agent.md before .md) ---
|
||
if fname.endswith('.agent.md'):
|
||
provider = 'copilot'
|
||
name_stem = fname[:-len('.agent.md')]
|
||
elif fname.endswith('.md'):
|
||
provider = 'claude-code'
|
||
name_stem = fname[:-len('.md')]
|
||
else:
|
||
print(f"Error: unrecognized extension '{fname}' — expected .md or .agent.md", file=sys.stderr)
|
||
sys.exit(2)
|
||
|
||
# --- Load field-inventory.md ---
|
||
inv_path = os.path.normpath(os.path.join(script_dir, '..', 'references', 'field-inventory.md'))
|
||
if not os.path.isfile(inv_path):
|
||
print(f"Error: field-inventory.md not found at {inv_path}", file=sys.stderr)
|
||
sys.exit(2)
|
||
|
||
# Encoding is pinned to UTF-8 rather than inherited from the locale: under
|
||
# LC_ALL=C the inherited default is ASCII, and this file legitimately carries
|
||
# non-ASCII prose. read_text() in the shared resolver block below does the same
|
||
# thing for every other file; this one is read before that block is defined.
|
||
try:
|
||
with open(inv_path, encoding='utf-8') as f:
|
||
inv_content = f.read()
|
||
except UnicodeDecodeError as exc:
|
||
print(f"Error: field-inventory.md at {inv_path} is not valid UTF-8 "
|
||
f"({exc.reason} at byte {exc.start}) — re-save it as UTF-8.",
|
||
file=sys.stderr)
|
||
sys.exit(2)
|
||
|
||
def parse_section_tokens(content, section_name):
|
||
lines = content.splitlines()
|
||
for i, line in enumerate(lines):
|
||
if line.strip() == f'## {section_name}':
|
||
for j in range(i + 1, len(lines)):
|
||
stripped = lines[j].strip()
|
||
if stripped and not stripped.startswith('#') and not stripped.startswith('---'):
|
||
return set(stripped.split())
|
||
return set()
|
||
|
||
cc_only_fields = parse_section_tokens(inv_content, 'claude-code-only-fields')
|
||
copilot_only_fields = parse_section_tokens(inv_content, 'copilot-only-fields')
|
||
apm_agent_allowlist = parse_section_tokens(inv_content, 'apm-agent-allowlist')
|
||
|
||
# Tools the runtime withholds from subagents regardless of the tools field
|
||
SUBAGENT_UNAVAILABLE_TOOLS = {
|
||
'AskUserQuestion', 'EnterPlanMode', 'ExitPlanMode', 'ScheduleWakeup', 'WaitForMcpServers',
|
||
}
|
||
|
||
# Copilot body length limit (chars) — content beyond this is silently truncated
|
||
COPILOT_BODY_LIMIT = 30000
|
||
|
||
# ADR-0020 description budget. An agent's name + description is preloaded into
|
||
# every session exactly like a skill's, so agents take the SAME description
|
||
# gates. These two constants are DUPLICATED from scripts/skill-size-check.sh
|
||
# and skill-audit/scripts/validate.sh rather than shared from one file: a
|
||
# cache-installed plugin's scripts cannot read files outside their own plugin
|
||
# directory, so there is no single source to share (same rationale as
|
||
# vale-wrap.sh's per-plugin duplication). tests/test-skill-size-check.sh
|
||
# asserts all copies agree, so drift fails CI rather than silently diverging.
|
||
#
|
||
# Agents deliberately take NO body word gate, and adding one here would
|
||
# contradict ADR-0020: a skill body is loaded into the caller's context and
|
||
# competes with the live conversation, while an agent body becomes the system
|
||
# prompt of a fresh context. The rationale for the 900-word skill ceiling does
|
||
# not transfer. Agent body length falls out of the delegation rule instead.
|
||
DESC_SUGGEST_CHARS = 250
|
||
DESC_MAX_CHARS = 400
|
||
|
||
# --- Helpers (shared by every scope) ---
|
||
failed = False
|
||
suggestions = []
|
||
|
||
def fail(msg):
|
||
# stderr, matching scripts/skill-size-check.sh's ERROR routing. All three
|
||
# scripts in the ADR-0020 family now agree: findings that fail the run go to
|
||
# stderr, everything advisory (SUGGESTION / INFO) goes to stdout. Both repo
|
||
# callers (check-apm-agents-valid.sh, check-scope-walkup-sync.sh) capture
|
||
# `2>&1`, so nothing a human reads moves.
|
||
global failed
|
||
failed = True
|
||
print(f"FAIL {msg}", file=sys.stderr)
|
||
|
||
def suggest(msg):
|
||
suggestions.append(msg)
|
||
|
||
def info(msg):
|
||
# A check that DECLINED to run says so out loud, rather than passing
|
||
# silently. Silence is what let a whole gate family go missing unnoticed.
|
||
print(f"INFO {msg}")
|
||
|
||
PLACEHOLDER_RE = re.compile(r'(?<!`)FILL IN:[^`\n]')
|
||
|
||
|
||
# ===== BEGIN ADR-0020 SHARED BOUNDARY RESOLVER =====
|
||
# ONE resolver, embedded VERBATIM in three scripts:
|
||
# scripts/skill-size-check.sh
|
||
# plugins/kyberforge/.apm/skills/skill-audit/scripts/validate.sh
|
||
# plugins/kyberforge/.apm/skills/agent-audit/scripts/validate.sh
|
||
# The block between these markers must stay byte-identical in all three. It is
|
||
# copied rather than imported because a cache-installed plugin's scripts cannot
|
||
# read files outside their own plugin directory, so there is no single file all
|
||
# three can share (same constraint that forces the ADR-0020 constants to be
|
||
# duplicated). Edit one copy, then paste it over the other two.
|
||
#
|
||
# 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 39-skill universe into every consumer repo that ran this hook
|
||
# through pre-commit, so a consumer skill routing to `skill-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 <root>/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/<name>/. 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/<x> 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 <thing> -> <skill-name>`. 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's audit skills (agent-audit -> agent-author, agent-audit ->
|
||
# skill-audit, skill-audit -> skill-author), 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 <thing> -> <name>`) and Claude Code's
|
||
# invocation form (`/<name>`). 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
|
||
# `<plugin>:` 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)`|(?<![\w./*-])/(%s)(?![\w-])(?!/|\.\S))"
|
||
% (NAME_ANY, NAME_ANY))
|
||
ANY_TARGET = r"(?:%s|(%s)\b)" % (MARKED_TARGET, NAME_HYPH)
|
||
ROUTE_MARKED = re.compile(r"\b%s\s+(?:the\s+|an?\s+)?%s" % (ROUTE_VERB, MARKED_TARGET), re.I)
|
||
ROUTE_ANY = re.compile(r"\b%s\s+(?:the\s+|an?\s+)?%s" % (ROUTE_VERB, ANY_TARGET), re.I)
|
||
# re.I on ALL of them, uniformly. The patterns are built from the same
|
||
# lowercase NAME_* fragments, so half of them carrying the flag and half not
|
||
# meant `Skill-Audit` at the start of a boundary sentence was extracted by
|
||
# ROUTE_ANY but invisible to BACKTICK — contradicting normalize_target()'s own
|
||
# docstring, which exists precisely because extraction is case-insensitive and
|
||
# the universe is not.
|
||
CONT_MARKED = re.compile(r"\s*(?:or|and|/|,)\s*%s" % MARKED_TARGET, re.I)
|
||
CONT_ANY = re.compile(r"\s*(?:or|and|/|,)\s*%s" % ANY_TARGET, re.I)
|
||
ARROW_MARKED = re.compile(r"(?:->|→)\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"(?<![\w./*-])/(%s)(?![\w-])(?!/|\.\S)" % NAME_ANY, re.I)
|
||
NOTATION_ARROW = 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 <thing> -> <name>`.
|
||
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'(?<!\\be\\.g\\.)(?<!\\bi\\.e\\.)(?<!\\betc\\.)(?<!\\bvs\\.)(?<!\\bcf\\.)'
|
||
u'(?<=[.!?])\\s+(?=[A-Za-z`"“(])', re.I)
|
||
|
||
# The token that may follow a route target without turning it into a compound
|
||
# modifier: punctuation, end of sentence, a conjunction, a boundary word, or a
|
||
# head noun that names the artifact itself ("the git-workflow skill"). Anything
|
||
# else — `hooks`, `template`, `formatting`, `logic` — is attributive prose.
|
||
FOLLOWER = re.compile(r"[`\s]*([a-z][a-z0-9]*)")
|
||
FOLLOWER_OK = frozenset("""
|
||
and or nor but for to when if unless while after before with from in on at by
|
||
of as than then instead rather directly first only always never also even
|
||
both either neither so because since per via plus alone here there this that
|
||
these those it its they them is are was were be been being has have had will
|
||
would can could should must may might does do did
|
||
skill skills agent agents plugin plugins command commands
|
||
""".split())
|
||
|
||
|
||
def normalize_target(target):
|
||
"""Comparison key: namespace stripped, lowercased.
|
||
|
||
Extraction is case-insensitive (re.I) but the universe is built from
|
||
lowercase directory names, so `Git-Commits` at the start of a sentence
|
||
resolved to nothing until this normalization existed.
|
||
"""
|
||
return target.split(':')[-1].lower()
|
||
|
||
|
||
def has_boundary_clause(description):
|
||
return bool(BOUNDARY_MARKER.search(description)
|
||
or BOUNDARY_ARROW.search(description))
|
||
|
||
|
||
def _first(match):
|
||
"""(name, start, end) offsets for the first group that matched."""
|
||
for index in range(1, (match.re.groups or 0) + 1):
|
||
if match.group(index):
|
||
return match.group(index), match.start(index), match.end(index)
|
||
return None, None, None
|
||
|
||
|
||
def _terminal(text, pos):
|
||
"""True if the token at pos does not make the preceding name a modifier."""
|
||
follower = FOLLOWER.match(text, pos)
|
||
return not follower or follower.group(1) in FOLLOWER_OK
|
||
|
||
|
||
def _notation(text, start, arrow):
|
||
"""True if the name is written in route NOTATION rather than in prose.
|
||
|
||
Two forms qualify: `/name` (Claude Code's invocation syntax, detected from
|
||
the character before the name) and `-> 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 <thing> -> <name>`. 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 skill-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'(?<![\w./-])(?:\./)?references/([A-Za-z0-9][A-Za-z0-9._/-]*\.md)')
|
||
# A pointer named in a sentence that says the file is GONE is a historical
|
||
# mention, not a dispatch entry: "the old `references/legacy.md` was removed in
|
||
# v2" is prose and must not be a hard ERROR. Narrow on purpose — a live
|
||
# dispatch table never describes its own target as removed, so this costs no
|
||
# recall.
|
||
REFERENCE_PAST = re.compile(
|
||
r'\b(?:removed|deleted|renamed|superseded|replaced|obsolete|deprecated'
|
||
r'|former|formerly|gone|no longer|used to)\b', re.I)
|
||
# A pointer QUALIFIED by another skill's name — "skill-audit's
|
||
# references/validation-scripts.md" — names a file that is deliberately NOT in
|
||
# this skill's directory. Requiring it on the local disk left NO legal spelling
|
||
# for a cross-skill reference at all: the only alternative, a full repo path
|
||
# (`plugins/kyberforge/.apm/skills/skill-audit/references/...`), is itself a
|
||
# FAIL under skill-audit's own file-structure rubric, because a path that climbs
|
||
# out of the skill directory stops resolving once the plugin is cache-installed.
|
||
# The possessive form is the sanctioned spelling, and it is skipped here. It is
|
||
# not checked further — this function has no way to locate another skill's
|
||
# directory, and inventing one would reintroduce exactly the cross-plugin path
|
||
# assumption the rubric forbids.
|
||
REFERENCE_QUALIFIER = re.compile(u"[A-Za-z0-9][A-Za-z0-9._-]*`?['’]s[ \t]+`?$")
|
||
|
||
|
||
def mask_fenced(text):
|
||
"""Body with fenced code blocks blanked out, byte offsets preserved."""
|
||
out = []
|
||
fence = None
|
||
for line in text.splitlines(keepends=True):
|
||
stripped = line.rstrip('\r\n')
|
||
opener = FENCE_OPEN.match(stripped)
|
||
marker = opener.group(1) if opener else None
|
||
if fence is None:
|
||
if marker:
|
||
fence = marker
|
||
out.append(' ' * len(stripped) + line[len(stripped):])
|
||
continue
|
||
out.append(line)
|
||
else:
|
||
out.append(' ' * len(stripped) + line[len(stripped):])
|
||
if (marker and marker[0] == fence[0] and len(marker) >= 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/<file>.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 =====
|
||
|
||
|
||
def parse_frontmatter(content):
|
||
m = FRONTMATTER_RE.match(strip_bom(content))
|
||
if not m:
|
||
return None, content
|
||
return m.group(1), strip_bom(content)[m.end():]
|
||
|
||
def extract_field(fm, field):
|
||
"""The raw text after `field:` ON ITS OWN LINE, or None.
|
||
|
||
The character class is `[^\\S\\r\\n]`, never `\\s`: under re.MULTILINE a
|
||
`\\s*` after the colon crosses the newline, so `description:` with no value
|
||
followed by `model: sonnet` captured `model: sonnet` as the description.
|
||
That made the value look present, skipped the "missing or empty" failure,
|
||
and then every ADR-0020 gate early-returned on the genuinely empty folded
|
||
value — a valueless description exited 0 with zero output on a BLOCKING
|
||
pre-push gate. This function is now used only for fields with no folding
|
||
semantics (name, tools); description goes through description_value(), the
|
||
shared resolver's YAML reader, which is the only thing that can see through
|
||
`>`, `null`, `''` and a quoted `"description"` key alike.
|
||
"""
|
||
m = re.search(rf'^{re.escape(field)}:[^\S\r\n]*(.+)', fm, re.MULTILINE)
|
||
return m.group(1).strip() if m else None
|
||
|
||
def get_frontmatter_keys(fm):
|
||
keys = set()
|
||
for line in fm.splitlines():
|
||
m = re.match(r'^([a-zA-Z][a-zA-Z0-9_-]*):', line)
|
||
if m:
|
||
keys.add(m.group(1))
|
||
return keys
|
||
|
||
def agent_description(fm, local_fname):
|
||
"""The folded description VALUE, or None if it could not be read."""
|
||
try:
|
||
return description_value(fm)
|
||
except FrontmatterError as exc:
|
||
# `exc` carries the whole clause — invalid YAML, a non-mapping block, or
|
||
# a description of the wrong type. Do not prefix a diagnosis here; the
|
||
# last one named a syntax error for two failures that have none.
|
||
fail(f"{exc} — the ADR-0020 description and boundary-target gates could "
|
||
f"not run — {local_fname}")
|
||
return None
|
||
|
||
def check_description_budget(value, local_fname, by_hand=False):
|
||
"""ADR-0020 description gates — identical for every scope.
|
||
|
||
`by_hand` is ADR-0020's hand-invocation carve-out (issue #108): an agent
|
||
carrying `disable-model-invocation: true` is absent from the model-visible
|
||
listing, so the 250-character SUGGESTION — a routing-quality budget — has
|
||
no listing to apply to. The 400-character ceiling is unaffected.
|
||
"""
|
||
if not value:
|
||
return
|
||
dlen = len(value)
|
||
if dlen > DESC_MAX_CHARS:
|
||
fail(f"description is {dlen} chars — exceeds the {DESC_MAX_CHARS}-character "
|
||
f"ADR-0020 ceiling. It is preloaded into every session whether or not the "
|
||
f"agent is invoked. Keep a trigger clause, at most one capability clause, "
|
||
f"and a boundary clause; move capability enumeration, output-format detail, "
|
||
f"composition notes and implementation detail to the body — {local_fname}")
|
||
elif dlen > DESC_SUGGEST_CHARS and not by_hand:
|
||
suggest(f"description is {dlen} chars — over the {DESC_SUGGEST_CHARS}-character "
|
||
f"ADR-0020 target (hard fail at {DESC_MAX_CHARS}). The SUGGESTION tier is "
|
||
f"what moves the corpus average; the FAIL tier only stops outliers "
|
||
f"— {local_fname}")
|
||
|
||
def check_boundary(value, fpath, local_fname, by_hand=False):
|
||
"""ADR-0020 boundary clause + resolvable boundary targets.
|
||
|
||
agent-author's SKILL.md states that an agent's boundary targets must
|
||
resolve, but until this ran no script checked it — the contract was
|
||
documented and unenforced. The resolution universe is derived from the
|
||
AGENT FILE's own location (the authoring root above it, its own apm
|
||
package, and that package's declared apm dependencies), never from this
|
||
script's path, and — when an authoring root exists — never from a deployed
|
||
.claude/ tree, so a fresh clone and a machine that has run `apm install`
|
||
return the same verdict.
|
||
"""
|
||
if not value:
|
||
return
|
||
# SUGGESTION, not FAIL: detecting the absence is deterministic, but whether
|
||
# this particular agent warrants a boundary clause is judgment. All four
|
||
# agents in this corpus currently lack one.
|
||
#
|
||
# THREE outcomes, not two: "no boundary clause" and "boundary clause I could
|
||
# not parse" are different findings (issue #110). And a hand-invoked agent is
|
||
# exempt from the clause altogether (issue #108) — the boundary-target
|
||
# resolution below still runs, because a target it DOES name should still
|
||
# resolve.
|
||
status = boundary_clause_status(value) if not by_hand else 'present'
|
||
if status == 'absent':
|
||
suggest(f"description has no boundary clause — add the prose form (\"Do not use "
|
||
f"for X — use `y` instead\") or ADR-0020's compressed form (\"Not X -> y\") "
|
||
f"so the router knows where NOT to send this agent — {local_fname}")
|
||
elif status == 'unparsed':
|
||
suggest(f"description has an arrow boundary clause (\"Not X -> y\") from which no "
|
||
f"target could be read, so the dangling-target check did not run on it — "
|
||
f"the clause is PRESENT and unparsed, not missing. Most often the target "
|
||
f"is a single word, which is deliberately not matchable bare: write it as "
|
||
f"`name` or /name — {local_fname}")
|
||
if not by_hand:
|
||
# One arrow, one target: a second name after the same arrow is resolved
|
||
# by nothing and reported by nothing (issue #107).
|
||
for first, second in multi_target_arrow_clauses(value):
|
||
suggest(f"an arrow boundary clause names more than one target ('{first}', then "
|
||
f"'{second}') and only the first is resolved — the second is checked by "
|
||
f"nothing. Split it into one arrow per target: \"Not X -> {first}. "
|
||
f"Not Y -> {second}.\" — {local_fname}")
|
||
targets = boundary_targets(value)
|
||
if not targets:
|
||
return
|
||
known = known_targets(os.path.dirname(os.path.abspath(fpath)))
|
||
if not known:
|
||
info(f"boundary-target resolution DID NOT RUN — no skill universe could be "
|
||
f"determined for this path (no authoring root above it, no apm package "
|
||
f"root, no declared apm dependencies, no deployed .claude/ or .agents/ "
|
||
f"tree). Unchecked target(s): {', '.join(targets)} — {local_fname}")
|
||
return
|
||
# blocking vs reported: a target only earns a FAIL when it is written in
|
||
# route notation or its own sentence corroborates it by naming another target
|
||
# that resolves. See the shared resolver's CORROBORATION note.
|
||
blocking, reported = unresolved_targets(value, known)
|
||
for target in blocking:
|
||
fail(f"description routes to '{target}', which resolves to no skill or agent "
|
||
f"in this monorepo, in this package, or in a package it declares in "
|
||
f"apm.yml dependencies.apm — a boundary clause naming a non-existent "
|
||
f"target sends the router nowhere — {local_fname}")
|
||
for target in reported:
|
||
suggest(f"description routes to '{target}', which resolves to no skill or agent "
|
||
f"in this monorepo, in this package, or in a package it declares in "
|
||
f"apm.yml dependencies.apm — SUGGESTION rather than FAIL because nothing "
|
||
f"else in that sentence resolves, so it is equally likely to be a tool, a "
|
||
f"file format or an English compound. If it IS a route, write it as "
|
||
f"`/{target}` or `-> {target}` and it will be checked properly — "
|
||
f"{local_fname}")
|
||
|
||
def extract_tools_list(fm):
|
||
"""Tool names from the `tools` field — inline scalar OR YAML block sequence.
|
||
|
||
Read off the PARSED mapping, never off extract_field(). That function's
|
||
capture is newline-bounded on purpose (`[^\\S\\r\\n]*(.+)`), so a `tools:`
|
||
written as a block sequence — the shape Copilot agent files use — captured
|
||
nothing at all and the subagent-unavailable-tool check silently stopped
|
||
firing on exactly the files it was written for. Both spellings are legal
|
||
YAML, so both are read here.
|
||
"""
|
||
try:
|
||
data = yaml.safe_load(fm)
|
||
except Exception:
|
||
# Not this function's failure to report: the frontmatter's validity is
|
||
# decided (and failed) by agent_description() on the same text.
|
||
return set()
|
||
if not isinstance(data, dict):
|
||
return set()
|
||
val = data.get('tools')
|
||
if isinstance(val, list):
|
||
items = [str(item).strip() for item in val]
|
||
elif isinstance(val, str):
|
||
items = re.split(r'[\s,]+', val.strip())
|
||
else:
|
||
return set()
|
||
return {item for item in items if item}
|
||
|
||
def is_copilot_cloud_ide(fpath):
|
||
"""True if the file is a cloud/IDE Copilot agent (name is optional for these)."""
|
||
return '.github/copilot/agents' in os.path.abspath(fpath).replace(os.sep, '/')
|
||
|
||
# --- Detect scope ---
|
||
# APM_TYPE_RE matches a top-level (column-0) `type:` line in apm.yml whose value is
|
||
# exactly one of the four package content types. Group 1 captures an optional
|
||
# opening quote; \1 requires the same character (or nothing) to close it, so
|
||
# "skill" and '"skill"' both match but a mismatched quote doesn't. The value
|
||
# must then be followed by whitespace or end-of-line — not just a non-word
|
||
# character — so a malformed value like `prompts-only` is correctly rejected
|
||
# instead of false-matching on the `prompts` prefix.
|
||
APM_TYPE_RE = re.compile(r"^type:\s*(['\"]?)(instructions|skill|hybrid|prompts)\1(?:\s|$)")
|
||
|
||
def find_apm_package_root(apm_yml_path):
|
||
"""Return True if apm_yml_path has a top-level type: line (i.e. is a package
|
||
manifest, not a type:-less marketplace-only apm.yml)."""
|
||
# errors='replace', not a hard failure: this only asks whether a `type:`
|
||
# line exists, and a stray undecodable byte elsewhere in someone else's
|
||
# apm.yml must not abort scope detection.
|
||
with open(apm_yml_path, encoding='utf-8', errors='replace') as f:
|
||
for line in f:
|
||
if APM_TYPE_RE.match(line):
|
||
return True
|
||
return False
|
||
|
||
def detect_scope(start_dir):
|
||
home = os.path.expanduser('~')
|
||
original_start = os.path.abspath(start_dir)
|
||
# Agent files conventionally live exactly two path segments below their
|
||
# scope root — <root>/.claude/agents, <root>/.github/agents,
|
||
# <root>/.copilot/agents, or <root>/.apm/agents (see new-agent.sh's
|
||
# CC_DIR/CP_DIR and user-scope dirs). Stripping those two segments
|
||
# recovers the same root new-agent.sh would have been invoked with to
|
||
# produce this exact file, independent of how far the walk below has to
|
||
# travel to find (or fail to find) a marker — mirrors new-agent.sh's
|
||
# `root` vs `current` distinction even though validate.sh is handed a
|
||
# file's directory, not the scope root itself.
|
||
#
|
||
# That arithmetic is only trustworthy when the path actually has this
|
||
# shape: parent directory literally named "agents", grandparent one of
|
||
# the four known scope-dir names. A hand-placed or otherwise
|
||
# non-conventional agent file (never produced by new-agent.sh) has no
|
||
# such guarantee — blindly trusting two-segments-up there could point at
|
||
# an unrelated ancestor. conventional_shape gates every use of
|
||
# conventional_root below; when it's false, the walked-to `current`
|
||
# directory is used instead, the same fallback this function used before
|
||
# conventional_root existed.
|
||
scope_dir_name = os.path.basename(os.path.dirname(original_start))
|
||
conventional_shape = (
|
||
os.path.basename(original_start) == 'agents'
|
||
and scope_dir_name in ('.claude', '.github', '.copilot', '.apm')
|
||
)
|
||
conventional_root = os.path.dirname(os.path.dirname(original_start))
|
||
current = original_start
|
||
while True:
|
||
# The filesystem root is never a candidate, the same guard the shared
|
||
# resolver's walk-up loops carry. Without it a file under a marker-less
|
||
# temp directory walked all the way to `/` and returned it as the scope
|
||
# root, which then reported `counterpart file not found:
|
||
# /.claude/agents/<name>.md` — a path that names someone else's machine,
|
||
# not the user's project. When the walk runs out, the agent file's own
|
||
# directory (or its conventional root) is the honest answer.
|
||
if _is_fs_root(current):
|
||
return 'project', conventional_root if conventional_shape else original_start
|
||
apm_yml = os.path.join(current, 'apm.yml')
|
||
if os.path.isfile(apm_yml) and find_apm_package_root(apm_yml):
|
||
return 'plugin', current
|
||
# $HOME is the user-scope boundary — checked before the .git test
|
||
# below, so a dotfiles-managed $HOME (yadm, chezmoi bare-repo, etc.)
|
||
# can't shadow user scope by being its own .git repo. 'user' scope
|
||
# requires EITHER start_dir to BE $HOME itself (no walk-up — the
|
||
# new-agent.sh "root exactly $HOME" case) OR start_dir to sit at the
|
||
# conventional two-segments-below-root depth (i.e. $HOME IS that
|
||
# root, matching the real ~/.claude/agents or ~/.copilot/agents
|
||
# shape). Any other walk-up into $HOME — a marker-less directory
|
||
# nested deeper than that convention — resolves to project scope
|
||
# instead: a stray directory under $HOME can't be silently
|
||
# redirected into the shared global ~/.claude or ~/.copilot agent
|
||
# directories.
|
||
if current == home:
|
||
if original_start == home or (conventional_shape and conventional_root == home):
|
||
return 'user', home
|
||
return 'project', conventional_root if conventional_shape else current
|
||
# .git is a directory in a normal checkout but a file (`gitdir: ...`)
|
||
# in a git worktree — exists() covers both. Returns conventional_root,
|
||
# not current: new-agent.sh's project-scope file placement always
|
||
# uses its `$ROOT` argument directly, never the walked-up `.git`
|
||
# location, so a <root> one or more levels below the repo's .git
|
||
# (a subdirectory of a larger git-tracked tree — explicitly a
|
||
# supported case per new-agent.sh's usage text) must resolve to the
|
||
# same root new-agent.sh actually wrote to, not to the .git dir —
|
||
# unless the path lacks the conventional shape, in which case that
|
||
# arithmetic isn't trustworthy and current is used instead.
|
||
if os.path.exists(os.path.join(current, '.git')):
|
||
return 'project', conventional_root if conventional_shape else current
|
||
parent = os.path.dirname(current)
|
||
if parent == current:
|
||
return 'project', conventional_root if conventional_shape else current
|
||
current = parent
|
||
|
||
agent_dir = os.path.dirname(agent_file)
|
||
scope, scope_root = detect_scope(agent_dir)
|
||
|
||
# --- Plugin/APM scope: single vendor-neutral file, no counterpart ---
|
||
def check_apm_agent_file(fpath, allowlist, stem):
|
||
local_fname = os.path.basename(fpath)
|
||
try:
|
||
content = read_text(fpath)
|
||
except EncodingError as exc:
|
||
fail(f"file is {exc}. Nothing could be measured, so this is a hard "
|
||
f"failure, not a skip — {local_fname}")
|
||
return
|
||
except OSError as exc:
|
||
# A path that cannot be opened gets a FAIL line naming it, not a bare
|
||
# FileNotFoundError traceback. scripts/check-apm-agents-valid.sh takes
|
||
# this path for an agent file deleted from the worktree but still
|
||
# tracked in the index — a real, expected state, and the caller needs to
|
||
# be told which file, not handed an interpreter stack.
|
||
fail(f"could not be read ({exc.strerror or exc}): {fpath}. Nothing could "
|
||
f"be measured, so this is a hard failure, not a skip — {local_fname}")
|
||
return
|
||
|
||
fm, body = parse_frontmatter(content)
|
||
if fm is None:
|
||
fail(f"no parseable YAML frontmatter block — expected a `---` line, the fields, "
|
||
f"then a closing `---` line (a BOM, leading blank lines, trailing spaces "
|
||
f"after either marker and CRLF endings are all tolerated). Nothing could be "
|
||
f"measured, so this is a hard failure, not a skip — {local_fname}")
|
||
return
|
||
|
||
# The apm-agent.md template embeds its authoring guidance as HTML
|
||
# comments inside the frontmatter block (so they render invisible in a
|
||
# Markdown preview but stay visible in the raw file). get_frontmatter_keys
|
||
# silently ignores any line that isn't a `key:` match, so a comment left
|
||
# behind at ship time would otherwise pass unnoticed — yet apm compile
|
||
# copies this frontmatter verbatim to both harnesses, and `<!-- -->` is
|
||
# not valid YAML, so yaml.safe_load breaks on both downstream (ADR-0016).
|
||
if re.search(r'<!--|-->', fm):
|
||
fail(f"frontmatter still contains template HTML comments (<!-- ... -->) "
|
||
f"— delete them before shipping — {local_fname}")
|
||
|
||
# Allowlist: the permitted keys are data, read at load time from
|
||
# references/field-inventory.md's `## apm-agent-allowlist` section — do not
|
||
# restate them here, or this comment goes stale the next time that line
|
||
# changes. apm compile verbatim-copies frontmatter to every target, so a key
|
||
# outside the list is unsafe on at least one harness (ADR-0016). Note the
|
||
# list admits denylist-shaped restrictions (disallowedTools) but never
|
||
# allowlist-shaped ones (tools), whose value shape differs per harness.
|
||
fm_keys = get_frontmatter_keys(fm)
|
||
for key in sorted(fm_keys):
|
||
if key not in allowlist:
|
||
fail(f"field '{key}' is not in the vendor-neutral APM agent allowlist "
|
||
f"({', '.join(sorted(allowlist))}) — {local_fname}")
|
||
|
||
# name — required, kebab-case, must match filename stem (file is <name>.agent.md)
|
||
name_val = extract_field(fm, 'name')
|
||
if not name_val:
|
||
fail(f"name field is missing or empty — {local_fname}")
|
||
else:
|
||
if not re.match(r'^[a-z0-9]+(-[a-z0-9]+)*$', name_val):
|
||
fail(f"name '{name_val}' is not kebab-case — {local_fname}")
|
||
if name_val != stem:
|
||
fail(f"name '{name_val}' does not match filename stem '{stem}' — {local_fname}")
|
||
|
||
# description — required, non-empty, no placeholder
|
||
# Presence is decided on the FOLDED value, never on a line regex. Deciding
|
||
# it on extract_field's raw capture is what let `description:` with no value
|
||
# pass this gate in total silence: the capture picked up the next key, so
|
||
# "missing or empty" never fired, and every ADR-0020 check below then
|
||
# early-returned on the empty folded value. Exit 0, zero output, no gate run.
|
||
folded = agent_description(fm, local_fname)
|
||
if folded is None:
|
||
pass # frontmatter is not valid YAML — agent_description already failed
|
||
elif not folded:
|
||
fail(f"description field is missing or empty — {local_fname}")
|
||
else:
|
||
if PLACEHOLDER_RE.search(folded):
|
||
fail(f"description contains unfilled FILL IN: placeholder — {local_fname}")
|
||
by_hand = hand_invoked(fm)
|
||
check_description_budget(folded, local_fname, by_hand)
|
||
check_boundary(folded, fpath, local_fname, by_hand)
|
||
|
||
# body — required, non-empty, no placeholder; same Copilot truncation risk
|
||
# applies since this file compiles verbatim into a real Copilot file downstream.
|
||
if not body.strip():
|
||
fail(f"system prompt body is empty — {local_fname}")
|
||
else:
|
||
if PLACEHOLDER_RE.search(body):
|
||
fail(f"body contains unfilled FILL IN: placeholder — {local_fname}")
|
||
if len(body) > COPILOT_BODY_LIMIT:
|
||
suggest(f"body exceeds {COPILOT_BODY_LIMIT:,} characters ({len(body):,} chars) — "
|
||
f"content beyond the limit is silently truncated by the Copilot runtime "
|
||
f"once apm compile emits it downstream — {local_fname}")
|
||
|
||
if scope == 'plugin':
|
||
check_apm_agent_file(agent_file, apm_agent_allowlist, name_stem)
|
||
for s in suggestions:
|
||
print(f"SUGGESTION {s}")
|
||
sys.exit(1 if failed else 0)
|
||
|
||
# --- Project/user scope: unchanged CC/Copilot pair validation ---
|
||
|
||
# --- Derive counterpart path ---
|
||
if scope == 'project':
|
||
if provider == 'claude-code':
|
||
counterpart = os.path.join(scope_root, '.github', 'agents', name_stem + '.agent.md')
|
||
counterpart_provider = 'copilot'
|
||
else:
|
||
counterpart = os.path.join(scope_root, '.claude', 'agents', name_stem + '.md')
|
||
counterpart_provider = 'claude-code'
|
||
else: # user
|
||
home = os.path.expanduser('~')
|
||
if provider == 'claude-code':
|
||
counterpart = os.path.join(home, '.copilot', 'agents', name_stem + '.agent.md')
|
||
counterpart_provider = 'copilot'
|
||
else:
|
||
counterpart = os.path.join(home, '.claude', 'agents', name_stem + '.md')
|
||
counterpart_provider = 'claude-code'
|
||
|
||
def check_file(fpath, file_provider):
|
||
local_fname = os.path.basename(fpath)
|
||
try:
|
||
content = read_text(fpath)
|
||
except EncodingError as exc:
|
||
fail(f"file is {exc}. Nothing could be measured, so this is a hard "
|
||
f"failure, not a skip — {local_fname}")
|
||
return
|
||
except OSError as exc:
|
||
# Same reason as check_apm_agent_file's: a diagnostic naming the path
|
||
# beats a FileNotFoundError traceback. The counterpart is pre-checked at
|
||
# the bottom of this script, but agent_file itself never was.
|
||
fail(f"could not be read ({exc.strerror or exc}): {fpath}. Nothing could "
|
||
f"be measured, so this is a hard failure, not a skip — {local_fname}")
|
||
return
|
||
|
||
fm, body = parse_frontmatter(content)
|
||
if fm is None:
|
||
fail(f"no parseable YAML frontmatter block — expected a `---` line, the fields, "
|
||
f"then a closing `---` line (a BOM, leading blank lines, trailing spaces "
|
||
f"after either marker and CRLF endings are all tolerated). Nothing could be "
|
||
f"measured, so this is a hard failure, not a skip — {local_fname}")
|
||
return
|
||
|
||
# name — required for CC and Copilot CLI; optional for Copilot cloud/IDE agents
|
||
cloud_ide = (file_provider == 'copilot' and is_copilot_cloud_ide(fpath))
|
||
name_val = extract_field(fm, 'name')
|
||
if not cloud_ide:
|
||
if not name_val:
|
||
fail(f"name field is missing or empty — {local_fname}")
|
||
else:
|
||
if not re.match(r'^[a-z0-9]+(-[a-z0-9]+)*$', name_val):
|
||
fail(f"name '{name_val}' is not kebab-case — {local_fname}")
|
||
# Stem check applies to Copilot CLI only; CC docs say filename need not match name
|
||
if file_provider == 'copilot':
|
||
stem = local_fname[:-len('.agent.md')]
|
||
if name_val != stem:
|
||
fail(f"name '{name_val}' does not match filename stem '{stem}' — {local_fname}")
|
||
elif name_val and not re.match(r'^[a-z0-9]+(-[a-z0-9]+)*$', name_val):
|
||
# cloud/IDE: name is optional, but if present it must be valid
|
||
fail(f"name '{name_val}' is not kebab-case — {local_fname}")
|
||
|
||
# description
|
||
# Presence is decided on the FOLDED value, never on a line regex. Deciding
|
||
# it on extract_field's raw capture is what let `description:` with no value
|
||
# pass this gate in total silence: the capture picked up the next key, so
|
||
# "missing or empty" never fired, and every ADR-0020 check below then
|
||
# early-returned on the empty folded value. Exit 0, zero output, no gate run.
|
||
folded = agent_description(fm, local_fname)
|
||
if folded is None:
|
||
pass # frontmatter is not valid YAML — agent_description already failed
|
||
elif not folded:
|
||
fail(f"description field is missing or empty — {local_fname}")
|
||
else:
|
||
if PLACEHOLDER_RE.search(folded):
|
||
fail(f"description contains unfilled FILL IN: placeholder — {local_fname}")
|
||
by_hand = hand_invoked(fm)
|
||
check_description_budget(folded, local_fname, by_hand)
|
||
check_boundary(folded, fpath, local_fname, by_hand)
|
||
|
||
# body
|
||
if not body.strip():
|
||
fail(f"system prompt body is empty — {local_fname}")
|
||
else:
|
||
if PLACEHOLDER_RE.search(body):
|
||
fail(f"body contains unfilled FILL IN: placeholder — {local_fname}")
|
||
# Copilot body length limit
|
||
if file_provider == 'copilot' and len(body) > COPILOT_BODY_LIMIT:
|
||
suggest(f"body exceeds {COPILOT_BODY_LIMIT:,} characters ({len(body):,} chars) — content beyond the limit is silently truncated by the Copilot runtime — {local_fname}")
|
||
|
||
# CC-only fields in Copilot file
|
||
if file_provider == 'copilot':
|
||
fm_keys = get_frontmatter_keys(fm)
|
||
for key in sorted(fm_keys):
|
||
if key in cc_only_fields:
|
||
fail(f"CC-only field '{key}' present in Copilot file — {local_fname}")
|
||
|
||
# Copilot-only fields in CC file
|
||
if file_provider == 'claude-code':
|
||
fm_keys = get_frontmatter_keys(fm)
|
||
for key in sorted(fm_keys):
|
||
if key in copilot_only_fields:
|
||
fail(f"Copilot-only field '{key}' present in CC file — {local_fname}")
|
||
|
||
# Subagent-unavailable tools listed in tools field
|
||
tools = extract_tools_list(fm)
|
||
unavailable = tools & SUBAGENT_UNAVAILABLE_TOOLS
|
||
for tool in sorted(unavailable):
|
||
suggest(f"'{tool}' is listed in tools but is never available to subagents — the runtime withholds it regardless — {local_fname}")
|
||
|
||
# --- Check counterpart exists ---
|
||
if not os.path.isfile(counterpart):
|
||
fail(f"counterpart file not found: {counterpart}")
|
||
sys.exit(1)
|
||
|
||
# --- Check both files ---
|
||
check_file(agent_file, provider)
|
||
check_file(counterpart, counterpart_provider)
|
||
|
||
for s in suggestions:
|
||
print(f"SUGGESTION {s}")
|
||
|
||
sys.exit(1 if failed else 0)
|
||
PYTHON
|