Files
holocron/plugins/kyberforge/.apm/skills/factory-audit/tests/validate-provenance-agent.bats
Defame1297 620f20b0fd refactor(kyberforge)!: merge skill-audit and agent-audit into factory-audit
Why

The two audit skills carried 1,724 lines of byte-identical duplication: the ADR-0020 boundary
resolver (1,061), vale-wrap.sh (526), the Vale style rules (44) and the Contributing-files parser
(93). Nothing shared them — they were held in sync by a 413-line pre-push gate and its 797-line
test suite. Sync-by-gate had already failed once: at 484357a the two parser copies drifted into
different spellings of the bullet loop while a docstring asserted they were identical. That drift
was behaviour-neutral and was re-unified by hand at 598a7c3, so the copies were identical at merge
time — but nothing had caught it, and the next drift need not be neutral.

Implementation Notes

Self-containment binds BETWEEN skills, not within one. The agentskills.io spec forbids reaching
across skill directories, which is why two separate skills needed embedded copies; two files inside
ONE skill may source a third. That is the whole reason the merge removes duplication rather than
relocating it.

The union of both bodies measured 1,532 words against BODY_MAX_WORDS=900, and only 211 of those
words were shared, so SKILL.md is a dispatch body. Step 0 resolves the flow from the target path
before any validation, and its table mirrors validate.sh's detection exactly: a directory holding
SKILL.md or a SKILL.md file (skill); a *.agent.md, or a .md directly under an agents/ directory
(agent); anything else stops without running a validator. Steps 1-3 live in
references/skill-flow.md and references/agent-flow.md, and gotchas that apply to one flow live in
that flow's file, since it is loaded on every invocation anyway. If validate.sh reports on the
other artifact type, the body restarts at Step 0.

Named factory-audit rather than forge-audit because forge is a live skill, and a family prefix that
matches a live sibling reads as ownership rather than membership.

