#!/usr/bin/env bash # Differential test: scripts/skill-size-check.sh (the pre-commit hook) and # skill-audit/scripts/validate.sh (the in-skill auditor) must reach the SAME # ADR-0020 verdict on the same file. # # Why this exists as a separate suite. tests/test-skill-size-check.sh already # asserts the two agree on their CONSTANTS, and that assertion is necessary but # demonstrably not sufficient: a previous review found the two scripts disagreeing # on real files while every constant matched perfectly. Constants are one of the # ways two hand-duplicated implementations diverge; comparison operators, message # wording, which value gets measured, and which branch runs first are the others, # and none of them is visible to a constant check. # # The consequence of divergence is specific and bad: skill-audit reports a skill # ready to ship and the commit hook then rejects it, or worse, the reverse. So the # comparison here is over VERDICTS on files, not over source text. # # Scope: the ADR-0020 axes the two scripts share — description length and tier, # body word count and tier, dangling routing targets, missing references/ # pointers, the two Gotchas suggestions, the missing-boundary-clause suggestion, # a declined resolution, and an empty description. The two scripts legitimately # differ elsewhere (validate.sh also checks name/directory agreement, script # executability and the 1024-char spec backstop; the hook checks whole-file lines # and words), and those lines are ignored rather than being forced into a shared # shape they were never meant to have. # # Run over the real 39-skill corpus AND over purpose-built fixtures that sit ON # each boundary. The corpus alone is not enough — it happens not to contain a # file at exactly 900 body words, which is precisely where an inclusive/exclusive # comparison mismatch would hide. set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" HOOK="$REPO_ROOT/scripts/skill-size-check.sh" SKILL_VALIDATE="$REPO_ROOT/plugins/kyberforge/.apm/skills/skill-audit/scripts/validate.sh" TMPDIR_T="$(mktemp -d)" trap 'rm -rf "$TMPDIR_T"' EXIT # --------------------------------------------------------------------------- # Fixtures: one per ADR-0020 axis, placed ON the boundary wherever there is one. # --------------------------------------------------------------------------- # Built inside a synthetic plugin monorepo so boundary-target resolution actually # runs for both scripts (in a bare temp dir both would decline, and "both # declined" is agreement about nothing). FIXTURE_ROOT="$TMPDIR_T/fixtures" FX="$FIXTURE_ROOT/plugins/fixture-plugin/.apm/skills" mkdir -p "$FX/sibling-skill" "$FIXTURE_ROOT/plugins/fixture-plugin/.apm/agents" make_fx() { local name="$1" desc="$2" body_words="$3" mkdir -p "$FX/$name" { echo "---" echo "name: $name" echo "description: $desc" echo "---" echo "" python3 -c "print(' '.join(['word'] * $body_words))" } > "$FX/$name/SKILL.md" } desc_of_length() { python3 - "$1" <<'PY' import sys n = int(sys.argv[1]) prefix = 'Use when doing the thing. Do not use for anything else. ' print(prefix + 'x' * (n - len(prefix))) PY } CLEAN="Use when doing the thing. Do not use for anything else." # Description tier boundaries, both sides of both thresholds. make_fx desc-249 "$(desc_of_length 249)" 10 make_fx desc-250 "$(desc_of_length 250)" 10 make_fx desc-251 "$(desc_of_length 251)" 10 make_fx desc-400 "$(desc_of_length 400)" 10 make_fx desc-401 "$(desc_of_length 401)" 10 # Body tier boundaries, both sides of both thresholds. make_fx body-599 "$CLEAN" 599 make_fx body-600 "$CLEAN" 600 make_fx body-601 "$CLEAN" 601 make_fx body-900 "$CLEAN" 900 make_fx body-901 "$CLEAN" 901 # Folding: the value has to be measured after YAML folding in both scripts. mkdir -p "$FX/folded-desc" { echo "---" echo "name: folded-desc" echo "description: >" python3 -c "print('\n'.join([' ' + 'x' * 40] * 11))" echo "---" echo "" echo "Do the thing." } > "$FX/folded-desc/SKILL.md" # Routing targets, one per tier the resolver can produce: resolves, dangles with # in-sentence corroboration (ERROR/FAIL), dangles alone (SUGGESTION on both # sides), route notation (ERROR/FAIL without corroboration), attributive # (silent). Each tier is here because the two scripts have to agree on the TIER, # not merely on the finding — a copy that promoted or demoted one of them would # otherwise pass this comparison. make_fx target-resolves "Use when doing the thing. Do not use for the other thing — use sibling-skill instead." 10 make_fx target-dangles "Use when doing the thing. Do not use for the other thing — use sibling-skill or no-such-skill instead." 10 make_fx target-dangles-lone "Use when doing the thing. Do not use for the other thing — use no-such-lone-skill instead." 10 make_fx target-dangles-notation "Use when doing the thing. Do not use for the other thing — use /no-such-notation-skill instead." 10 make_fx target-attributive "Use when doing the thing. Use pre-commit hooks instead of ad-hoc scripts." 10 # No boundary clause at all. make_fx no-boundary "Use when the user wants the thing done." 10 # A missing references/ pointer, and a present one. make_fx ref-missing "$CLEAN" 10 printf '\nIf the caller needs detail, read references/absent.md first.\n' >> "$FX/ref-missing/SKILL.md" make_fx ref-present "$CLEAN" 10 printf '\nIf the caller needs detail, read references/there.md first.\n' >> "$FX/ref-present/SKILL.md" mkdir -p "$FX/ref-present/references" echo "detail" > "$FX/ref-present/references/there.md" # Gotchas, over each guideline. make_fx gotchas-many "$CLEAN" 0 cat >> "$FX/gotchas-many/SKILL.md" <<'EOF' ## Gotchas - one trap here - two trap here - three trap here - four trap here - five trap here - six trap here - seven trap here ## Notes EOF python3 -c "print(' '.join(['word'] * 200))" >> "$FX/gotchas-many/SKILL.md" # Gotchas over the 25% body-fraction guideline. Prose, not list items, so the # entry guideline cannot fire and the two suggestions stay separable: 26 section # words in a 100-word body is one word past the threshold. make_fx gotchas-fraction "$CLEAN" 0 { echo "" echo "## Gotchas" echo "" python3 -c "print(' '.join(['word'] * 26))" echo "" echo "## Notes" echo "" python3 -c "print(' '.join(['word'] * 70))" } >> "$FX/gotchas-fraction/SKILL.md" # Empty description — the shape that used to exit 0 in silence. mkdir -p "$FX/empty-desc" printf -- '---\nname: empty-desc\ndescription:\nmodel: sonnet\n---\n\nDo the thing.\n' \ > "$FX/empty-desc/SKILL.md" # Every boundary shape at once, so a divergence that only appears when several # findings fire together is not missed. make_fx combined "$(desc_of_length 401)" 901 printf '\nIf the caller needs detail, read references/absent.md first.\n' >> "$FX/combined/SKILL.md" # A skill with routing targets and NO authoring root above it — deliberately # OUTSIDE the fixture plugin tree. Both scripts must decline out loud, and both # must decline identically; "both declined" is only meaningful as agreement if # the declining path is exercised on purpose somewhere. ORPHAN_ROOT="$TMPDIR_T/orphan" mkdir -p "$ORPHAN_ROOT/no-universe" { echo "---" echo "name: no-universe" echo "description: Use when doing the thing. Do not use for the other thing — use some-other-skill instead." echo "---" echo "" echo "Do the thing." } > "$ORPHAN_ROOT/no-universe/SKILL.md" # --------------------------------------------------------------------------- # The comparison # --------------------------------------------------------------------------- python3 - "$HOOK" "$SKILL_VALIDATE" "$REPO_ROOT" "$FX" "$ORPHAN_ROOT" <<'PYTHON' import glob import os import re import subprocess import sys hook, validate, repo_root, fixture_dir, orphan_dir = sys.argv[1:6] passes = 0 failures = 0 def ok(msg): global passes passes += 1 print(" PASS: %s" % msg) def bad(msg): global failures failures += 1 print(" FAIL: %s" % msg) # Tier prefixes. The hook writes `ERROR: ` / `SUGGESTION: ` / `INFO: `; the # auditor writes `FAIL ` / `SUGGESTION ` / `INFO ` and additionally `PASS ` # lines, which carry no finding and are dropped. TIERS = ( ('ERROR', ('ERROR:', 'FAIL ')), ('SUGGESTION', ('SUGGESTION:', 'SUGGESTION ')), ('INFO', ('INFO:', 'INFO ')), ) # Each rule turns a finding line into a canonical token. Wording differs between # the two scripts by design (one addresses a committer, the other an auditor), so # the tokens deliberately capture the MEASUREMENT and not the sentence. RULES = ( ('DESC_CHARS', re.compile(r'description is (\d+) char')), ('BODY_WORDS', re.compile(r'body is (\d+) words')), ('ROUTE', re.compile(r"routes to '([^']+)'")), ('MISSING_REF', re.compile(r'points at (references/[^\s,]+)')), ('GOTCHA_ENTRIES', re.compile(r'Gotchas section has (\d+) entries')), ('GOTCHA_FRACTION', re.compile(r'Gotchas section is (\d+) of (\d+) body words')), ('NO_BOUNDARY_CLAUSE', re.compile(r'(description has no boundary clause)')), ('RESOLUTION_DECLINED', re.compile(r'(boundary-target resolution DID NOT RUN)')), ('DESC_EMPTY', re.compile(r'(description field is missing or empty)')), ) def verdict(output): """The set of ADR-0020 findings in a script's output, tier included. Lines that match no rule are dropped rather than compared: the two scripts legitimately check different things outside ADR-0020 (name/directory agreement, script executability, the 1024-char spec backstop, whole-file line and word ceilings), and forcing those into the comparison would report a difference that is not a disagreement. """ found = set() for raw in output.splitlines(): line = raw.strip() tier = None for name, prefixes in TIERS: if any(line.startswith(p) for p in prefixes): tier = name break if tier is None: continue for token, pattern in RULES: match = pattern.search(line) if match: found.add((tier, token) + tuple(match.groups())) return found def run(cmd): proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) return proc.returncode, proc.stdout.decode('utf-8', 'replace') def compare(label, skill_dir): skill_md = os.path.join(skill_dir, 'SKILL.md') hook_rc, hook_out = run(['bash', hook, skill_md]) audit_rc, audit_out = run(['bash', validate, skill_dir]) hook_v = verdict(hook_out) audit_v = verdict(audit_out) problems = [] only_hook = sorted(hook_v - audit_v) only_audit = sorted(audit_v - hook_v) if only_hook: problems.append('only the hook reported %s' % (only_hook,)) if only_audit: problems.append('only skill-audit reported %s' % (only_audit,)) # Exit codes are compared on the ADR-0020 axis only: an ERROR-tier ADR-0020 # finding must make BOTH scripts non-zero, and neither may be turned # non-zero by a SUGGESTION. The raw codes cannot be compared directly — # validate.sh also fails on checks the hook does not run at all. hook_err = any(t == 'ERROR' for t, *_ in hook_v) audit_err = any(t == 'ERROR' for t, *_ in audit_v) if hook_err and hook_rc == 0: problems.append('the hook reported an ADR-0020 ERROR but exited 0') if audit_err and audit_rc == 0: problems.append('skill-audit reported an ADR-0020 FAIL but exited 0') if not hook_err and hook_rc != 0 and not _non_adr_hook_error(hook_out): problems.append('the hook exited %d with no ADR-0020 ERROR and no spec-ceiling ERROR' % hook_rc) if problems: bad('%s: %s' % (label, '; '.join(problems))) else: return True return False def _non_adr_hook_error(output): """True if the hook failed on a spec ceiling rather than an ADR-0020 gate. MAX_LINES / MAX_WORDS are the hook's other ERROR sources and are outside this comparison, so a non-zero exit explained by one of them is not a disagreement. """ return bool(re.search(r'ERROR: .*(-line ceiling|-word ceiling \(~5,000 tokens)', output)) # --- The real corpus ------------------------------------------------------- corpus = sorted(glob.glob(os.path.join(repo_root, 'plugins', '*', '.apm', 'skills', '*'))) corpus = [d for d in corpus if os.path.isfile(os.path.join(d, 'SKILL.md'))] print("") print("--- the two scripts agree on every skill in the live corpus (%d files) ---" % len(corpus)) if len(corpus) < 30: bad('only %d corpus skills were discovered — the glob is wrong, so this leg ' 'proves nothing' % len(corpus)) else: ok('discovered %d corpus skills to compare' % len(corpus)) agreed = 0 for skill_dir in corpus: rel = os.path.relpath(skill_dir, repo_root) if compare(rel, skill_dir): agreed += 1 if agreed == len(corpus): ok('all %d corpus skills produce identical ADR-0020 verdicts from both scripts' % agreed) # --- Boundary fixtures ----------------------------------------------------- fixtures = sorted(d for d in (glob.glob(os.path.join(fixture_dir, '*')) + glob.glob(os.path.join(orphan_dir, '*'))) if os.path.isfile(os.path.join(d, 'SKILL.md'))) print("") print("--- the two scripts agree on every boundary fixture (%d files) ---" % len(fixtures)) if len(fixtures) < 15: bad('only %d fixtures were built — the fixture set is incomplete, so the ' 'boundaries the corpus does not cover are untested' % len(fixtures)) else: ok('built %d boundary fixtures to compare' % len(fixtures)) fx_agreed = 0 for skill_dir in fixtures: if compare(os.path.basename(skill_dir), skill_dir): fx_agreed += 1 if fx_agreed == len(fixtures): ok('all %d boundary fixtures produce identical ADR-0020 verdicts from both scripts' % fx_agreed) # --- The comparison must not be vacuous ------------------------------------ # Everything above would also pass if verdict() extracted nothing at all. So the # fixtures are required to have produced findings across every axis this suite # claims to compare — if a rule stops matching (a reworded message, say), that is # a silent loss of coverage and it fails here instead. print("") print("--- the comparison actually extracted findings on every axis it claims to cover ---") seen_tokens = set() for skill_dir in fixtures: _, out = run(['bash', hook, os.path.join(skill_dir, 'SKILL.md')]) for entry in verdict(out): seen_tokens.add(entry[1]) _, out = run(['bash', validate, skill_dir]) for entry in verdict(out): seen_tokens.add(entry[1]) expected_tokens = {t for t, _ in RULES} missing = sorted(expected_tokens - seen_tokens) if missing: bad('no fixture produced a finding for %s — verdict() may no longer match ' 'those messages, and any disagreement on them would go unseen' % missing) else: ok('every one of the %d compared axes was exercised by at least one fixture' % len(expected_tokens)) print("") print("Results: %d passed, %d failed" % (passes, failures)) sys.exit(1 if failures else 0) PYTHON