Files
boshu2__agentops/scripts/audit-skill-metadata.sh
T
Bo 94fd8eafea feat(corpus): advisory skill-metadata resolution audit — context_rel.with (ag-f0i #context-rel-resolution) (#565)
## Summary

Adds `scripts/audit-skill-metadata.sh` — an **advisory** auditor that
checks every non-empty `context_rel[].with` in `skills/*/SKILL.md`
resolves to an existing skill directory. The `skill-frontmatter.v2`
schema declares `context_rel.with` as a *skill slug*; the existing
`validate-skill-frontmatter.sh` checks presence/shape only — nothing
checked resolution. This closes that gap.

- Advisory by default (exit 0, logs findings); `--strict` exits
non-zero; `--json` emits a verdict naming `file` + `field` + `value`.
- Reads the skill universe from `--skills-root` /
`$SKILL_METADATA_SKILLS_ROOT` (default `skills/`).
- `tests/scripts/audit-skill-metadata.bats` (9 tests, tmp-generated
fixtures so no committed `SKILL.md` is seen by repo-wide scanners).

## Scope (ag-f0i, advisory slice)

The bead's original premise was partly stale:
`skills/domain/references/ubiquitous-language.md` does not exist, and
`practices`/`produces` canonicality would require a canonical registry
that does not yet exist (inventing one is out of scope per PROGRAM.md
"prefer existing patterns over new policy surfaces"). This PR ships the
**one well-defined, non-inventing resolution check** —
`context_rel.with` → existing skill — and splits the rest into
follow-ups.

`consumes[]` is intentionally **not** failed: per schema it may be an
artifact-kind *or* a skill slug (open vocabulary).

## Drift found (advisory, not gating this PR)

Live run: 75 skills checked, **5 finite findings**, all in
`session-bootstrap` (`context_rel.with` → `AGENTS.md` /
`AGENTS-WORKFLOW.md` / `AGENTS-CI.md` / `AGENTS-CODEX.md` /
`AGENTS-RUNTIME.md`, i.e. doc files rather than peer skills). Filed as a
backfill follow-up. Gate stays advisory until the backfill lands.

## Follow-ups (discovered-from ag-f0i)

- Backfill `session-bootstrap` context_rel.with edges (5 finite
violations).
- Promote `audit-skill-metadata.sh --strict` to a required CI gate after
backfill.
- Stand up a canonical `practices` registry, then extend the auditor to
validate practices canonicality (+ `produces` resolution).

## Test plan

- [x] `bats tests/scripts/audit-skill-metadata.bats` — 9/9 pass
- [x] `shellcheck scripts/audit-skill-metadata.sh` — clean
- [x] advisory run vs live tree: 5 finite findings, exit 0; `--strict`
exit 1; `--json` verdict correct
- [x] `heal.sh --strict` — clean
- [x] No Go changed (build/test unaffected)

Closes-scenario: ag-f0i#context-rel-resolution
Bounded-context: BC1-Corpus
Evidence: bats tests/scripts/audit-skill-metadata.bats (9/9); shellcheck
clean; advisory run = 5 finite findings
2026-05-28 01:55:18 -04:00

172 lines
6.1 KiB
Bash
Executable File

#!/usr/bin/env bash
# audit-skill-metadata.sh — Audit skill-frontmatter *resolution* across every
# skills/<name>/SKILL.md.
#
# The skill-frontmatter.v2 schema declares `context_rel[].with` as a *skill
# slug*. The existing validate-skill-frontmatter.sh checks presence and schema
# shape, but nothing checks that each `with:` value actually resolves to a peer
# skill directory. This auditor closes that resolution gap: every non-empty
# `context_rel.with` MUST name an existing skill under the skills root.
#
# Scope note (ag-f0i, advisory slice): only the well-defined, non-inventing
# resolution check ships here — `context_rel.with` -> existing skill dir.
# `consumes` may be an artifact-kind OR a skill slug (open vocabulary), and
# `practices`/`produces` canonicality require a canonical registry that does not
# yet exist; those are tracked as discovered follow-ups, not enforced here.
#
# Usage:
# bash scripts/audit-skill-metadata.sh [--strict] [--json] [--skills-root DIR]
#
# --strict exit non-zero when any finding exists (default: advisory, exit 0)
# --json emit a machine-readable verdict on stdout (stdout = data only)
# --skills-root directory holding skills/<name>/SKILL.md (default: <repo>/skills,
# or $SKILL_METADATA_SKILLS_ROOT when set)
# -h, --help show this help
#
# Exit codes: 0 = clean (or advisory), 1 = findings in --strict, 2 = usage error.
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
STRICT=0
JSON=0
SKILLS_ROOT="${SKILL_METADATA_SKILLS_ROOT:-$ROOT/skills}"
usage() {
cat <<'USAGE'
audit-skill-metadata.sh — audit skill-frontmatter resolution across all SKILL.md.
Checks that every non-empty context_rel.with value resolves to an existing skill
directory (the skill-frontmatter.v2 schema declares context_rel.with as a skill
slug). Presence/shape stay owned by validate-skill-frontmatter.sh.
Usage:
bash scripts/audit-skill-metadata.sh [--strict] [--json] [--skills-root DIR]
--strict exit non-zero when any finding exists (default: advisory, exit 0)
--json emit a machine-readable verdict on stdout (stdout = data only)
--skills-root directory holding skills/<name>/SKILL.md
(default: <repo>/skills, or $SKILL_METADATA_SKILLS_ROOT)
-h, --help show this help
Exit codes: 0 = clean (or advisory), 1 = findings in --strict, 2 = usage error.
USAGE
}
while [[ $# -gt 0 ]]; do
case "$1" in
--strict) STRICT=1; shift ;;
--json) JSON=1; shift ;;
--skills-root) SKILLS_ROOT="${2:-}"; shift 2 ;;
-h|--help) usage; exit 0 ;;
--*) echo "ERROR: unknown flag: $1 (try: bash scripts/audit-skill-metadata.sh --help)" >&2; exit 2 ;;
*) echo "ERROR: unexpected argument: $1 (try: bash scripts/audit-skill-metadata.sh --help)" >&2; exit 2 ;;
esac
done
if [[ ! -d "$SKILLS_ROOT" ]]; then
echo "ERROR: skills root not found: $SKILLS_ROOT" >&2
exit 1
fi
if ! command -v python3 >/dev/null 2>&1; then
echo "ERROR: python3 is required" >&2
exit 1
fi
if ! python3 -c "import yaml" >/dev/null 2>&1; then
echo "ERROR: python yaml missing (need PyYAML). Try: pip install pyyaml" >&2
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
echo "ERROR: jq is required" >&2
exit 1
fi
# Parse every SKILL.md frontmatter and emit findings as a JSON object:
# {"checked_skills": N, "findings": [{file, field, value, message}, ...]}
REPORT="$(
python3 - "$SKILLS_ROOT" <<'PY'
import glob
import json
import os
import sys
import yaml
skills_root = sys.argv[1]
skill_files = sorted(glob.glob(os.path.join(skills_root, "*", "SKILL.md")))
slugs = {os.path.basename(os.path.dirname(p)) for p in skill_files}
findings = []
for path in skill_files:
rel = os.path.relpath(path, skills_root)
with open(path, "r", encoding="utf-8") as fh:
raw = fh.read()
if not raw.startswith("---"):
continue
parts = raw.split("---", 2)
if len(parts) < 3:
continue
try:
fm = yaml.safe_load(parts[1])
except yaml.YAMLError:
# Frontmatter shape errors are owned by validate-skill-frontmatter.sh.
continue
if not isinstance(fm, dict):
continue
rels = fm.get("context_rel")
if not isinstance(rels, list):
continue
for entry in rels:
if not isinstance(entry, dict):
continue
target = entry.get("with")
if not isinstance(target, str) or target == "":
# Missing/empty `with` is a schema-shape concern, not resolution.
continue
if target not in slugs:
findings.append({
"file": rel,
"field": "context_rel.with",
"value": target,
"message": (
"context_rel.with '%s' does not resolve to a skill under %s"
% (target, skills_root)
),
})
print(json.dumps({"checked_skills": len(skill_files), "findings": findings}))
PY
)"
rc=$?
if [[ $rc -ne 0 ]]; then
echo "ERROR: failed to parse skill frontmatter (python exited $rc)" >&2
exit 1
fi
CHECKED="$(jq -r '.checked_skills' <<<"$REPORT")"
NUM_FINDINGS="$(jq -r '.findings | length' <<<"$REPORT")"
if [[ "$JSON" -eq 1 ]]; then
valid="true"
[[ "$NUM_FINDINGS" -gt 0 ]] && valid="false"
jq --argjson valid "$valid" --arg root "$SKILLS_ROOT" \
'{valid: $valid, skills_root: $root, checked_skills: .checked_skills, findings: .findings}' \
<<<"$REPORT"
else
if [[ "$NUM_FINDINGS" -gt 0 ]]; then
while IFS=$'\t' read -r file value; do
echo "FINDING ${file}: context_rel.with -> '${value}' does not resolve to a skill under ${SKILLS_ROOT}"
done < <(jq -r '.findings[] | [.file, .value] | @tsv' <<<"$REPORT")
fi
echo "audit-skill-metadata: ${CHECKED} skill(s) checked, ${NUM_FINDINGS} unresolved context_rel.with edge(s)"
if [[ "$NUM_FINDINGS" -gt 0 ]]; then
echo "fix: point each context_rel.with at an existing skill slug, or remove the edge" >&2
fi
fi
if [[ "$NUM_FINDINGS" -gt 0 && "$STRICT" -eq 1 ]]; then
exit 1
fi
exit 0