refactor!: carry out the simplification audit across gates, tests, plugins and docs #135

Merged
Defame1297 merged 85 commits from docs/simplification-audit into main 2026-09-20 19:14:03 +00:00
8 changed files with 446 additions and 87 deletions
Showing only changes of commit 1d40544075 - Show all commits

View File

@@ -67,23 +67,14 @@ it's asked, not one that varies by plugin domain.
create/improve pass owes a bump, so the 12 skills carrying it are not tracking dead weight — removing create/improve pass owes a bump, so the 12 skills carrying it are not tracking dead weight — removing
it discards real revision signal for no gain. it discards real revision signal for no gain.
## Consequences
27 SKILL.md files gain `metadata.version: "1.0.0"`, and a 28th — `bin/write-docs` — reaches the same
value by relocating its top-level `version: "1.0"` into `metadata:`. `skill-author`'s `create.md`
moves the field from "Optional frontmatter" to the required list, citing this ADR. `skill-author`'s
own SKILL.md drops the "with `metadata.version` present" conditional in its bump-rule line, since
presence is no longer in question. `.pre-commit-config.yaml`'s `skill-frontmatter` hook is extended
to require the field, closing the gap #113 and #118 both named in the same audit pass: a stated rule
with nothing enforcing it drifts the same way an unstated one does.
## Amendment (2026-09-16): the bump is enforced at push, not only required to exist ## Amendment (2026-09-16): the bump is enforced at push, not only required to exist
Making the field mandatory did not make it move. The only thing that bumped it was `skill-author` Making the field mandatory did not make it move. The only thing that bumped it was `skill-author`
Step 4, so every hand edit and every trim pass skipped the bump: on `docs/simplification-audit`, 17 Step 4, so every hand edit and every trim pass skipped the bump: on `docs/simplification-audit`, 17
of the 40 skill directories that changed against `main` carried the same `metadata.version` as of the 40 skill directories that changed against `main` carried the same `metadata.version` as
`main`, and `gitea` alone sat at six different values. Both validators checked presence and semver `main`, and `gitea` alone sat at six different values. Both validators —
shape, never movement, so the field could not answer the question this ADR gives it — "did this `scripts/skill-size-check.sh` (the pre-commit hook) and `factory-audit`'s
`scripts/lib-checks-skill.sh` — checked presence and semver shape, never movement, so the field could not answer the question this ADR gives it — "did this
change since I last read it". (Simplification audit finding 33.) change since I last read it". (Simplification audit finding 33.)
`scripts/check-skill-version-bump.sh` now runs as a pre-push hook on every push, whatever the `scripts/check-skill-version-bump.sh` now runs as a pre-push hook on every push, whatever the
@@ -91,7 +82,8 @@ target branch. It takes its baseline from the merge-base of the pushed commit wi
(local `main` if `origin/main` does not resolve). For each skill directory under (local `main` if `origin/main` does not resolve). For each skill directory under
`plugins/*/.apm/skills/` that differs between the pushed commit and that merge-base, ignoring `plugins/*/.apm/skills/` that differs between the pushed commit and that merge-base, ignoring
`tests/`, the pushed `metadata.version` must be strictly greater than the version the skill had at `tests/`, the pushed `metadata.version` must be strictly greater than the version the skill had at
the merge-base — not the version on `main`'s current tip. the merge-base — not the version on `main`'s current tip. (The amendment below reverses that last
choice: the pushed version must now also exceed `main`'s tip.)
The baseline is the merge-base, not the previous commit. Readers only ever see `main` — installs The baseline is the merge-base, not the previous commit. Readers only ever see `main` — installs
resolve against the default branch (ADR-0018) — so one bump per branch is what the field owes resolve against the default branch (ADR-0018) — so one bump per branch is what the field owes
@@ -106,8 +98,10 @@ through `.pre-commit-hooks.yaml`.
The gate fails closed rather than passing when it has no trustworthy baseline: when neither The gate fails closed rather than passing when it has no trustworthy baseline: when neither
`origin/main` nor `main` resolves, when the pushed commit shares no merge-base with it, and when `origin/main` nor `main` resolves, when the pushed commit shares no merge-base with it, and when
only local `main` resolves and the pushed commit is that merge-base, since a local `main` the only local `main` resolves and the pushed commit is that merge-base, since a local `main` the
pushed commit already contains is no independent record of what shipped. It reads versions with pushed commit already contains is no independent record of what shipped. It also fails closed when
`python3` and PyYAML and fails with a clear message if either is missing. the pushed ref does not resolve to a commit, and when a `SKILL.md` the tree names cannot be read by
`git show` or parsed by `python3` — a read failure is reported as such, never as a missing version.
It reads versions with `python3` and PyYAML and fails with a clear message if either is missing.
Three alternatives were rejected. Declaring the field advisory is the cheapest, but concedes the Three alternatives were rejected. Declaring the field advisory is the cheapest, but concedes the
field cannot do its job. Dropping the field was rejected by this ADR already, and costs more now. field cannot do its job. Dropping the field was rejected by this ADR already, and costs more now.
@@ -120,6 +114,46 @@ The gate differs from `check-release-needed` in when it runs: that hook acts onl
reports a push to `main`, so a manual `pre-commit run --hook-stage pre-push` skips it, while this reports a push to `main`, so a manual `pre-commit run --hook-stage pre-push` skips it, while this
gate runs there too and checks `HEAD`. The two hooks share both known gaps. A merge made with gate runs there too and checks `HEAD`. The two hooks share both known gaps. A merge made with
Gitea's merge button runs no local hooks, so it is not checked. And pre-commit's pre-push Gitea's merge button runs no local hooks, so it is not checked. And pre-commit's pre-push
integration checks only the first ref with new commits in a multi-ref push (`git push origin a b`, integration checks only one ref of a multi-ref push (`git push origin a b`, `git push --all`).
`git push --all`): `_pre_push_ns` in pre-commit's `hook_impl.py` returns after that ref, so the In pre-commit 4.6.1, `_pre_push_ns` in `hook_impl.py` skips delete lines and returns on the first
other refs are pushed unchecked. remaining ref whose remote sha is non-zero and present locally; a ref whose remote sha is zero or
unknown locally is returned only if it has commits that no remote-tracking ref of that remote has.
Every later ref is pushed unchecked. When the returned ref's unpushed history reaches a root
commit, pre-commit sets no `PRE_COMMIT_TO_REF` at all, so the gate checks `HEAD`, which is the
pushed ref only if it is checked out.
## Amendment (2026-09-16): the pushed version must also exceed `main`'s tip
This reverses the choice above that the baseline is the merge-base "not the version on `main`'s
current tip". A changed skill's pushed `metadata.version` must now be strictly greater than **both**
its version at the merge-base and its version at the tip of `origin/main` (local `main` under the
same fallback, with the same fail-closed rules).
The merge-base alone lets two branches ship two different changes under one version. Branches A and
B both start from a skill at `1.0.0`, change it differently, and bump it to `1.0.1`. A merges. B's
merge-base is still the `1.0.0` commit, so B passes, and the two `1.0.1` bumps are the same line
change, so git merges B without a conflict. `main` then carries two different `1.0.1` contents, and
the field again fails to answer "did this change since I last read it". Checking against the tip as
well makes B fail until it bumps past `1.0.1`.
- **A skill absent at the tip** (deleted on `main` since the branch started) is held to the
merge-base rule alone. **A skill absent at both** is new and stays exempt.
- **When `main` has not moved since the merge-base**, the two baselines are the same commit and the
skill is checked once.
- **The failure names the baseline it missed**: `(not above merge-base)` or
`(not above origin/main tip)`, one line per baseline missed.
The cost is that a branch behind `main` may have to bump again after another branch lands a bump
on the same skill. That is the case the rule exists for, and rebasing onto or merging `main` first
shows the version to beat. The rule reads `origin/main` as last fetched, so a tip that moved since
the last fetch is not seen until the next one.
## Consequences
27 SKILL.md files gain `metadata.version: "1.0.0"`, and a 28th — `bin/write-docs` — reaches the same
value by relocating its top-level `version: "1.0"` into `metadata:`. `skill-author`'s `create.md`
moves the field from "Optional frontmatter" to the required list, citing this ADR. `skill-author`'s
own SKILL.md drops the "with `metadata.version` present" conditional in its bump-rule line, since
presence is no longer in question. `.pre-commit-config.yaml`'s `skill-frontmatter` hook is extended
to require the field, closing the gap #113 and #118 both named in the same audit pass: a stated rule
with nothing enforcing it drifts the same way an unstated one does.

View File

@@ -37,10 +37,15 @@ rehearsal of 8 hooks and a skip of the ninth. The script's own header records th
a PR merged through Gitea's merge button, where no local push happens at all. a PR merged through Gitea's merge button, where no local push happens at all.
A real push has a gap of its own. When one `git push` carries several refs A real push has a gap of its own. When one `git push` carries several refs
(`git push origin a b`, `git push --all`), pre-commit runs the pre-push stage once, for the first (`git push origin a b`, `git push --all`), pre-commit runs the pre-push stage once, for one ref.
ref that has new commits: `_pre_push_ns` in pre-commit's `hook_impl.py` returns as soon as it finds In pre-commit 4.6.1, `_pre_push_ns` in `hook_impl.py` skips delete lines and returns on the first
that ref. The two hooks that read the pushed ref — `check-release-needed` and remaining ref whose remote sha is non-zero and present locally; a ref whose remote sha is zero or
unknown locally is returned only if it has commits that no remote-tracking ref of that remote has.
The two hooks that read the pushed ref — `check-release-needed` and
`check-skill-version-bump` — therefore check only that ref, and the others are pushed unchecked. `check-skill-version-bump` — therefore check only that ref, and the others are pushed unchecked.
When that ref's unpushed history reaches a root commit, pre-commit runs with all files and sets no
`PRE_COMMIT_TO_REF`, so `check-skill-version-bump` checks `HEAD`, which is the pushed ref only if it
is checked out.
For `check-release-needed`, a multi-ref push whose first ref is not `main` never gates `main` at For `check-release-needed`, a multi-ref push whose first ref is not `main` never gates `main` at
all. Push one ref at a time when the gate matters. all. Push one ref at a time when the gate matters.
@@ -87,7 +92,7 @@ drift in generated text.
| Hook | Guards | | Hook | Guards |
|---|---| |---|---|
| `check-skill-version-bump` | fails if a skill directory changed since the pushed commit's merge-base with `main` without its `metadata.version` rising above the merge-base's (see [below](#check-skill-version-bump)) | | `check-skill-version-bump` | fails if a skill directory changed since the pushed commit's merge-base with `main` without its `metadata.version` rising above both the merge-base's and `main`'s tip's (see [below](#check-skill-version-bump)) |
**Release** **Release**
@@ -108,32 +113,46 @@ ADR-0022 makes `metadata.version` mandatory and says a skill change carries a bu
- **It runs on every push and under a manual `pre-commit run --hook-stage pre-push`.** It does not - **It runs on every push and under a manual `pre-commit run --hook-stage pre-push`.** It does not
read `PRE_COMMIT_REMOTE_BRANCH`, so unlike `check-release-needed` the manual rehearsal really read `PRE_COMMIT_REMOTE_BRANCH`, so unlike `check-release-needed` the manual rehearsal really
checks it. The pushed commit is `PRE_COMMIT_TO_REF`, or `HEAD` when that is unset. checks it. The pushed commit is `PRE_COMMIT_TO_REF`, or `HEAD` when that is unset.
- **Baseline is the merge-base of the pushed commit with `origin/main`** (local `main` if - **"Changed" is measured from the merge-base of the pushed commit with `origin/main`** (local
`origin/main` does not resolve). The pushed version is compared with the skill's version *at `main` if `origin/main` does not resolve). Readers install from `main`, so "changed" means
that merge-base*, not with `main`'s current tip. Readers install from `main`, so "changed" means
changed against the `main` the branch started from. The remote branch tip is not the baseline: changed against the `main` the branch started from. The remote branch tip is not the baseline:
a second push would excuse an unbumped change the first push already carried. a second push would excuse an unbumped change the first push already carried.
- **A changed skill's version must beat two baselines**: its version at that merge-base *and* its
version at the tip of the same `main` ref (ADR-0022's second 2026-09-16 amendment). The tip
check stops two branches that make the same bump (`1.0.0` → `1.0.1`) with different content from
both landing, since the identical version lines merge without a conflict. A skill absent at the
tip is held to the merge-base alone; when `main` has not moved, the two are the same commit. Each
failure line names the baseline it missed: `(not above merge-base)` or
`(not above origin/main tip)`. The tip is `origin/main` as last fetched.
- **It fails closed when it has no trustworthy baseline:** neither `origin/main` nor `main` - **It fails closed when it has no trustworthy baseline:** neither `origin/main` nor `main`
resolves; there is no merge-base (shallow clone, unrelated history); or only local `main` resolves; there is no merge-base (shallow clone, unrelated history); or only local `main`
resolves and the pushed commit *is* the merge-base, so local `main` already contains the pushed resolves and the pushed commit *is* the merge-base, so local `main` already contains the pushed
commit and says nothing independent about what shipped. commit and says nothing independent about what shipped.
- **A changed skill must end strictly above its baseline version**, compared numerically - **A changed skill must end strictly above each baseline version**, compared numerically
(`1.0.10` > `1.0.9`). Any bump size passes. A missing or non-`MAJOR.MINOR.PATCH` version at the (`1.0.10` > `1.0.9`). Any bump size passes. A missing or non-`MAJOR.MINOR.PATCH` version at the
pushed ref fails, and so does a skill directory left without its `SKILL.md`. Versions are pushed ref fails, and so does a skill directory left without its `SKILL.md`. Each part is ASCII
ASCII-only with at most nine digits per part — stricter than `skill-size-check`'s shape check, digits, at most nine of them, with no leading zero (`1.0.08` is malformed) — the same shape
so a Unicode digit or an overflowing part cannot pass as a bump. Skills absent at the baseline `skill-size-check` enforces, so a Unicode digit, an overflowing part or an octal-looking part
(new, renamed, merged) or at the pushed ref (deleted) are exempt. cannot pass as a bump. A leading UTF-8 BOM is ignored. A baseline with no valid version accepts
- **It also fails closed on read errors:** a pushed ref that does not resolve to a commit, or a any valid version. Skills absent at both baselines (new, renamed, merged) or at the pushed ref
`SKILL.md` that `git show` or `python3` cannot read, stops the push with a read-failure message (deleted, or replaced by a symlink) are exempt. A file moved between skills counts as a change
rather than being reported as a missing version. Frontmatter that reads but does not parse to both: renames are diffed as delete plus add. A mode-only change counts too.
- **It also fails closed on read errors:** a pushed ref that does not resolve to a commit
(including a tag on a tree), or a `SKILL.md` that the tree names but `git show` or `python3`
cannot read, stops the push with a read-failure message rather than being reported as a missing
version or treated as an absent skill. Presence is read from the tree, so a blob missing from a
corrupt or partial clone cannot make a skill look new. Frontmatter that reads but does not parse
counts as an invalid version. counts as an invalid version.
- **`<skill>/tests/` is excluded**: no agent loads it, so a test-only change ships nothing. - **`<skill>/tests/` is excluded**: no agent loads it, so a test-only change ships nothing.
- **It needs `python3` and PyYAML** to read the frontmatter, and fails with a clear message if - **It needs `python3` and PyYAML** to read the frontmatter, and fails with a clear message if
either is missing, for the reasons in either is missing, for the reasons in
[`python3` and PyYAML are hard requirements](#python3-and-pyyaml-are-hard-requirements). [`python3` and PyYAML are hard requirements](#python3-and-pyyaml-are-hard-requirements).
- **Known gaps, both shared with `check-release-needed`:** a PR merged through Gitea's merge button - **Known gaps, both shared with `check-release-needed`:** a PR merged through Gitea's merge button
runs no local hook; and a multi-ref push checks only its first ref with new commits (see runs no local hook; and a multi-ref push checks only the one ref pre-commit selects, and a push
reaching a root commit gets no `PRE_COMMIT_TO_REF`, so `HEAD` is checked (see
[Running the gates](#running-the-gates)). [Running the gates](#running-the-gates)).
- **An all-zeros `PRE_COMMIT_TO_REF` (a branch delete) exits 0.** The branch is defensive:
pre-commit 4.6.1 skips delete lines before it sets the variable.
## Skill and agent context gates (ADR-0020) ## Skill and agent context gates (ADR-0020)

View File

@@ -235,7 +235,9 @@ else:
# --- ADR-0022: metadata.version is mandatory ------------------------------- # --- ADR-0022: metadata.version is mandatory -------------------------------
# FAIL, not SUGGESTION, and the tier is set by the gate rather than by taste. # FAIL, not SUGGESTION, and the tier is set by the gate rather than by taste.
# `.pre-commit-config.yaml`'s `skill-size-check` hook REJECTS a SKILL.md with # `.pre-commit-config.yaml`'s `skill-size-check` hook REJECTS a SKILL.md with
# no `metadata.version`, and rejects a value that is not three-part semver. # no `metadata.version`, and rejects a value that is not three-part semver:
# ASCII digits only, no leading zero (semver 2.0.0 item 2), at most nine digits
# per part (bash arithmetic in check-skill-version-bump.sh), whole-value match.
# skill-author's Step 4 says to run this audit and "resolve every FAIL", so any # skill-author's Step 4 says to run this audit and "resolve every FAIL", so any
# tier below FAIL lets that step report done on a skill the commit gate then # tier below FAIL lets that step report done on a skill the commit gate then
# refuses — the same audit-disagrees-with-the-gate failure the MAX_LINES note # refuses — the same audit-disagrees-with-the-gate failure the MAX_LINES note
@@ -246,8 +248,10 @@ else:
# The rule is DUPLICATED from that hook for the same cache-isolation reason as # The rule is DUPLICATED from that hook for the same cache-isolation reason as
# every other constant here — an installed plugin's scripts cannot read the # every other constant here — an installed plugin's scripts cannot read the
# repo-root config. Keep the two in step: this check must accept exactly what # repo-root config. Keep the two in step: this check must accept exactly what
# the hook accepts. # the hook accepts. Used with fullmatch(), never match() with ^...$ anchors:
SEMVER_RE = re.compile(r'^\d+\.\d+\.\d+$') # `$` also matches before a trailing newline, and `\d` also matches non-ASCII
# Unicode digits — both of which the hook rejects.
SEMVER_RE = re.compile(r'(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})')
try: try:
fm_data = yaml.safe_load(fm) fm_data = yaml.safe_load(fm)
@@ -268,13 +272,15 @@ else:
# spelling is exactly the two-part value the hook rejects — coercing and # spelling is exactly the two-part value the hook rejects — coercing and
# then matching keeps this check and the hook agreeing on that case. # then matching keeps this check and the hook agreeing on that case.
version_text = version_value if isinstance(version_value, str) else str(version_value) version_text = version_value if isinstance(version_value, str) else str(version_value)
version_text = version_text.strip() # Same normalisation as the hook: surrounding whitespace, then quotes.
if SEMVER_RE.match(version_text): version_text = version_text.strip().strip('\'"')
if SEMVER_RE.fullmatch(version_text):
ok(f"metadata.version present: '{version_text}' (ADR-0022)") ok(f"metadata.version present: '{version_text}' (ADR-0022)")
else: else:
fail(f"metadata.version '{version_text}' is not three-part semver — the " fail(f"metadata.version '{version_text}' is not three-part semver — the "
f"skill-size-check pre-commit hook rejects it. Use MAJOR.MINOR.PATCH, " f"skill-size-check pre-commit hook rejects it. Use MAJOR.MINOR.PATCH "
f"e.g. \"1.0.0\"") f"with ASCII digits, no leading zeros and at most nine digits per "
f"part, e.g. \"1.0.0\"")
# SKILL.md size ceilings (agentskills.io skill-authoring.md: 500 lines, # SKILL.md size ceilings (agentskills.io skill-authoring.md: 500 lines,
# ~5,000 tokens). Both constants are DUPLICATED from the repo-root pre-commit # ~5,000 tokens). Both constants are DUPLICATED from the repo-root pre-commit

View File

@@ -377,7 +377,7 @@ SH
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# ADR-0022 — metadata.version is mandatory. FAIL tier, matching the # ADR-0022 — metadata.version is mandatory. FAIL tier, matching the
# skill-frontmatter pre-commit hook: an audit that graded this lower would # skill-size-check pre-commit hook: an audit that graded this lower would
# report ready-to-ship on a file the commit gate rejects. # report ready-to-ship on a file the commit gate rejects.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -437,6 +437,62 @@ PY
assert_output --partial "metadata.version present: '0.1.3'" assert_output --partial "metadata.version present: '0.1.3'"
} }
@test "ADR-0022: a leading zero in the patch part FAILs (1.0.08)" {
local skill="$TMPDIR/my-skill"
make_valid_skill "$skill"
python3 - "$skill/SKILL.md" <<'PY'
import sys
p = sys.argv[1]
s = open(p).read().replace(' version: "1.0.0"\n', ' version: "1.0.08"\n')
open(p, 'w').write(s)
PY
run bash "$SCRIPT" "$skill"
assert_failure
assert_output --partial "three-part semver"
}
@test "ADR-0022: a leading zero in the major part FAILs (01.0.1)" {
local skill="$TMPDIR/my-skill"
make_valid_skill "$skill"
python3 - "$skill/SKILL.md" <<'PY'
import sys
p = sys.argv[1]
s = open(p).read().replace(' version: "1.0.0"\n', ' version: "01.0.1"\n')
open(p, 'w').write(s)
PY
run bash "$SCRIPT" "$skill"
assert_failure
assert_output --partial "three-part semver"
}
@test "ADR-0022: a multi-digit part with no leading zero passes (1.0.10)" {
local skill="$TMPDIR/my-skill"
make_valid_skill "$skill"
python3 - "$skill/SKILL.md" <<'PY'
import sys
p = sys.argv[1]
s = open(p).read().replace(' version: "1.0.0"\n', ' version: "1.0.10"\n')
open(p, 'w').write(s)
PY
run bash "$SCRIPT" "$skill"
assert_success
assert_output --partial "metadata.version present: '1.0.10'"
}
@test "ADR-0022: a zero major part passes (0.1.0)" {
local skill="$TMPDIR/my-skill"
make_valid_skill "$skill"
python3 - "$skill/SKILL.md" <<'PY'
import sys
p = sys.argv[1]
s = open(p).read().replace(' version: "1.0.0"\n', ' version: "0.1.0"\n')
open(p, 'w').write(s)
PY
run bash "$SCRIPT" "$skill"
assert_success
assert_output --partial "metadata.version present: '0.1.0'"
}
@test "fails when name contains consecutive hyphens" { @test "fails when name contains consecutive hyphens" {
local skill="$TMPDIR/my--skill" local skill="$TMPDIR/my--skill"
make_valid_skill "$skill" make_valid_skill "$skill"

View File

@@ -17,6 +17,14 @@ set -euo pipefail
# PRE_COMMIT_TO_REF is set). A missing bump is cheapest to fix on the branch, # PRE_COMMIT_TO_REF is set). A missing bump is cheapest to fix on the branch,
# before review. # before review.
# #
# Second baseline: the tip of that same <main> ref. A changed skill's pushed
# version must exceed its version there too (ADR-0022, second 2026-09-16
# amendment). Two branches bumping 1.0.0 -> 1.0.1 with different content merge
# without a conflict, so the merge-base alone would let main ship both under
# one version. When <main> has not moved since the merge-base, the two
# baselines are one commit and the skill is checked once. The tip is read as
# last fetched.
#
# Pushing main itself: with origin/main as the baseline, a push of main diffs # Pushing main itself: with origin/main as the baseline, a push of main diffs
# the new commits against what the remote already has, so it is covered. A # the new commits against what the remote already has, so it is covered. A
# pushed commit that is already an ancestor of origin/main (merge-base equals # pushed commit that is already an ancestor of origin/main (merge-base equals
@@ -31,36 +39,44 @@ set -euo pipefail
# when any other file under its directory differs between baseline and pushed # when any other file under its directory differs between baseline and pushed
# commit. Paths are read NUL-delimited (`git diff -z`), so core.quotePath never # commit. Paths are read NUL-delimited (`git diff -z`), so core.quotePath never
# hides a non-ASCII path. Renames are diffed as delete + add (--no-renames), so: # hides a non-ASCII path. Renames are diffed as delete + add (--no-renames), so:
# - a skill absent at the baseline (new, renamed-to, merged-into) is exempt; # - a skill absent at both baselines (new, renamed-to, merged-into) is
# it has no prior version to exceed. # exempt; it has no prior version to exceed. Absent at the tip only
# (deleted on main since): the merge-base rule alone applies.
# - a file moved from one skill to another changes both.
# - a skill directory absent at the pushed commit (deleted, renamed-from) is # - a skill directory absent at the pushed commit (deleted, renamed-from) is
# exempt; there is nothing left to version. A directory that survives # exempt; there is nothing left to version. A directory replaced by a
# without its SKILL.md is NOT exempt: it fails as "SKILL.md missing". # symlink is no longer a tree, so it counts as deleted (apm drops symlinks
# A changed skill present at both refs must carry a three-part semver # under .apm/, ADR-0017). A directory that survives without its SKILL.md
# `metadata.version` at the pushed commit that is numerically greater than the # is NOT exempt: it fails as "SKILL.md missing".
# baseline's. The shape rule follows skill-size-check.sh (str()-coerce, strip # Presence is read from the tree (rev-parse <commit>:<path>), not from the
# whitespace and quotes, three dot-separated numbers, so `1.0` and `1.0.0-rc1` # blob, so a blob missing from a corrupt or partial clone is a read failure,
# are rejected) but is deliberately stricter: ASCII digits only (Python's `\d` # never a skill that looks new.
# also matches e.g. U+FF11) and at most 9 digits per part, so every part fits # A changed skill must carry a three-part semver `metadata.version` at the
# bash arithmetic. A baseline with no valid version (a skill predating # pushed commit that is numerically greater than each baseline's. The shape
# rule matches skill-size-check.sh (str()-coerce, strip whitespace and quotes,
# so `1.0` and `1.0.0-rc1` are rejected): each part is ASCII digits (Python's
# `\d` also matches e.g. U+FF11), at most 9 of them so it fits bash
# arithmetic, with no leading zero so bash never reads it as octal. A leading
# UTF-8 BOM is ignored. A baseline with no valid version (a skill predating
# ADR-0022) accepts any valid version. Versions are read from git objects, # ADR-0022) accepts any valid version. Versions are read from git objects,
# never the working tree. # never the working tree.
# #
# Fails closed: if neither origin/main nor main resolves, if no merge-base # Fails closed: if neither origin/main nor main resolves, if no merge-base
# exists (shallow clone, unrelated history), if the pushed ref does not resolve # exists (shallow clone, unrelated history), if the pushed ref does not resolve
# to a commit, if python3 or PyYAML is unavailable, or if a SKILL.md cannot be # to a commit (an unknown sha, a tag on a tree), if python3 or PyYAML is
# read. Passing in any of those would make that environment the one place the # unavailable, or if a SKILL.md the tree names cannot be read. Passing in any
# rule is silently off. # of those would make that environment the one place the rule is silently off.
# #
# Known gaps: # Known gaps:
# - Only the first pushed ref is gated. pre-commit (4.x, hook_impl.py # - Only one pushed ref is gated. pre-commit (4.6.1, hook_impl.py
# `_pre_push_ns`) consumes the pre-push stdin itself and builds the # `_pre_push_ns`) consumes the pre-push stdin itself and walks the ref
# environment from the first ref line that is not a delete and has commits # lines in order: it skips deletes, returns on a ref whose remote sha is
# the remote lacks; every later ref in the same `git push` (e.g. # non-zero and exists locally, and otherwise returns on the ref only if it
# `git push origin a b`, `--all`, `--tags`) is never seen. When that first # has commits no remote-tracking ref of that remote has. Every other ref in
# ref's unpushed history reaches a root commit, pre-commit runs with # the same `git push` (e.g. `git push origin a b`, `--all`, `--tags`) is
# all_files and sets no PRE_COMMIT_TO_REF at all, so this script checks # never seen. When the selected ref's unpushed history reaches a root
# HEAD — which is the pushed ref only if it happens to be checked out. The # commit, pre-commit runs with all_files and sets no PRE_COMMIT_TO_REF at
# all, so this script checks HEAD — the pushed ref only if checked out. The
# script cannot recover either case: the ref list is gone by the time it # script cannot recover either case: the ref list is gone by the time it
# runs. Push refs one at a time to be sure each is checked. # runs. Push refs one at a time to be sure each is checked.
# - A PR merged via Gitea's merge button runs no local hook at all (the same # - A PR merged via Gitea's merge button runs no local hook at all (the same
@@ -76,7 +92,10 @@ export LC_ALL=C
# peeled to a commit below before use. # peeled to a commit below before use.
PUSHED_REF="${PRE_COMMIT_TO_REF:-HEAD}" PUSHED_REF="${PRE_COMMIT_TO_REF:-HEAD}"
# All-zeros sha: the push deletes a branch. Nothing ships; bail out. # All-zeros sha: the push deletes a branch, so nothing ships. Defensive only:
# pre-commit 4.6.1's `_pre_push_ns` already skips delete lines and never passes
# one here. Kept so a different caller cannot turn a delete into a rev-parse
# failure.
if [[ "$PUSHED_REF" =~ ^0+$ ]]; then if [[ "$PUSHED_REF" =~ ^0+$ ]]; then
exit 0 exit 0
fi fi
@@ -160,7 +179,7 @@ done < "$CHANGED_FILE"
read_version() { read_version() {
python3 -c ' python3 -c '
import re, sys, yaml import re, sys, yaml
text = sys.stdin.buffer.read().decode("utf-8", errors="replace") text = sys.stdin.buffer.read().decode("utf-8-sig", errors="replace")
m = re.match(r"---[ \t\r]*\n(.*?)\n---[ \t\r]*(\n|\Z)", text, re.S) m = re.match(r"---[ \t\r]*\n(.*?)\n---[ \t\r]*(\n|\Z)", text, re.S)
data = None data = None
if m: if m:
@@ -171,7 +190,7 @@ if m:
meta = data.get("metadata") if isinstance(data, dict) else None meta = data.get("metadata") if isinstance(data, dict) else None
ver = meta.get("version") if isinstance(meta, dict) else None ver = meta.get("version") if isinstance(meta, dict) else None
ver = None if ver is None else str(ver).strip().strip("\x27\"") ver = None if ver is None else str(ver).strip().strip("\x27\"")
if ver is not None and re.fullmatch(r"[0-9]{1,9}\.[0-9]{1,9}\.[0-9]{1,9}", ver): if ver is not None and re.fullmatch(r"(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})", ver):
print("OK " + ver) print("OK " + ver)
else: else:
print("INVALID") print("INVALID")
@@ -192,31 +211,47 @@ version_at() {
} }
# Exit 0 when $1 > $2, both MAJOR.MINOR.PATCH with parts of at most 9 ASCII # Exit 0 when $1 > $2, both MAJOR.MINOR.PATCH with parts of at most 9 ASCII
# digits, compared numerically so 1.0.10 > 1.0.9. 10# forces base 10 on a # digits and no leading zero (so bash never reads a part as octal), compared
# leading zero. # numerically so 1.0.10 > 1.0.9.
semver_gt() { semver_gt() {
local -a a b local -a a b
local i local i
IFS=. read -ra a <<< "$1" IFS=. read -ra a <<< "$1"
IFS=. read -ra b <<< "$2" IFS=. read -ra b <<< "$2"
for i in 0 1 2; do for i in 0 1 2; do
if (( 10#${a[i]} > 10#${b[i]} )); then return 0; fi if (( a[i] > b[i] )); then return 0; fi
if (( 10#${a[i]} < 10#${b[i]} )); then return 1; fi if (( a[i] < b[i] )); then return 1; fi
done done
return 1 return 1
} }
MAIN_TIP="$(git rev-parse --verify -q "$MAIN_REF^{commit}")"
# in_tree <commit> <path>: the tree names <path>. Unlike `git cat-file -e`, it
# does not need the blob itself, so a blob a corrupt or partial clone lacks is a
# read failure in version_at, not a skill that silently looks absent.
in_tree() {
git rev-parse --verify -q "$1:$2" > /dev/null
}
OFFENDERS=() OFFENDERS=()
for dir in ${SKILL_DIRS[@]+"${SKILL_DIRS[@]}"}; do for dir in ${SKILL_DIRS[@]+"${SKILL_DIRS[@]}"}; do
# Absent at baseline: new, renamed-to, or merged-into. Exempt. at_base=false
git cat-file -e "$BASELINE:$dir/SKILL.md" 2>/dev/null || continue at_tip=false
in_tree "$BASELINE" "$dir/SKILL.md" && at_base=true
# When main has not moved since the merge-base, the tip is the same baseline.
[[ "$MAIN_TIP" != "$BASELINE" ]] && in_tree "$MAIN_TIP" "$dir/SKILL.md" && at_tip=true
# Absent at both baselines: new, renamed-to, or merged-into. Exempt.
$at_base || $at_tip || continue
# Directory absent at pushed commit: deleted or renamed-from. Exempt. # Directory absent at pushed commit: deleted or renamed-from. Exempt.
[[ "$(git cat-file -t "$PUSHED_COMMIT:$dir" 2>/dev/null)" == "tree" ]] || continue [[ "$(git cat-file -t "$PUSHED_COMMIT:$dir" 2>/dev/null)" == "tree" ]] || continue
version_at "$BASELINE" "$dir/SKILL.md" base_ver=""
base_ver="$VERSION" tip_ver=""
if $at_base; then version_at "$BASELINE" "$dir/SKILL.md"; base_ver="$VERSION"; fi
if $at_tip; then version_at "$MAIN_TIP" "$dir/SKILL.md"; tip_ver="$VERSION"; fi
if ! git cat-file -e "$PUSHED_COMMIT:$dir/SKILL.md" 2>/dev/null; then if ! in_tree "$PUSHED_COMMIT" "$dir/SKILL.md"; then
OFFENDERS+=("$dir: SKILL.md missing at $PUSHED_REF (baseline: ${base_ver:-none})") OFFENDERS+=("$dir: SKILL.md missing at $PUSHED_REF (baseline: ${base_ver:-none})")
continue continue
fi fi
@@ -225,14 +260,19 @@ for dir in ${SKILL_DIRS[@]+"${SKILL_DIRS[@]}"}; do
if [[ -z "$cur_ver" ]]; then if [[ -z "$cur_ver" ]]; then
OFFENDERS+=("$dir: metadata.version missing or not MAJOR.MINOR.PATCH at $PUSHED_REF (baseline: ${base_ver:-none})") OFFENDERS+=("$dir: metadata.version missing or not MAJOR.MINOR.PATCH at $PUSHED_REF (baseline: ${base_ver:-none})")
elif [[ -n "$base_ver" ]] && ! semver_gt "$cur_ver" "$base_ver"; then continue
OFFENDERS+=("$dir: $base_ver -> $cur_ver") fi
if [[ -n "$base_ver" ]] && ! semver_gt "$cur_ver" "$base_ver"; then
OFFENDERS+=("$dir: $base_ver -> $cur_ver (not above merge-base)")
fi
if [[ -n "$tip_ver" ]] && ! semver_gt "$cur_ver" "$tip_ver"; then
OFFENDERS+=("$dir: $tip_ver -> $cur_ver (not above $MAIN_REF tip)")
fi fi
done done
if [[ ${#OFFENDERS[@]} -gt 0 ]]; then if [[ ${#OFFENDERS[@]} -gt 0 ]]; then
echo "FAIL: skills changed since merge-base with $MAIN_REF without a metadata.version bump (ADR-0022):" >&2 echo "FAIL: skills changed since merge-base with $MAIN_REF without a metadata.version above both that merge-base and the $MAIN_REF tip (ADR-0022):" >&2
printf ' %s\n' ${OFFENDERS[@]+"${OFFENDERS[@]}"} >&2 printf ' %s\n' ${OFFENDERS[@]+"${OFFENDERS[@]}"} >&2
echo " Fix: raise metadata.version in each SKILL.md above the baseline — bump PATCH at minimum." >&2 echo " Fix: raise metadata.version in each SKILL.md above the baseline named — bump PATCH at minimum." >&2
exit 1 exit 1
fi fi

View File

@@ -1374,7 +1374,10 @@ for path in files:
if version_val is None: if version_val is None:
error("%s: metadata.version field is missing (required frontmatter " error("%s: metadata.version field is missing (required frontmatter "
"field, e.g. \"1.0.0\")." % path) "field, e.g. \"1.0.0\")." % path)
elif not re.match(r'^\d+\.\d+\.\d+$', str(version_val).strip().strip('\'"')): # Same shape as check-skill-version-bump.sh: ASCII digits, at most nine per
# part (bash arithmetic), no leading zero (semver 2.0.0 item 2).
elif not re.fullmatch(r'(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})',
str(version_val).strip().strip('\'"')):
error("%s: metadata.version is malformed (%r) -- expected a " error("%s: metadata.version is malformed (%r) -- expected a "
"three-part semver, e.g. \"1.0.0\"." % (path, version_val)) "three-part semver, e.g. \"1.0.0\"." % (path, version_val))

View File

@@ -899,6 +899,45 @@ else
pass "the SUGGESTION survives LC_ALL=C, streams pinned to UTF-8" pass "the SUGGESTION survives LC_ALL=C, streams pinned to UTF-8"
fi fi
# ---------------------------------------------------------------------------
# metadata.version shape: no leading zeros, matching check-skill-version-bump
# ---------------------------------------------------------------------------
echo ""
echo "--- metadata.version with a leading zero is malformed ---"
for version_case in "1.0.08:malformed" "01.0.1:malformed" "1.0.10:valid" "0.1.0:valid"; do
version="${version_case%%:*}"
expected="${version_case##*:}"
VERSION_SKILL="$TMPDIR/version-$version"
mkdir -p "$VERSION_SKILL"
cat > "$VERSION_SKILL/SKILL.md" <<VERSIONEOF
---
name: version-skill
description: A valid skill description that is well within the limit.
metadata:
version: "$version"
---
## Step 1
Do the thing.
VERSIONEOF
set +e
VERSION_OUT="$("$SCRIPT" "$VERSION_SKILL/SKILL.md" 2>&1)"
VERSION_STATUS=$?
set -e
if [[ "$expected" == malformed ]]; then
if [[ $VERSION_STATUS -ne 0 && "$VERSION_OUT" == *"metadata.version is malformed ('$version')"* ]]; then
pass "'$version' is rejected as malformed"
else
fail "'$version' was not rejected as malformed (exit $VERSION_STATUS): ${VERSION_OUT:-<empty>}"
fi
elif [[ "$VERSION_OUT" == *"metadata.version is malformed"* ]]; then
fail "'$version' was wrongly rejected as malformed: $VERSION_OUT"
else
pass "'$version' is accepted"
fi
done
echo "" echo ""
echo "Results: $PASS passed, $FAIL failed" echo "Results: $PASS passed, $FAIL failed"
[[ $FAIL -eq 0 ]] [[ $FAIL -eq 0 ]]

View File

@@ -217,13 +217,22 @@ write_skill "$F" demo beta "version: 1.0.10" "x"; commit "$F"
expect_pass "1.0.10 > 1.0.9 passes" "$F" expect_pass "1.0.10 > 1.0.9 passes" "$F"
echo "" echo ""
echo "--- 17. baseline is the merge-base, not main's tip ---" echo "--- 17. pushed version must also exceed main's tip ---"
F="$(make_fixture)" F="$(make_fixture)"
write_skill "$F" demo alpha 'version: "1.0.1"' "branch change"; commit "$F" write_skill "$F" demo alpha 'version: "1.0.1"' "branch change"; commit "$F"
(cd "$F" && git checkout -q main) (cd "$F" && git checkout -q main)
write_skill "$F" demo alpha 'version: "1.0.5"' "main moved on"; commit "$F" write_skill "$F" demo alpha 'version: "1.0.5"' "main moved on"; commit "$F"
(cd "$F" && git checkout -q feature) (cd "$F" && git checkout -q feature)
expect_pass "bump over the merge-base passes even though main is ahead" "$F" expect_fail "bump over the merge-base fails when main's tip is higher" \
"alpha: 1\.0\.5 -> 1\.0\.1 \(not above main tip\)" "$F"
OUT="$(run_check "$F" || true)"
if grep -q "not above merge-base" <<< "$OUT"; then
fail "merge-base reported as failed although 1.0.1 > 1.0.0: $OUT"
else
pass "only the baseline actually failed is named"
fi
write_skill "$F" demo alpha 'version: "1.0.6"' "branch change 2"; commit "$F"
expect_pass "bump above both the merge-base and main's tip passes" "$F"
echo "" echo ""
echo "--- 18. origin/main preferred over local main ---" echo "--- 18. origin/main preferred over local main ---"
@@ -241,6 +250,8 @@ fi
(cd "$F" && git update-ref refs/remotes/origin/main feature~1) (cd "$F" && git update-ref refs/remotes/origin/main feature~1)
expect_fail "origin/main at the bumped commit flags the further unbumped change" \ expect_fail "origin/main at the bumped commit flags the further unbumped change" \
"merge-base with origin/main" "$F" "merge-base with origin/main" "$F"
expect_fail "the report names alpha and the versions read from origin/main's merge-base" \
"demo/\.apm/skills/alpha: 1\.0\.1 -> 1\.0\.1 \(not above merge-base\)" "$F"
echo "" echo ""
echo "--- 19. no main ref fails closed ---" echo "--- 19. no main ref fails closed ---"
@@ -271,7 +282,8 @@ MAIN_SHA="$(cd "$F" && git rev-parse main)"
expect_fail "pushing main's sha without origin/main fails" \ expect_fail "pushing main's sha without origin/main fails" \
"origin/main does not resolve.*already contained in local main" "$F" "$MAIN_SHA" "origin/main does not resolve.*already contained in local main" "$F" "$MAIN_SHA"
(cd "$F" && git checkout -q main) (cd "$F" && git checkout -q main)
expect_fail "HEAD on main without origin/main fails" "Fix: git fetch origin main" "$F" expect_fail "HEAD on main without origin/main fails" \
"origin/main does not resolve and HEAD is already contained in local main" "$F"
echo "" echo ""
echo "--- 22. pushing main itself with origin/main present ---" echo "--- 22. pushing main itself with origin/main present ---"
@@ -287,9 +299,10 @@ expect_pass "already-merged content (merge-base == pushed) passes against origin
"$(cd "$F" && git rev-parse main~1)" "$(cd "$F" && git rev-parse main~1)"
echo "" echo ""
echo "--- 23. version shape is ASCII-only and bounded ---" echo "--- 23. version shape is ASCII-only, bounded, and has no leading zeros ---"
for v in 'version: "1.0.1"' 'version: "1.0.1"' 'version: "1.0.9999999999"' \ for v in 'version: "1.0.1"' 'version: "1.0.1"' 'version: "1.0.9999999999"' \
'version: "99999999999999999999.0.0"'; do 'version: "99999999999999999999.0.0"' 'version: "1.0.08"' 'version: "01.0.1"' \
'version: "1.00.1"'; do
F="$(make_fixture)" F="$(make_fixture)"
write_skill "$F" demo alpha "$v" "new body"; commit "$F" write_skill "$F" demo alpha "$v" "new body"; commit "$F"
OUT="$(run_check "$F" || true)" OUT="$(run_check "$F" || true)"
@@ -303,6 +316,9 @@ done
F="$(make_fixture)" F="$(make_fixture)"
write_skill "$F" demo alpha 'version: "1.0.999999999"' "new body"; commit "$F" write_skill "$F" demo alpha 'version: "1.0.999999999"' "new body"; commit "$F"
expect_pass "nine-digit part is accepted and compared" "$F" expect_pass "nine-digit part is accepted and compared" "$F"
F="$(make_fixture)"
write_skill "$F" demo alpha 'version: "1.0.10"' "new body"; commit "$F"
expect_pass "a zero inside a part (1.0.10) is not a leading zero" "$F"
echo "" echo ""
echo "--- 24. python3 / PyYAML failures are never reported as a missing version ---" echo "--- 24. python3 / PyYAML failures are never reported as a missing version ---"
@@ -372,7 +388,7 @@ write_skill "$F" demo alpha 'version: "1.0.0"' "orphan"; commit "$F" "orphan roo
expect_fail "unrelated history fails at the merge-base check" "no merge-base between main and HEAD" "$F" expect_fail "unrelated history fails at the merge-base check" "no merge-base between main and HEAD" "$F"
echo "" echo ""
echo "--- 28. annotated tag push is peeled to its commit ---" echo "--- 28. annotated tag objects as PRE_COMMIT_TO_REF ---"
F="$(make_fixture)" F="$(make_fixture)"
write_skill "$F" demo alpha 'version: "1.0.0"' "unbumped"; commit "$F" write_skill "$F" demo alpha 'version: "1.0.0"' "unbumped"; commit "$F"
(cd "$F" && git tag -a v9 -m "tag" && git checkout -q main) (cd "$F" && git tag -a v9 -m "tag" && git checkout -q main)
@@ -383,6 +399,16 @@ else
fail "fixture check: v9 is not an annotated tag object" fail "fixture check: v9 is not an annotated tag object"
fi fi
expect_fail "unbumped change behind an annotated tag fails" "alpha: 1\.0\.0 -> 1\.0\.0" "$F" "$TAG_OBJ" expect_fail "unbumped change behind an annotated tag fails" "alpha: 1\.0\.0 -> 1\.0\.0" "$F" "$TAG_OBJ"
# Peeling is what makes the local-main fallback's "pushed commit is the
# merge-base" test see through a tag: compared unpeeled, the tag's own sha
# never equals the merge-base and the empty diff would pass.
(cd "$F" && git tag -a on-main -m "tag" main)
expect_fail "a tag on local main's commit fails closed like the commit itself" \
"origin/main does not resolve and [0-9a-f]+ is already contained in local main" \
"$F" "$(cd "$F" && git rev-parse on-main)"
(cd "$F" && git tag -a tree-tag -m "tag" "main^{tree}")
expect_fail "a tag on a tree fails closed" "pushed ref [0-9a-f]+ does not resolve to a commit" \
"$F" "$(cd "$F" && git rev-parse tree-tag)"
echo "" echo ""
echo "--- 29. CRLF frontmatter is parsed ---" echo "--- 29. CRLF frontmatter is parsed ---"
@@ -405,6 +431,142 @@ expect_fail "unbumped CRLF skill reports both parsed versions" "alpha: 1\.0\.0 -
write_skill "$F" demo alpha 'version: "1.0.1"' "crlf body 2"; crlf "$F" alpha; commit "$F" write_skill "$F" demo alpha 'version: "1.0.1"' "crlf body 2"; crlf "$F" alpha; commit "$F"
expect_pass "bumped CRLF skill passes" "$F" expect_pass "bumped CRLF skill passes" "$F"
echo ""
echo "--- 30. identical bump already merged to main fails ---"
# Branches A and B both bump alpha 1.0.0 -> 1.0.1 with different content. The
# bumps do not conflict at merge, so without the tip rule main would ship two
# changes under one version.
F="$(make_fixture)"
(cd "$F" && git checkout -q -b branch-a main)
write_skill "$F" demo alpha 'version: "1.0.1"' "change A"; commit "$F"
(cd "$F" && git checkout -q main && git merge -q --no-ff -m "merge A" branch-a \
&& git update-ref refs/remotes/origin/main main && git checkout -q feature)
write_skill "$F" demo alpha 'version: "1.0.1"' "change B"; commit "$F"
expect_fail "B's 1.0.1 fails against A's 1.0.1 on origin/main" \
"alpha: 1\.0\.1 -> 1\.0\.1 \(not above origin/main tip\)" "$F"
write_skill "$F" demo alpha 'version: "1.0.2"' "change B 2"; commit "$F"
expect_pass "B at 1.0.2 passes" "$F"
echo ""
echo "--- 31. skill deleted on main's tip: only the merge-base rule applies ---"
F="$(make_fixture)"
(cd "$F" && git checkout -q main)
rm -rf "$F/plugins/demo/.apm/skills/alpha"; commit "$F" "drop alpha on main"
(cd "$F" && git checkout -q feature)
write_skill "$F" demo alpha 'version: "1.0.1"' "branch change"; commit "$F"
expect_pass "bump over the merge-base passes when main's tip lacks the skill" "$F"
(cd "$F" && git checkout -q -b unbumped main~1)
write_skill "$F" demo alpha 'version: "1.0.0"' "unbumped"; commit "$F"
expect_fail "unbumped change still fails against the merge-base" \
"alpha: 1\.0\.0 -> 1\.0\.0 \(not above merge-base\)" "$F"
echo ""
echo "--- 32. UTF-8 BOM before the frontmatter is parsed ---"
# bom <repo> <skill>: prefix that skill's SKILL.md with a UTF-8 byte-order mark.
bom() {
local f="$1/plugins/demo/.apm/skills/$2/SKILL.md"
{ printf '\xef\xbb\xbf'; cat "$f"; } > "$f.tmp" && mv "$f.tmp" "$f"
}
F="$(make_fixture)"
write_skill "$F" demo alpha 'version: "1.0.0"' "bom body"; bom "$F" alpha; commit "$F"
if [[ "$(head -c 3 "$F/plugins/demo/.apm/skills/alpha/SKILL.md" | od -An -tx1 | tr -d ' ')" == "efbbbf" ]]; then
pass "fixture check: SKILL.md starts with a BOM"
else
fail "fixture check: SKILL.md has no BOM"
fi
expect_fail "unbumped BOM skill reports its parsed version, not a missing one" \
"alpha: 1\.0\.0 -> 1\.0\.0 \(not above merge-base\)" "$F"
write_skill "$F" demo alpha 'version: "1.0.1"' "bom body 2"; bom "$F" alpha; commit "$F"
expect_pass "bumped BOM skill passes" "$F"
echo ""
echo "--- 33. a file moved from one skill to another flags both ---"
# --no-renames: with rename detection, --name-only lists only the new path and
# alpha would lose a file without anyone noticing.
F="$(make_fixture)"
(cd "$F" && git checkout -q main)
mkdir -p "$F/plugins/demo/.apm/skills/alpha/references"
printf 'ref line %s\n' 1 2 3 4 5 > "$F/plugins/demo/.apm/skills/alpha/references/x.md"
commit "$F" "alpha reference"
(cd "$F" && git checkout -q feature && git merge -q main)
mkdir -p "$F/plugins/demo/.apm/skills/beta/references"
(cd "$F" && git mv plugins/demo/.apm/skills/alpha/references/x.md plugins/demo/.apm/skills/beta/references/x.md)
write_skill "$F" demo beta "version: 1.0.10"; commit "$F"
expect_fail "alpha is flagged although only beta was bumped" \
"alpha: 1\.0\.0 -> 1\.0\.0 \(not above merge-base\)" "$F"
echo ""
echo "--- 34. comparison is ordered major first ---"
F="$(make_fixture)"
(cd "$F" && git checkout -q main)
write_skill "$F" demo alpha 'version: "2.0.0"'; commit "$F" "alpha 2.0.0"
(cd "$F" && git checkout -q feature && git merge -q main)
write_skill "$F" demo alpha 'version: "1.9.0"' "new body"; commit "$F"
expect_fail "2.0.0 -> 1.9.0 fails although minor rose" "alpha: 2\.0\.0 -> 1\.9\.0" "$F"
echo ""
echo "--- 35. unresolvable PRE_COMMIT_TO_REF fails closed ---"
F="$(make_fixture)"
expect_fail "a sha absent from the repo fails" \
"pushed ref 1234567890abcdef1234567890abcdef12345678 does not resolve to a commit" \
"$F" "1234567890abcdef1234567890abcdef12345678"
echo ""
echo "--- 36. a SKILL.md git cannot read fails closed ---"
# drop_blob <repo> <rev:path>: delete that blob's loose object, as a corrupt or
# partial clone would lack it. The tree still names the file.
drop_blob() {
local sha
sha="$(cd "$1" && git rev-parse "$2")"
rm -f "$1/.git/objects/${sha:0:2}/${sha:2}"
}
F="$(make_fixture)"
write_skill "$F" demo alpha 'version: "1.0.1"' "new body"; commit "$F"
drop_blob "$F" "HEAD:plugins/demo/.apm/skills/alpha/SKILL.md"
OUT="$(run_check "$F")" && RC=0 || RC=$?
if [[ $RC -ne 0 ]] && grep -q "could not read metadata.version from [0-9a-f]*:plugins/demo/.apm/skills/alpha/SKILL.md" <<< "$OUT" \
&& grep -q "fatal: bad object" <<< "$OUT" && ! grep -qE "missing or not|SKILL\.md missing" <<< "$OUT"; then
pass "an unreadable pushed SKILL.md is a read failure carrying git's error"
else
fail "unreadable pushed SKILL.md misreported (rc=$RC): $OUT"
fi
F="$(make_fixture)"
write_skill "$F" demo alpha 'version: "1.0.1"' "new body"; commit "$F"
drop_blob "$F" "main:plugins/demo/.apm/skills/alpha/SKILL.md"
OUT="$(run_check "$F")" && RC=0 || RC=$?
if [[ $RC -ne 0 ]] && grep -q "could not read metadata.version" <<< "$OUT"; then
pass "an unreadable merge-base SKILL.md fails closed instead of exempting the skill"
else
fail "unreadable merge-base SKILL.md not caught (rc=$RC): $OUT"
fi
echo ""
echo "--- 37. a mode-only change counts as a change ---"
F="$(make_fixture)"
(cd "$F" && git config core.fileMode true)
chmod +x "$F/plugins/demo/.apm/skills/alpha/SKILL.md"; commit "$F"
if [[ "$(cd "$F" && git diff --summary main HEAD)" == *"mode change 100644 => 100755"* ]]; then
pass "fixture check: the commit changes only the file mode"
else
fail "fixture check: no mode change recorded"
fi
expect_fail "chmod +x without a bump fails" "alpha: 1\.0\.0 -> 1\.0\.0" "$F"
echo ""
echo "--- 38. skill directory replaced by a symlink ---"
# Current behaviour, pinned: the path is no longer a tree at the pushed commit,
# so the skill is exempt as deleted. apm drops symlinks under .apm/ (ADR-0017),
# so readers do lose the skill.
F="$(make_fixture)"
rm -rf "$F/plugins/demo/.apm/skills/alpha"
ln -s beta "$F/plugins/demo/.apm/skills/alpha"; commit "$F"
if [[ "$(cd "$F" && git ls-tree HEAD plugins/demo/.apm/skills/alpha)" == 120000* ]]; then
pass "fixture check: alpha is committed as a symlink"
else
fail "fixture check: alpha is not a symlink in the commit"
fi
expect_pass "a skill replaced by a symlink is exempt as deleted" "$F"
echo "" echo ""
echo "Results: $PASS passed, $FAIL failed" echo "Results: $PASS passed, $FAIL failed"
[[ $FAIL -eq 0 ]] [[ $FAIL -eq 0 ]]