Files
boshu2__agentops/scripts/validate-skill-body-refs.sh
T
Bo c88d0f0d88 feat(ci): skill-body command-ref gate — block stale ao refs in prose (ag-4x8) (#532)
## What

New CI gate `validate-skill-body-refs` (T1) that scans the **inline-code
prose** of every `SKILL.md` + `references/*.md` (under `skills/` and
`skills-codex/`) for `` `ao <command>` `` / `` `ao <command> --flag` ``
tokens and validates each against the **live `ao` help tree**.

This is the systemic fix for oracle gap #3:
`validate-skill-cli-snippets.sh` only inspects fenced/`ao`-leading
snippets, so a stale command/flag named inside an inline code span
mid-sentence (e.g. `` `ao schedule` `` , `` `ao context assemble` ``)
regenerated after every CLI rename. This gate is the **complement** of
the snippet gate — together they cover both fenced snippets and prose.

## How the gate works

- Builds a robust live command tree: `ao capabilities` (authoritative
top-level) + recursive grouped-help subcommand parse (handles `ao
goals`' category-grouped help that the standard "Available Commands:"
parser misses) + command aliases (`quickstart` → `quick-start`) +
per-command flags.
- Only validates `ao` refs inside inline `` `code` `` spans — bare-prose
English ("if ao available", "ao not installed") is never treated as a
command, eliminating false positives while still catching the genuine
(always backtick-quoted) stale refs.
- Justified historical refs skip via an inline marker (`Superseded` /
`Historical` / `removed in` / `retained for history` / `(historical)`)
on the line/file, or `scripts/skill-body-refs-allowlist.txt`.

## Prose sweep (what made main pass)

The gate detected genuine stale refs on main; swept them to keep the
gate green:

- `ao context assemble` → `ao lookup` (8 sites — the command was never
built; `ao lookup` is the real co-cited replacement)
- `ao cite` → `ao metrics cite`
- `ao worktree gc` → `git worktree prune` (4 sites — `ao worktree` never
existed)
- `ao hooks` removal note → reworded to "removed in 3.0" (matches the
`removed in` historical marker)

## Allowlist contents

- `skills/standards/references/cli-wireup-template.md` — illustrative
naming template (deliberately names `ao loop-history` / `ao loop write`
/ `ao loop tail` as good/bad examples + future placeholders)
- `skills/release/references/release-notes.md`,
`skills-codex/release/references/release-notes.md` — historical
changelog prose (e.g. `ao daemon status` from the daemon era)

## Wiring

- `.github/workflows/validate.yml` — new T1 job + `summary.needs` +
`validate-pr-evidence-claims.needs` + summary echo (65 jobs)
- `docs/contracts/ci-jobs.yaml` — manifest entry (keeps
`validate-ci-policy-parity` green)
- `AGENTS-CI.md` — regenerated via `scripts/generate-ci-jobs-table.sh
--write`
- `tests/scripts/validate-skill-body-refs.bats` — 6 cases (clean pass,
stale-cmd fail, stale-flag fail, historical-marker skip, bare-prose
ignore, committed-tree pass)

## Verification (local, green)

- `bash scripts/validate-skill-body-refs.sh` → exit 0
- `scripts/validate-ci-policy-parity.sh` → PASS (65 rows)
- `scripts/validate-skill-cli-snippets.sh` → passed (no regression)
- `scripts/check-codex-parity-drift.sh` +
`validate-codex-generated-artifacts.sh --scope worktree` → passed (codex
hashes regenerated for the 6 edited skills only)
- `bats tests/scripts/validate-skill-body-refs.bats` → 6/6 ok
- `shellcheck scripts/validate-skill-body-refs.sh` → clean
- `markdownlint` on changed docs → clean

Closes-scenario: ag-4x8#body-ref-gate
Bounded-context: BC1-Corpus
Evidence: scripts/validate-skill-body-refs.sh
2026-05-24 22:42:40 -04:00

361 lines
12 KiB
Bash
Executable File

#!/usr/bin/env bash
# validate-skill-body-refs.sh — block stale `ao` command/flag references in
# skill+reference PROSE (inline-code spans), the complement of the fenced-block
# gate validate-skill-cli-snippets.sh.
#
# ag-4x8 — systemic fix for oracle gap #3. validate-skill-cli-snippets.sh only
# inspects fenced/`ao`-leading snippets; a stale command or flag named inside an
# inline `code span` mid-sentence (e.g. "use `ao context assemble`" or "`ao
# schedule` runs nightly") slips through. After every CLI rename those prose
# refs regenerate. This gate scans every inline-code span in the prose of
# SKILL.md + references/*.md across skills/ and skills-codex/ for `ao <command>`
# and `ao <command> --<flag>` tokens and validates each against the live `ao`
# help tree.
#
# Why inline-code spans only: a bare-prose "(if ao available)" or "ao not
# installed" is English, not a command invocation. Authors mark literal commands
# with backticks. Restricting to inline `code` is the precise signal that the
# author means a real command — it eliminates English false positives while
# still catching the genuine stale refs (which are always backtick-quoted).
# Fenced ``` blocks and lines that *start* with `ao ` are out of scope here —
# validate-skill-cli-snippets.sh owns those.
#
# Justified historical references are skipped two ways:
# 1. Per-line / per-file historical marker — a line (or the file's head)
# containing one of: "Superseded", "Historical", "(historical)",
# "removed in", "retained for history" (case-insensitive). A marked line is
# skipped; a marker in the first 40 lines skips the whole file (migration
# records / deprecation notes whose stale refs are the point).
# 2. scripts/skill-body-refs-allowlist.txt — minimal, commented allowlist of
# repo-relative file paths (migration records, ADRs, release notes).
#
# Exit 0 when every inline-code ref resolves (or is justified); exit 1 with
# file:line on stale refs.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
AO_BIN="${AGENTOPS_AO_BIN:-}"
if [[ -z "$AO_BIN" ]]; then
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
AO_BIN="$TMP_DIR/ao"
(
cd "$REPO_ROOT/cli"
go build -o "$AO_BIN" ./cmd/ao
)
fi
[[ -x "$AO_BIN" ]] || {
echo "Missing or non-executable ao binary: $AO_BIN" >&2
exit 1
}
export AO_BIN
export REPO_ROOT
python3 - <<'PY'
import json
import os
import pathlib
import re
import subprocess
import sys
repo_root = pathlib.Path(os.environ["REPO_ROOT"])
ao_bin = os.environ["AO_BIN"]
# Scan roots default to skills/ + skills-codex/. AGENTOPS_SKILL_BODY_ROOTS
# (colon-separated absolute paths) overrides them — used by the bats fixture to
# point the gate at a throwaway tree without mutating tracked skills.
roots_override = os.environ.get("AGENTOPS_SKILL_BODY_ROOTS", "").strip()
if roots_override:
roots = [pathlib.Path(p) for p in roots_override.split(":") if p]
else:
roots = [repo_root / "skills", repo_root / "skills-codex"]
HISTORICAL_MARKERS = (
"superseded",
"historical",
"removed in",
"retained for history",
)
allowlist_path = repo_root / "scripts" / "skill-body-refs-allowlist.txt"
allowlist = set()
if allowlist_path.is_file():
for raw in allowlist_path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if line and not line.startswith("#"):
allowlist.add(line)
WORDISH = re.compile(r"^[a-z][a-z0-9-]*$")
# ---------------------------------------------------------------------------
# Build the authoritative command tree from the live binary.
#
# Top-level names come from `ao capabilities` (the only strict source — cobra's
# `ao help <unknown>` returns 0 and cannot be trusted). Subcommands come from
# parsing help text, recognising BOTH the standard "Available Commands:" block
# AND grouped layouts (e.g. `ao goals` lists subcommands under category headers
# like "Measurement:" / "Analysis:"). Flags come from each command's --help.
# ---------------------------------------------------------------------------
def capabilities_top_level(ao):
res = subprocess.run(
[ao, "capabilities"],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
check=False,
)
if res.returncode != 0:
raise SystemExit("ao capabilities failed; cannot build command tree")
data = json.loads(res.stdout)
names = set()
for group in data.get("command_groups", []):
for cmd in group.get("commands", []):
name = cmd.get("name")
if name and name not in {"help", "completion"}:
names.add(name)
return names
def help_text(ao, path):
res = subprocess.run(
[ao, *path, "--help"],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
check=False,
)
return res.stdout
# A cobra command row: 2+ space indent, a lowercase command token, optional
# "(alias)", then whitespace and a description. Excludes flag rows (start with
# "-") and usage examples (skipped separately because they contain `ao `).
COMMAND_ROW = re.compile(r"^\s{2,}([a-z][a-z0-9-]*)(?:\s+\([a-z0-9-]+\))?\s+\S")
LONG_FLAG = re.compile(r"--[a-z][a-z0-9-]+")
# Cobra "Aliases:" block: " ao foo\n\nAliases:\n foo, f, fo".
ALIASES_LINE = re.compile(r"^Aliases:\s*$")
def parse_subcommands(text):
subs = set()
in_flags = False
for line in text.splitlines():
stripped = line.strip()
# Once we hit a Flags section, stop treating rows as subcommands.
if re.match(r"^(Flags|Global Flags):\s*$", stripped):
in_flags = True
continue
if stripped.endswith(":") and not stripped.startswith("-"):
# A new section header (Usage:, Measurement:, Examples:, ...).
# Flag sections were handled above; a non-flag header re-opens
# command parsing (covers grouped layouts like `ao goals`).
in_flags = False
continue
if in_flags:
continue
# Skip usage/example lines that mention `ao ` (those are invocations,
# not the command-listing rows).
if "ao " in line:
continue
m = COMMAND_ROW.match(line)
if m:
name = m.group(1)
if name not in {"help", "completion"}:
subs.add(name)
return subs
def parse_aliases(text):
"""Parse the `Aliases:` block of a command's help into alias names."""
lines = text.splitlines()
for idx, line in enumerate(lines):
if ALIASES_LINE.match(line.strip()):
if idx + 1 < len(lines):
names = [n.strip() for n in lines[idx + 1].split(",")]
return {n for n in names if WORDISH.match(n)}
break
return set()
def parse_flags(text):
flags = set()
for m in LONG_FLAG.finditer(text):
flags.add(m.group(0))
return flags
def build_tree(ao, max_depth=3):
valid_paths = set()
flags_by_path = {}
flags_by_path[()] = parse_flags(help_text(ao, []))
frontier = [(name,) for name in sorted(capabilities_top_level(ao))]
for p in frontier:
valid_paths.add(p)
depth = 1
while frontier and depth <= max_depth:
nxt = []
for path in frontier:
text = help_text(ao, list(path))
cmd_flags = parse_flags(text)
flags_by_path[path] = cmd_flags
# Register command aliases as alternate leaf paths sharing flags so
# `ao quickstart` (alias of `quick-start`) resolves like the canonical
# name. Aliases are leaves — we don't re-walk their subtree.
for alias in parse_aliases(text):
alias_path = path[:-1] + (alias,)
if alias_path not in valid_paths:
valid_paths.add(alias_path)
flags_by_path.setdefault(alias_path, cmd_flags)
for sub in parse_subcommands(text):
child = path + (sub,)
if child not in valid_paths:
valid_paths.add(child)
nxt.append(child)
frontier = nxt
depth += 1
return valid_paths, flags_by_path
valid_paths, flags_by_path = build_tree(ao_bin)
def resolve_command(cmd_tokens):
"""Longest valid command-path prefix of cmd_tokens, or None."""
for end in range(len(cmd_tokens), 0, -1):
path = tuple(cmd_tokens[:end])
if path in valid_paths:
return path
return None
def line_is_historical(line):
low = line.lower()
return "(historical)" in low or any(m in low for m in HISTORICAL_MARKERS)
def file_is_historical(text):
head = "\n".join(text.splitlines()[:40]).lower()
return "(historical)" in head or any(m in head for m in HISTORICAL_MARKERS)
# Within an inline `code span`, parse the `ao <cmd...> [--flag]` shape.
INLINE_AO = re.compile(
r"\bao\s+"
r"(?P<cmd>[a-z][a-z0-9-]*(?:\s+[a-z][a-z0-9-]*)*)"
r"(?P<rest>\s+--[a-z][a-z0-9-]+.*)?"
)
INLINE_SPAN = re.compile(r"`([^`\n]+)`")
def iter_inline_ao_refs(line):
"""Yield (command_tokens, flag_or_None) for each `ao ...` inline-code span."""
for span in INLINE_SPAN.finditer(line):
content = span.group(1).strip()
m = INLINE_AO.match(content)
if not m:
continue
cmd_tokens = m.group("cmd").split()
if not cmd_tokens:
continue
# First long flag after the command, if any.
flag = None
rest = m.group("rest")
if rest:
fm = re.search(r"(--[a-z][a-z0-9-]+)", rest)
if fm:
flag = fm.group(1)
yield cmd_tokens, flag
failures = []
for root in roots:
if not root.is_dir():
continue
targets = []
for path in sorted(root.rglob("*")):
if not path.is_file() or path.suffix != ".md":
continue
if path.name == "SKILL.md" or "references" in path.parts:
targets.append(path)
for path in targets:
try:
rel = str(path.relative_to(repo_root))
except ValueError:
# Fixture root outside repo_root (bats): fall back to absolute path
# for reporting; the allowlist is repo-relative so it won't match.
rel = str(path)
if rel in allowlist:
continue
try:
text = path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
continue
if file_is_historical(text):
continue
for lineno, line in enumerate(text.splitlines(), start=1):
if "ao " not in line:
continue
if line_is_historical(line):
continue
for cmd_tokens, flag in iter_inline_ao_refs(line):
resolved = resolve_command(cmd_tokens)
if resolved is None:
failures.append(
f"{rel}:{lineno}: unknown ao command `ao {' '.join(cmd_tokens)}`"
)
continue
if flag:
known = flags_by_path.get(resolved, set())
global_flags = flags_by_path.get((), set())
if flag not in known and flag not in global_flags:
failures.append(
f"{rel}:{lineno}: flag {flag} not found in help for "
f"`ao {' '.join(resolved)}`"
)
if failures:
print("Skill BODY command-ref validation FAILED:", file=sys.stderr)
seen = set()
shown = 0
for failure in failures:
if failure in seen:
continue
seen.add(failure)
print(f" {failure}", file=sys.stderr)
shown += 1
if shown >= 100:
remaining = len(set(failures)) - shown
if remaining > 0:
print(f" ... and {remaining} more", file=sys.stderr)
break
print("", file=sys.stderr)
print(
"These `ao <command>`/`ao <command> --flag` inline-code prose refs do "
"not resolve against the live CLI help tree.",
file=sys.stderr,
)
print(
"Fix the ref, or — if it is a justified migration record — add an "
"explicit historical marker (Superseded / Historical / removed in / "
"retained for history / (historical)) on the line/file, or list the "
"file in scripts/skill-body-refs-allowlist.txt.",
file=sys.stderr,
)
sys.exit(1)
print("Skill BODY command-ref validation passed.")
PY