The description carries one arrow per boundary target, because ADR-0020 resolves only the first
target after an arrow. It drops the quoted "audit this skill"-style phrases, which restated
"audited" in a second register (ADR-0020's duplicate-register rule). 241 characters, Gotchas 16%
of the body: no size SUGGESTIONs.

The boundary resolver stays embedded in two files rather than imported: a cache-installed plugin
cannot read outside its own directory, and the repo-root hook resolves via .pre-commit-hooks.yaml
where entry[0] is the only token pre-commit rewrites, so no single file is reachable by both.
tests/test-adr0020-contract.sh hashes both copies for byte-identity, and asserts validate.sh sources
the resolver and that no third copy exists.

The entry scripts classify the target from its resolved parent directory, so a bare agent filename
typed inside agents/ works; resolve SCRIPT_DIR CDPATH-safely; and exit 2 when a lib-*.sh is
missing, rather than dying with exit 1, the tier the flows relay as real findings.

The provenance run functions stash their findings code in KYBERFORGE_PROV_RC and
return 0, so validate-provenance.sh calls them UNTESTED. Testing a function's
status (`f || RC=$?`) disables errexit for its entire body, and no subshell or
`set -e` inside can re-arm it once the call sits in a condition context
(measured, both spellings). Their error paths use `exit`, which is unaffected
either way; this keeps errexit armed for anything added later.

Case 0's readability guard reads the file instead of asking `[[ -r ]]`. `-r` is
access(2), which answers yes for uid 0 even on a mode-000 file, and this repo's
dev environment is root -- so the guard could never fire where it exists to fire.
A read attempt is also the stricter question, catching EIO. This is the reasoning
scripts/check-vale-style-sync.sh carried before this commit deleted it; the
hazard did not go with it.

All three entry scripts are CDPATH-safe, vale-wrap.sh included: both of its cd sites are cleared,
the --config resolution and the directory-mirror walk, where an exported CDPATH would otherwise
print a decoy path into the -print0 stream and build the mirror from the decoy's files. The two
remaining bare cd calls take absolute paths, which CDPATH is never consulted for.

Impact

BREAKING: skill-audit and agent-audit no longer exist as invocable skills. kyberforge goes to
2.0.0 (catalog 0.4.7).

Check logic is unchanged: differential runs of the old and new validators across every skill and
agent produced byte-identical stdout, stderr and exit codes, and the reconstructed Python payloads
differ only in comments and the references/field-inventory.md -> agent-field-inventory.md rename.
One doctrine governs the tiers: exit 0 is audited and clean, exit 1 is audited with findings OR a
target present but unreadable, exit 2 is that nothing was audited at all. Edge paths DID change,
deliberately (full table in ADR-0025):
- a missing target exits 2 (never ran), not 1, under its own "does not exist" message; detection is
  by path shape, so a shape-matching path that is simply absent used to reach the validator and come
  back as a FAIL against a file that never existed;
- an unshaped target exits 2 under the generic "matches neither" message, and a directory with no
  SKILL.md under a third, distinct one -- three exit-2 messages, not one;
- a dangling symlink or a symlink loop stays exit 1: it is present but broken, which is a finding
  about the artifact rather than a usage error;
- a SKILL.md file path is audited as its skill directory instead of refused;
- a .md agent outside an agents/ directory is refused rather than audited;
- a missing script library, a missing python3, a missing PyYAML, and no argument at all each exit 2.
  validate-provenance.sh already exited 2 for the last two; validate.sh now matches it.

.pre-commit-hooks.yaml is a published contract consumed by external repos. Both hook IDs and both
files: regexes are unchanged; only entry: and description: moved.

scripts/check-vale-style-sync.sh (413), scripts/sync-vale-styles.sh (21),
tests/test-check-vale-style-sync.sh (797) and agent-audit/scripts/README.md (47) are deleted. The
checker made 17 assertions: 6 compared the two Vale copies and are moot; 10 are rehomed into
tests/test-vale-wrap.sh (case 0, cases 28-31, and the suite's Vale-absent skip); and the
cross-manifest files: agreement check, which selected hooks by entry: and so could not survive both
hooks sharing one, is ported as case 33 pairing hooks by id:. Cases 28, 30 and 33 carry mutation
self-tests; narrowing the local skill prefilter to 6 of 38 SKILL.md files now fails the suite.

Skills go 39 to 38. Pre-push goes 9 repo-authored hooks to 8.

ADR: 0025
BREAKING-CHANGE: the skill-audit and agent-audit skills are removed. Both flows are served by
  factory-audit, which auto-detects whether it was handed a skill directory or an agent file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YR2CjVumUbEGWcMikcoXBD
2026-09-16 09:13:57 +00:00

1148 lines
42 KiB
Bash

#!/usr/bin/env bats
setup() {
REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../../../../" && pwd)"
load "$REPO_ROOT/tests/test_helper/bats-support/load"
load "$REPO_ROOT/tests/test_helper/bats-assert/load"
SCRIPT="$(cd "$BATS_TEST_DIRNAME/../scripts" && pwd)/validate-provenance.sh"
TMPDIR="$(mktemp -d)"
# Helper: create an APM package root at <root> (apm.yml with a top-level
# type: line — a real package manifest, not marketplace-only) plus a
# single vendor-neutral agent file at <root>/.apm/agents/<name>.agent.md.
make_package() {
local root="$1"
mkdir -p "$root/.apm/agents"
cat > "$root/apm.yml" <<EOF
name: test-package
version: 0.1.0
type: skill
EOF
}
# Helper: create a clean agent file (no source_keys)
make_clean_agent() {
local root="$1"
local name="${2:-my-agent}"
cat > "$root/.apm/agents/${name}.agent.md" <<EOF
---
name: ${name}
description: A valid agent description.
---
You are a test agent.
EOF
}
# Helper: create an agent file with source_keys
make_agent_with_source_keys() {
local root="$1"
local name="${2:-my-agent}"
local slug="${3:-my-source}"
cat > "$root/.apm/agents/${name}.agent.md" <<EOF
---
name: ${name}
description: A valid agent description.
source_keys:
- ${slug}
---
You are a test agent.
EOF
}
# Helper: create a valid sources.md with one entry
make_sources_md() {
local root="$1"
local slug="${2:-my-source}"
local contrib="${3:-.apm/agents/my-agent.agent.md}"
local research="${4:-(none)}"
cat > "$root/sources.md" <<EOF
# Sources
## ${slug}
- **URL:** https://example.com/${slug}
- **Description:** A test source.
- **Contributing files:** ${contrib}
- **Research doc:** ${research}
- **Status:** \`extracted\`
EOF
}
}
teardown() {
rm -rf "$TMPDIR"
}
# ---------------------------------------------------------------------------
# --help
# ---------------------------------------------------------------------------
@test "--help exits 0" {
run bash "$SCRIPT" --help
assert_success
assert_output --partial "Usage:"
}
# ---------------------------------------------------------------------------
# Non-plugin/APM scope → exit 0 silently
# ---------------------------------------------------------------------------
@test "non-plugin scope: no apm.yml in tree → exit 0, no output" {
local dir="$TMPDIR/no-package"
mkdir -p "$dir/.apm/agents"
cat > "$dir/.apm/agents/my-agent.agent.md" <<EOF
---
name: my-agent
description: A valid agent description.
source_keys:
- my-source
---
You are a test agent.
EOF
run bash "$SCRIPT" "$dir/.apm/agents/my-agent.agent.md"
assert_success
assert_output ""
}
@test "non-plugin scope: apm.yml present but type:-less (marketplace-only) → exit 0, no output" {
local dir="$TMPDIR/marketplace-only"
mkdir -p "$dir/.apm/agents"
cat > "$dir/apm.yml" <<EOF
name: root-marketplace
marketplace:
owner: someone
packages:
- ./packages/plugin-a
EOF
cat > "$dir/.apm/agents/my-agent.agent.md" <<EOF
---
name: my-agent
description: A valid agent description.
source_keys:
- my-source
---
You are a test agent.
EOF
run bash "$SCRIPT" "$dir/.apm/agents/my-agent.agent.md"
assert_success
assert_output ""
}
@test "non-plugin scope: bare plugin.json (no apm.yml) is no longer a scope signal → exit 0, no output" {
local dir="$TMPDIR/old-plugin-json-only"
mkdir -p "$dir/agents"
echo '{"name":"test-plugin","version":"0.1.0"}' > "$dir/plugin.json"
cat > "$dir/agents/my-agent.md" <<EOF
---
name: my-agent
description: A valid agent description.
source_keys:
- my-source
---
You are a test agent.
EOF
run bash "$SCRIPT" "$dir/agents/my-agent.md"
assert_success
assert_output ""
}
@test "non-plugin scope: walk-up stops at .git boundary before reaching an ancestor apm.yml" {
local dir="$TMPDIR/repo"
mkdir -p "$dir/.git" "$dir/.apm/agents"
cat > "$dir/apm.yml" <<EOF
name: test-package
version: 0.1.0
type: skill
EOF
mkdir -p "$dir/sub/.apm/agents"
cat > "$dir/sub/.apm/agents/my-agent.agent.md" <<EOF
---
name: my-agent
description: A valid agent description.
source_keys:
- my-source
---
You are a test agent.
EOF
# sub/ has no .git and no apm.yml of its own; the real package apm.yml
# lives at $dir, but $dir/.git means the walk from sub/ should stop at
# sub/ itself only if sub/ had a .git — here .git is at $dir, ABOVE
# sub/, so the walk from sub/ reaches $dir/apm.yml before any .git.
# This test instead verifies the walk finds that package root correctly
# (a positive case) — see the dedicated .git-stops-first test below for
# the negative case.
run bash "$SCRIPT" "$dir/sub/.apm/agents/my-agent.agent.md"
assert_failure
assert_output --partial "FAIL"
}
@test "non-plugin scope: \$HOME boundary stops the walk before reaching an ancestor apm.yml above \$HOME" {
# A type-bearing apm.yml sits ABOVE the fake $HOME — if find_plugin_root
# didn't stop at $HOME, it would walk past it and misclassify this
# user/project-scope file as plugin scope, which would then FAIL on
# Check 0 (source_keys declared but sources.md absent) since sources.md
# doesn't exist at that ancestor apm.yml's location either.
local dir="$TMPDIR/anc"
mkdir -p "$dir"
cat > "$dir/apm.yml" <<EOF
name: outer-package
version: 0.1.0
type: skill
EOF
local fake_home="$dir/fakehome"
mkdir -p "$fake_home/.apm/agents"
cat > "$fake_home/.apm/agents/my-agent.agent.md" <<EOF
---
name: my-agent
description: A valid agent description.
source_keys:
- my-source
---
You are a test agent.
EOF
run env HOME="$fake_home" bash "$SCRIPT" "$fake_home/.apm/agents/my-agent.agent.md"
assert_success
assert_output ""
}
@test "non-plugin scope: .git between the agent file and an ancestor apm.yml stops the walk first" {
local dir="$TMPDIR/repo2"
mkdir -p "$dir/.apm/agents"
cat > "$dir/apm.yml" <<EOF
name: test-package
version: 0.1.0
type: skill
EOF
mkdir -p "$dir/sub/.git" "$dir/sub/.apm/agents"
cat > "$dir/sub/.apm/agents/my-agent.agent.md" <<EOF
---
name: my-agent
description: A valid agent description.
source_keys:
- my-source
---
You are a test agent.
EOF
run bash "$SCRIPT" "$dir/sub/.apm/agents/my-agent.agent.md"
assert_success
assert_output ""
}
# ---------------------------------------------------------------------------
# Early exit: no sources.md, no source_keys → exit 0, no output
# ---------------------------------------------------------------------------
@test "clean pass: no sources.md and no source_keys → exit 0, no output" {
local root="$TMPDIR/package"
make_package "$root"
make_clean_agent "$root"
run bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md"
assert_success
assert_output ""
}
# ---------------------------------------------------------------------------
# Check 0: source_keys present but sources.md absent → FAIL
# ---------------------------------------------------------------------------
@test "FAIL: source_keys in agent file but sources.md absent" {
local root="$TMPDIR/package"
make_package "$root"
make_agent_with_source_keys "$root"
run bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md"
assert_failure
assert_output --partial "FAIL"
}
# ---------------------------------------------------------------------------
# Check 1: FILL IN: placeholder in sources.md → FAIL
# ---------------------------------------------------------------------------
@test "FAIL: FILL IN: placeholder in sources.md" {
local root="$TMPDIR/package"
make_package "$root"
make_agent_with_source_keys "$root"
cat > "$root/sources.md" <<EOF
# Sources
## my-source
- **URL:** FILL IN: add url
- **Description:** A test source.
- **Contributing files:** .apm/agents/my-agent.agent.md
- **Research doc:** (none)
- **Status:** \`extracted\`
EOF
run bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md"
assert_failure
assert_output --partial "FAIL"
}
@test "FILL IN: inside backticks in sources.md does not fail" {
local root="$TMPDIR/package"
make_package "$root"
make_agent_with_source_keys "$root"
make_sources_md "$root"
echo "Use \`FILL IN: value\` as example." >> "$root/sources.md"
run bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md"
assert_success
}
# ---------------------------------------------------------------------------
# Check 2: source_keys slug missing from sources.md → FAIL
# ---------------------------------------------------------------------------
@test "FAIL: source_keys slug in agent file not present as H2 in sources.md" {
local root="$TMPDIR/package"
make_package "$root"
make_agent_with_source_keys "$root" "my-agent" "my-source"
make_sources_md "$root" "different-source" "(none)" "(none)"
run bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md"
assert_failure
assert_output --partial "FAIL"
}
# ---------------------------------------------------------------------------
# Check 3: Contributing file path doesn't exist → FAIL
# ---------------------------------------------------------------------------
@test "FAIL: Contributing file listed in sources.md does not exist" {
local root="$TMPDIR/package"
make_package "$root"
make_agent_with_source_keys "$root"
make_sources_md "$root" "my-source" ".apm/agents/nonexistent.agent.md"
run bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md"
assert_failure
assert_output --partial "FAIL"
}
@test "pass: (none) in Contributing files is skipped" {
local root="$TMPDIR/package"
make_package "$root"
make_agent_with_source_keys "$root"
make_sources_md "$root" "my-source" "(none — not used directly)"
run bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md"
assert_success
}
# ---------------------------------------------------------------------------
# Check 5: Research doc field missing or placeholder → FAIL
# ---------------------------------------------------------------------------
@test "FAIL: Research doc field missing from sources.md entry" {
local root="$TMPDIR/package"
make_package "$root"
make_agent_with_source_keys "$root"
cat > "$root/sources.md" <<EOF
# Sources
## my-source
- **URL:** https://example.com/my-source
- **Description:** A test source.
- **Contributing files:** .apm/agents/my-agent.agent.md
- **Status:** \`extracted\`
EOF
run bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md"
assert_failure
assert_output --partial "FAIL"
}
@test "FAIL: Research doc field is FILL IN: placeholder" {
local root="$TMPDIR/package"
make_package "$root"
make_agent_with_source_keys "$root"
cat > "$root/sources.md" <<EOF
# Sources
## my-source
- **URL:** https://example.com/my-source
- **Description:** A test source.
- **Contributing files:** .apm/agents/my-agent.agent.md
- **Research doc:** FILL IN: path to research doc
- **Status:** \`extracted\`
EOF
run bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md"
assert_failure
assert_output --partial "FAIL"
}
# ---------------------------------------------------------------------------
# Check 4: Bidirectional — contributing file missing slug in source_keys → FAIL
# ---------------------------------------------------------------------------
@test "FAIL: Contributing file exists but does not list parent slug in source_keys" {
local root="$TMPDIR/package"
make_package "$root"
# agent file has source_keys: other-source (not my-source)
cat > "$root/.apm/agents/my-agent.agent.md" <<EOF
---
name: my-agent
description: A valid agent description.
source_keys:
- other-source
---
You are a test agent.
EOF
# sources.md says my-agent.agent.md contributed to my-source, but
# my-agent.agent.md doesn't list my-source
cat > "$root/sources.md" <<EOF
# Sources
## other-source
- **URL:** https://example.com/other-source
- **Description:** A test source.
- **Contributing files:** .apm/agents/my-agent.agent.md
- **Research doc:** (none)
- **Status:** \`extracted\`
## my-source
- **URL:** https://example.com/my-source
- **Description:** Another source.
- **Contributing files:** .apm/agents/my-agent.agent.md
- **Research doc:** (none)
- **Status:** \`extracted\`
EOF
run bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md"
assert_failure
assert_output --partial "FAIL"
}
# ---------------------------------------------------------------------------
# Clean full pass
# ---------------------------------------------------------------------------
@test "clean full pass: all checks satisfied" {
local root="$TMPDIR/package"
make_package "$root"
make_agent_with_source_keys "$root"
make_sources_md "$root"
run bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md"
assert_success
}
# ---------------------------------------------------------------------------
# Checks 3 and 4: None ("could not parse") is NOT [] ("explicitly (none)")
#
# parse_contributing_files returns three distinguishable answers and checks 3
# and 4 have to honour all three. `[]` is the author writing "(none)" — the
# skip is correct and silent. None is a Contributing files block the parser
# cannot read, and skipping THAT silently disables both checks on the one entry
# least likely to be right, which is the failure mode the parser's own
# docstring warns about. The assertions below are therefore about the INFO
# appearing; a silent exit 0 is exactly the bug.
# ---------------------------------------------------------------------------
@test "INFO: an unparsable Contributing files block names the slug instead of skipping checks 3 and 4 silently" {
local root="$TMPDIR/package"
make_package "$root"
make_agent_with_source_keys "$root"
cat > "$root/sources.md" <<EOF
# Sources
## my-source
- **URL:** https://example.com/my-source
- **Description:** A test source.
- **Research doc:** (none)
**Contributing files:**
* .apm/agents/ghost.agent.md (asterisk bullets are not the bullet form)
- **Status:** \`extracted\`
EOF
run bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md"
assert_success
assert_output --partial "INFO"
assert_output --partial "Contributing-file checks skipped for 'my-source' — the Contributing files block could not be parsed"
}
@test "INFO: an entry with no Contributing files field at all is reported, not skipped silently" {
local root="$TMPDIR/package"
make_package "$root"
make_agent_with_source_keys "$root"
cat > "$root/sources.md" <<EOF
# Sources
## my-source
- **URL:** https://example.com/my-source
- **Description:** A test source.
- **Research doc:** (none)
- **Status:** \`extracted\`
EOF
run bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md"
assert_success
assert_output --partial "INFO"
assert_output --partial "Contributing-file checks skipped for 'my-source' — the Contributing files block could not be parsed"
}
@test "checks 3 and 4 skipped silently: an explicit '(none)' emits no INFO" {
local root="$TMPDIR/package"
make_package "$root"
make_agent_with_source_keys "$root"
make_sources_md "$root" "my-source" "(none — not used directly)"
run bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md"
assert_success
assert_output ""
}
@test "checks 3 and 4 still run: a parseable Contributing files list is not diverted to the INFO" {
local root="$TMPDIR/package"
make_package "$root"
make_agent_with_source_keys "$root"
make_sources_md "$root" "my-source" ".apm/agents/nonexistent.agent.md"
run bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md"
assert_failure
assert_output --partial "FAIL"
assert_output --partial "Contributing file '.apm/agents/nonexistent.agent.md' does not exist"
refute_output --partial "could not be parsed"
}
# ---------------------------------------------------------------------------
# The INFO tier itself: kind-aware printing and a kind-aware exit code
#
# INFO is new here — before it, findings was a 5-tuple and print_findings
# stamped every entry FAIL. The two cases below pin the tier rather than any
# one check: an INFO must print under the INFO prefix and leave the exit code
# at 0, and a real FAIL must keep printing under the FAIL prefix and still exit
# non-zero even when an INFO is sitting in the same findings list.
# ---------------------------------------------------------------------------
@test "INFO tier: an INFO alone prints as INFO with a Note and does not set a failing exit code" {
local root="$TMPDIR/package"
make_package "$root"
make_agent_with_source_keys "$root"
cat > "$root/sources.md" <<EOF
# Sources
## my-source
- **URL:** https://example.com/my-source
- **Description:** A test source.
- **Research doc:** (none)
- **Status:** \`extracted\`
EOF
run bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md"
assert_success
assert_output --partial "INFO Contributing-file checks skipped for 'my-source'"
assert_output --partial "Note:"
refute_output --partial "FAIL"
}
@test "FAIL tier: a genuine FAIL alongside an INFO still prints as FAIL and exits non-zero" {
local root="$TMPDIR/package"
make_package "$root"
make_agent_with_source_keys "$root"
cat > "$root/sources.md" <<EOF
# Sources
## my-source
- **URL:** https://example.com/my-source
- **Description:** A test source.
- **Contributing files:** .apm/agents/nonexistent.agent.md
- **Research doc:** (none)
- **Status:** \`extracted\`
## ghost-source
- **URL:** https://example.com/ghost-source
- **Description:** Another test source.
- **Research doc:** (none)
- **Status:** \`extracted\`
EOF
run bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md"
assert_failure
assert_output --partial "FAIL Contributing file '.apm/agents/nonexistent.agent.md' does not exist"
assert_output --partial "Why:"
assert_output --partial "INFO Contributing-file checks skipped for 'ghost-source'"
assert_output --partial "Note:"
}
# ---------------------------------------------------------------------------
# The exit-2 tier, and its boundary with the silent exit 0
#
# Ported from the skill-audit sibling, which had already split usage and
# environment errors (exit 2) away from findings (exit 1). SKILL.md tells the
# auditor to surface a non-zero exit, so a usage error leaving exit 1 with
# nothing on stdout was indistinguishable from a clean-but-failing run.
#
# The reconciliation this script needs and the sibling does not: "the walk-up
# found no type:-bearing apm.yml" is NOT bad input. It is a verdict about a
# real, readable agent file — user or project scope, where plugin-scope
# provenance does not apply — and scripts/check-scope-walkup-sync.sh fixture 6
# pins it as exit 0 with empty output. Every exit-2 gate is therefore decided
# from the ARGUMENT ALONE, before the walk-up runs, so the two can never
# collide. The two tests at the end of this block assert both halves.
#
# PORT NOTE RESOLVED (factory-audit merge): two cases in this block asserted
# wording that the skill suite asserted differently for the same class of input
# — "agent-file is required" against its "skill-dir is required", and "no such
# file" against its "not a directory". Each pre-merge script knew what shape it
# was owed; the merged one classifies before it complains. How each pair landed:
#
# * no argument — NEITHER old wording survives. One entry point takes both
# shapes, so it names both: "a skill directory or an agent file is
# required." Both suites now assert that one sentence.
# * nonexistent path — "no such file" SURVIVES here and did not move. A
# nonexistent *.agent.md still classifies as an agent on its name alone, so
# agent mode's own precondition is what rejects it. The skill suite's "not a
# directory" is the wording that moved, because a nonexistent path with no
# agent-shaped name classifies as neither.
#
# Two further cases in this block did move, and neither moved to the other
# suite's wording: a directory handed to this script is now "is a directory with
# no SKILL.md in it" rather than "not a regular file", and an unrecognized
# extension is "matches neither a skill directory nor an agent file" rather than
# "unrecognized extension". Both are classification verdicts now, reached before
# either mode's preconditions run.
#
# The invariant every case in this block pins is unchanged and still pinned at
# every site: exit 2 with an explanatory message on stderr, never a silent exit
# 0 or a bare 1 with no output.
# ---------------------------------------------------------------------------
@test "exit 2: no arguments is a usage error, not a finding" {
run bash "$SCRIPT"
[ "$status" -eq 2 ]
assert_output --partial "Error: a skill directory or an agent file is required."
# The usage block has to follow the error, or "required" names no shape the
# caller can act on.
assert_output --partial "Usage: validate-provenance.sh"
}
@test "exit 2: a second positional argument is rejected instead of silently dropped" {
local root="$TMPDIR/package"
make_package "$root"
make_clean_agent "$root"
run bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md" --some-typo
[ "$status" -eq 2 ]
assert_output --partial "expected exactly one argument"
}
@test "exit 2: a nonexistent path is an error, not a silent pass" {
run bash "$SCRIPT" "$TMPDIR/no-such-agent.agent.md"
[ "$status" -eq 2 ]
assert_output --partial "no such file"
}
@test "exit 2: a directory is not an agent file" {
# Post-merge this is a CLASSIFICATION rejection, not agent mode's old
# "not a regular file" precondition: a directory is half of a skill target,
# so the detector reaches for SKILL.md, does not find one, and says so. The
# thing being pinned is the same — a directory handed to a script that
# audits agent FILES exits 2 with a message, never a silent 0.
local root="$TMPDIR/package"
make_package "$root"
run bash "$SCRIPT" "$root/.apm/agents"
[ "$status" -eq 2 ]
assert_output --partial "Error: '$root/.apm/agents' is a directory with no SKILL.md in it."
}
@test "exit 2: an unrecognized extension is rejected before the walk-up runs" {
# Also a classification rejection now. .txt under .apm/agents/ is neither
# shape: the agents/-parent rule only admits .md, and this is not *.agent.md.
# Rejected on the name alone, so the walk-up still never runs — which is what
# keeps this case from colliding with the silent not-plugin-scope exit 0.
local root="$TMPDIR/package"
make_package "$root"
echo "not an agent" > "$root/.apm/agents/my-agent.txt"
run bash "$SCRIPT" "$root/.apm/agents/my-agent.txt"
[ "$status" -eq 2 ]
assert_output --partial "Error: '$root/.apm/agents/my-agent.txt' matches neither a skill directory nor an agent file."
}
@test "exit 2: a PATH with no python3 names the missing dependency instead of exiting 127" {
local root="$TMPDIR/package"
make_package "$root"
make_clean_agent "$root"
local emptybin="$TMPDIR/emptybin"
mkdir -p "$emptybin"
# dirname and basename are deliberately ABSENT, and that absence is load-
# bearing. The invariant: nothing external is needed to reach the python3
# preflight. The entry point resolves SCRIPT_DIR and classifies the target
# with bash builtins (_kf_dirname, _kf_basename, _kf_parent_name) and the
# mode libraries build their Python bodies with `read` heredocs, so a
# genuinely empty PATH reaches the preflight too. Widening this list to keep
# a test green would silently retire that guarantee: the script would die at
# 127 naming `dirname` instead of the dependency it actually needs, the
# failure tests/test-adr0020-contract.sh assertion 2 exists to prevent. cat
# and sed stay so the stub matches the skill suite's and is a DENY OF python3
# ALONE rather than a test of "no PATH at all"; bash is not among them
# because `env -i` below invokes it by absolute path.
local cmd
for cmd in cat sed; do
ln -s "$(command -v "$cmd")" "$emptybin/$cmd"
done
local bash_bin
bash_bin="$(command -v bash)"
run env -i PATH="$emptybin" HOME="$HOME" "$bash_bin" "$SCRIPT" "$root/.apm/agents/my-agent.agent.md"
[ "$status" -eq 2 ]
# The needle is the DIAGNOSTIC, not the bare word: with no preflight, bash's
# own "python3: command not found" would satisfy a bare-word match.
assert_output --partial "python3 is required"
}
@test "reconciliation: a REAL agent file at non-plugin scope still exits 0 silently, never 2" {
# scripts/check-scope-walkup-sync.sh fixture 6 in miniature. The exit-2 tier
# must not widen to cover "find_plugin_root returned None": the file exists,
# is readable and is correctly named — it is simply user/project scope.
local dir="$TMPDIR/anc"
mkdir -p "$dir"
cat > "$dir/apm.yml" <<EOF
name: outer-package
version: 0.1.0
type: skill
EOF
local fake_home="$dir/fakehome"
mkdir -p "$fake_home/.apm/agents"
cat > "$fake_home/.apm/agents/my-agent.agent.md" <<EOF
---
name: my-agent
description: A valid agent description.
source_keys:
- my-source
---
You are a test agent.
EOF
run env HOME="$fake_home" bash "$SCRIPT" "$fake_home/.apm/agents/my-agent.agent.md"
[ "$status" -eq 0 ]
assert_output ""
}
@test "reconciliation: a nonexistent path inside a non-plugin-scope tree exits 2, not the old silent 0" {
# The other half. Before the exit-2 tier, a typo'd path anywhere outside a
# package took the not-plugin-scope exit and reported a silent pass, so the
# typo and a clean agent produced identical output and identical status.
local fake_home="$TMPDIR/plainhome"
mkdir -p "$fake_home/.apm/agents"
run env HOME="$fake_home" bash "$SCRIPT" "$fake_home/.apm/agents/typo.agent.md"
[ "$status" -eq 2 ]
assert_output --partial "no such file"
}
# ---------------------------------------------------------------------------
# PLACEHOLDER_RE: the trailing character class was CONSUMING
#
# `(?<!\`)FILL IN:[^\`\n]` required a character after the colon, so a `FILL IN:`
# at end of line matched nothing and escaped checks 1 and 5 entirely — and
# `- **Description:** FILL IN:` is the most likely spelling of a half-written
# entry. The lookahead states the same exclusion without eating a character.
# ---------------------------------------------------------------------------
@test "FAIL: a FILL IN: placeholder at end of line is caught, not skipped" {
local root="$TMPDIR/package"
make_package "$root"
make_agent_with_source_keys "$root"
cat > "$root/sources.md" <<EOF
# Sources
## my-source
- **URL:** https://example.com/my-source
- **Description:** FILL IN:
- **Contributing files:** .apm/agents/my-agent.agent.md
- **Research doc:** (none)
- **Status:** \`extracted\`
EOF
run bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md"
assert_failure
assert_output --partial "Unfilled FILL IN: placeholder"
}
@test "FAIL: a Research doc value that is a bare end-of-line FILL IN: is caught" {
local root="$TMPDIR/package"
make_package "$root"
make_agent_with_source_keys "$root"
cat > "$root/sources.md" <<EOF
# Sources
## my-source
- **URL:** https://example.com/my-source
- **Description:** A test source.
- **Contributing files:** .apm/agents/my-agent.agent.md
- **Research doc:** FILL IN:
- **Status:** \`extracted\`
EOF
run bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md"
assert_failure
assert_output --partial "Research doc field is empty or placeholder"
}
# ---------------------------------------------------------------------------
# Encoding, read side: read_text() pins UTF-8 and strips a BOM
#
# The old code used bare open() calls inheriting locale.getpreferredencoding(),
# which is ASCII under LC_ALL=C, and wrapped exactly one of them in
# `except Exception: return []` — so an unreadable agent file was reported as
# having no source_keys and therefore as CLEAN. The other call sites had no
# handler at all and died with a traceback.
# ---------------------------------------------------------------------------
@test "FAIL: an undecodable agent file is reported, not swallowed into a clean pass" {
local root="$TMPDIR/package"
make_package "$root"
printf '\xff\xfe---\nname: my-agent\n---\n' > "$root/.apm/agents/my-agent.agent.md"
make_sources_md "$root" "my-source" "(none)"
run bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md"
assert_failure
assert_output --partial "not valid UTF-8"
refute_output --partial "Traceback"
}
@test "FAIL: an undecodable contributing file is reported, not a traceback" {
local root="$TMPDIR/package"
make_package "$root"
make_agent_with_source_keys "$root"
printf '\xff\xfe---\nname: other\n---\n' > "$root/.apm/agents/other.agent.md"
make_sources_md "$root" "my-source" ".apm/agents/other.agent.md"
run bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md"
assert_failure
assert_output --partial "not valid UTF-8"
refute_output --partial "Traceback"
}
@test "exit 2: an undecodable apm.yml names the file instead of dying mid walk-up" {
local root="$TMPDIR/package"
make_package "$root"
make_clean_agent "$root"
printf 'name: t\nversion: 0.1.0\ntype: skill\n# \xff\xfe\n' > "$root/apm.yml"
run bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md"
[ "$status" -eq 2 ]
assert_output --partial "not valid UTF-8"
refute_output --partial "Traceback"
}
@test "a BOM-prefixed agent file still has its source_keys read (check 2 runs)" {
# A leading BOM defeats parse_frontmatter()'s ^--- anchor, so no frontmatter
# parsed means no source_keys parsed means nothing to validate — check 2
# went silently missing on exactly the file it was pointed at.
local root="$TMPDIR/package"
make_package "$root"
printf '\xef\xbb\xbf---\nname: my-agent\ndescription: A valid agent description.\nsource_keys:\n - ghost-source\n---\n\nYou are a test agent.\n' \
> "$root/.apm/agents/my-agent.agent.md"
make_sources_md "$root" "my-source" "(none)"
run bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md"
assert_failure
assert_output --partial "source_keys slug 'ghost-source' not found in sources.md"
}
@test "under LC_ALL=C a sources.md carrying an em dash is read, not a UnicodeDecodeError" {
local root="$TMPDIR/package"
make_package "$root"
make_agent_with_source_keys "$root"
cat > "$root/sources.md" <<EOF
# Sources
## my-source
- **URL:** https://example.com/my-source
- **Description:** A test source — with an em dash.
- **Contributing files:** .apm/agents/ghost.agent.md
- **Research doc:** (none)
- **Status:** \`extracted\`
EOF
run env LC_ALL=C PYTHONUTF8=0 bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md"
assert_failure
assert_output --partial "Contributing file '.apm/agents/ghost.agent.md' does not exist"
refute_output --partial "Traceback"
}
# ---------------------------------------------------------------------------
# Encoding, write side: sys.stdout/stderr.reconfigure(encoding='utf-8')
#
# Pinning only the reads moved the crash from the read to the WRITE. Every
# finding this script prints contains an em dash, so under LC_ALL=C
# print_findings() died with UnicodeEncodeError after every check had already
# run — losing the whole report at the last step.
# ---------------------------------------------------------------------------
@test "under LC_ALL=C the findings report is printed, not lost to a UnicodeEncodeError" {
local root="$TMPDIR/package"
make_package "$root"
make_agent_with_source_keys "$root"
make_sources_md "$root" "my-source" ".apm/agents/ghost.agent.md"
run env LC_ALL=C PYTHONUTF8=0 bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md"
assert_failure
assert_output --partial "FAIL Contributing file '.apm/agents/ghost.agent.md' does not exist"
assert_output --partial "Why:"
refute_output --partial "UnicodeEncodeError"
}
# ---------------------------------------------------------------------------
# Half-validated entries announced instead of passing silently
# ---------------------------------------------------------------------------
@test "INFO: a duplicated '## slug' says only the first block was checked" {
# Every per-slug parser locates its block with pattern.search(), so a slug
# written twice resolves to the FIRST block every time: the second block's
# fields are never validated, and the entry looked fully checked.
local root="$TMPDIR/package"
make_package "$root"
make_agent_with_source_keys "$root"
cat > "$root/sources.md" <<EOF
# Sources
## my-source
- **URL:** https://example.com/my-source
- **Description:** A test source.
- **Contributing files:** (none)
- **Research doc:** (none)
- **Status:** \`extracted\`
## my-source
- **URL:** https://example.com/dup
- **Description:** A duplicate entry.
- **Contributing files:** .apm/agents/ghost.agent.md
- **Research doc:** (none)
- **Status:** \`extracted\`
EOF
run bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md"
assert_success
assert_output --partial "Duplicate '## my-source' entry in sources.md"
# The second block's ghost contributing file is genuinely never checked —
# the INFO is what makes that visible rather than a silent half-pass.
refute_output --partial "does not exist"
}
@test "INFO: a second '- **Research doc:**' line in one entry is announced, not ignored" {
local root="$TMPDIR/package"
make_package "$root"
make_agent_with_source_keys "$root"
cat > "$root/sources.md" <<EOF
# Sources
## my-source
- **URL:** https://example.com/my-source
- **Description:** A test source.
- **Contributing files:** (none)
- **Research doc:** (none)
- **Research doc:** docs/research/added-later.md
- **Status:** \`extracted\`
EOF
run bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md"
assert_success
assert_output --partial "Multiple '- **Research doc:**' lines for 'my-source'"
}
# ---------------------------------------------------------------------------
# Finding dedup
#
# The agent file is read once for its own source_keys and again as a
# contributing file, so an unreadable one produced the identical finding twice.
# Distinct findings about the same file still both appear.
# ---------------------------------------------------------------------------
@test "the same unreadable file reached by two checks is reported once, not twice" {
local root="$TMPDIR/package"
make_package "$root"
printf '\xff\xfe---\nname: my-agent\n---\n' > "$root/.apm/agents/my-agent.agent.md"
make_sources_md "$root" "my-source" ".apm/agents/my-agent.agent.md"
run bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md"
assert_failure
local count
count="$(printf '%s\n' "$output" | grep -c "^FAIL File is not valid UTF-8" || true)"
[ "$count" -eq 1 ]
}
# ---------------------------------------------------------------------------
# Auto-detection — the merged provenance entry point classifies its own target
#
# NEW with the factory-audit merge, and new behaviour rather than a ported
# case: scripts/validate-provenance.sh is now ONE entry point for both artifact
# types and works out from the target which rubric to run. This file is the
# AGENT half of that contract; validate-provenance-skill.bats holds the skill
# half and the neither-shape rejection. Same script in all four suites.
#
# ADR-0025 states the rule: "A directory containing SKILL.md takes the skill
# flow; an .agent.md file or a file under agents/ takes the agent flow." That is
# TWO independent rules on the agent side, and each is pinned on its own below,
# because either one alone would make the other look like it worked:
#
# 1. the filename ends in .agent.md, wherever it sits
# 2. the file sits under a directory named agents/, whatever it is called
# (.apm/agents/, .claude/agents/, .github/agents/ — the same shape the
# exported Vale hook matches with `(^|/)agents/[^/]+\.md$`)
#
# The skill rubric rejects a FILE outright ("not a directory"), so a
# misclassified agent file does not produce a wrong finding — it produces a
# usage error about a file that was perfectly well-formed, which is why these
# assert on a real finding rather than merely on a non-crash.
# ---------------------------------------------------------------------------
@test "auto-detect: a *.agent.md file is checked in AGENT provenance mode wherever it sits" {
# Rule 1 in isolation. The directory is deliberately named `defs/`, not
# `agents/`, so rule 2 cannot reach this fixture and the extension is the
# only thing that can classify it.
local root="$TMPDIR/package"
make_package "$root"
mkdir -p "$root/defs"
cat > "$root/defs/my-agent.agent.md" <<EOF
---
name: my-agent
description: A valid agent description.
source_keys:
- ghost-source
---
You are a test agent.
EOF
make_sources_md "$root" "my-source" "(none)"
run bash "$SCRIPT" "$root/defs/my-agent.agent.md"
assert_failure
assert_output --partial "source_keys slug 'ghost-source' not found in sources.md"
# Check 9 is skill-only. It must not appear here at any tier — its presence
# would mean the skill rubric had been run against an agent file.
refute_output --partial "Check 9"
}
@test "auto-detect: a file under .apm/agents/ is checked in AGENT provenance mode even without the .agent.md suffix" {
# Rule 2 in isolation. `.apm/agents/` is the vendor-neutral authoring
# directory, so anything sitting in it is an agent definition whatever it is
# called — and the plain `.md` suffix here is deliberate, so that rule 1
# cannot rescue the fixture and rule 2 is the only thing being measured.
local root="$TMPDIR/package"
make_package "$root"
cat > "$root/.apm/agents/my-agent.md" <<EOF
---
name: my-agent
description: A valid agent description.
source_keys:
- ghost-source
---
You are a test agent.
EOF
make_sources_md "$root" "my-source" "(none)"
run bash "$SCRIPT" "$root/.apm/agents/my-agent.md"
assert_failure
assert_output --partial "source_keys slug 'ghost-source' not found in sources.md"
refute_output --partial "Check 9"
}
@test "--base-ref is SKILL-mode only: handing it an agent target is an error, not a silent no-op" {
# --base-ref belongs to skill mode's check 9, and agent mode has no check 9
# at any tier. The merged entry point does NOT own the flag rules — it skips
# --base-ref only far enough to find the positional that decides the mode,
# then hands the original "$@" to the mode's own suite. So in agent mode the
# flag is still just a second argument and is still rejected as one. Silently
# accepting and ignoring it is the failure worth pinning: a caller who passed
# --base-ref believes a diff ran, and nothing would tell them otherwise.
local root="$TMPDIR/package"
make_package "$root"
make_clean_agent "$root"
run bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md" --base-ref=HEAD
[ "$status" -eq 2 ]
assert_output --partial "expected exactly one argument"
}
@test "--base-ref before an agent target is rejected too, not swallowed by the mode detector" {
# The detector skips leading --base-ref= tokens to find the positional. That
# skip must not launder the flag out of the argument list: the suite still
# sees two arguments and still rejects them. Asserted separately from the
# trailing-flag case because the skip loop only runs on this ordering, so a
# detector that consumed the flag instead of stepping over it would pass the
# test above and fail only here.
local root="$TMPDIR/package"
make_package "$root"
make_clean_agent "$root"
run bash "$SCRIPT" --base-ref=HEAD "$root/.apm/agents/my-agent.agent.md"
[ "$status" -eq 2 ]
assert_output --partial "expected exactly one argument"
}
@test "exit contract: AGENT mode prints NOTHING on a successful run" {
# The other half of the contract pinned in validate-provenance-skill.bats:
# skill mode is allowed to exit 0 with INFO findings on stdout; agent mode
# says nothing at all when it passes.
#
# Both halves are asserted explicitly and on purpose. A merge that collapsed
# one contract into the other would still satisfy whichever side was left
# unasserted, so a single-sided pin would go green on exactly the defect it
# was written to catch. assert_output "" is an equality assertion, not a
# --partial one, so any added banner, mode line or progress note trips it.
local root="$TMPDIR/package"
make_package "$root"
make_agent_with_source_keys "$root"
make_sources_md "$root"
run bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md"
[ "$status" -eq 0 ]
assert_output ""
}
# ---------------------------------------------------------------------------
# Entry-point robustness: how the target and the libraries are located
# ---------------------------------------------------------------------------
# The same three plumbing defects validate-agent.bats pins for validate.sh — a
# bare filename inside agents/ refused as neither shape, a missing lib-*.sh
# reported as exit 1 findings, and a CDPATH-redirected SCRIPT_DIR — pinned here
# for the provenance entry point, which resolves both the same way.
# ---------------------------------------------------------------------------
@test "entry point: a bare filename typed from inside an agents/ directory is audited, not refused" {
local root="$TMPDIR/package"
make_package "$root"
cat > "$root/.apm/agents/my-agent.md" <<EOF
---
name: my-agent
description: A valid agent description.
---
You are a test agent.
EOF
cd "$root/.apm/agents"
run bash "$SCRIPT" my-agent.md
[ "$status" -eq 0 ]
assert_output ""
run bash "$SCRIPT" ./my-agent.md
[ "$status" -eq 0 ]
assert_output ""
}
@test "entry point: a missing library exits 2 naming it, never exit 1" {
local root="$TMPDIR/package"
make_package "$root"
make_clean_agent "$root"
local lone="$TMPDIR/lone-scripts"
cp -R "$(dirname "$SCRIPT")" "$lone"
rm "$lone/lib-contributing-files.sh"
run bash "$lone/validate-provenance.sh" "$root/.apm/agents/my-agent.agent.md"
[ "$status" -eq 2 ]
assert_output --partial "required library '$lone/lib-contributing-files.sh' is missing or unreadable"
refute_output --partial "No such file or directory"
}
@test "entry point: an exported CDPATH cannot redirect which libraries are sourced" {
local root="$TMPDIR/package"
make_package "$root"
make_clean_agent "$root"
local decoy="$TMPDIR/decoy"
mkdir -p "$decoy/scripts"
local lib
for lib in lib-contributing-files.sh lib-provenance-skill.sh lib-provenance-agent.sh; do
printf 'echo DECOY-SOURCED; exit 99\n' > "$decoy/scripts/$lib"
done
cd "$(dirname "$SCRIPT")/.."
run env CDPATH="$decoy" bash scripts/validate-provenance.sh "$root/.apm/agents/my-agent.agent.md"
[ "$status" -eq 0 ]
assert_output ""
}