refactor!: carry out the simplification audit across gates, tests, plugins and docs #135
@@ -183,15 +183,6 @@ repos:
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
|
||||
- id: check-release-needed
|
||||
name: Check a release tag covers .pre-commit-hooks.yaml's paths
|
||||
description: On push to main only, fail if files exposed via .pre-commit-hooks.yaml changed since the last tag
|
||||
entry: bash scripts/check-release-needed.sh
|
||||
language: system
|
||||
stages: [pre-push]
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
|
||||
- id: check-skill-version-bump
|
||||
name: Check changed skills bump metadata.version
|
||||
description: On every push, fail if a skill directory changed (tests/ excluded) since the merge-base with main without its SKILL.md metadata.version rising (ADR-0022)
|
||||
@@ -203,7 +194,7 @@ repos:
|
||||
# Baseline is the merge-base with origin/main (falling back to main),
|
||||
# not the remote branch tip: readers install from main. Fails closed
|
||||
# when no main ref resolves. Merges through Gitea's merge button run no
|
||||
# local hook, so they bypass this, just as they bypass check-release-needed.
|
||||
# local hook, so they bypass this.
|
||||
|
||||
- id: validate-marketplace
|
||||
name: Validate marketplace manifest
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
# PUBLISHED CONTRACT. External repos consume these IDs with `rev: <tag>`, so an
|
||||
# ID or a `files:` regex here may not change without breaking them on upgrade.
|
||||
# ADR-0025 merged skill-audit and agent-audit into factory-audit and re-pointed
|
||||
# both `entry:` paths at its single vale-wrap.sh; both IDs and both regexes are
|
||||
# unchanged, deliberately. Collapsing them into one was considered and rejected:
|
||||
# it breaks every consumer pinning kyberforge-vale-audit-agent, and it re-creates
|
||||
# ADR-0014's measured failure where one hook against one config silently scanned
|
||||
# 0 files of the other type. Two IDs are what keep both file scopes addressable.
|
||||
- id: kyberforge-vale-audit-skill
|
||||
name: Kyberforge Vale prose audit (SKILL.md)
|
||||
description: Deterministic prose-pattern prefilter for kyberforge's factory-audit skill flow, via its own bundled Vale config/styles
|
||||
entry: plugins/kyberforge/.apm/skills/factory-audit/scripts/vale-wrap.sh
|
||||
language: script
|
||||
files: '(^|/)SKILL\.md$'
|
||||
|
||||
- id: kyberforge-vale-audit-agent
|
||||
name: Kyberforge Vale prose audit (agent files)
|
||||
description: Deterministic prose-pattern prefilter for kyberforge's factory-audit agent flow, via its own bundled Vale config/styles
|
||||
entry: plugins/kyberforge/.apm/skills/factory-audit/scripts/vale-wrap.sh
|
||||
language: script
|
||||
files: '(^|/)agents/[^/]+\.md$|\.agent\.md$'
|
||||
|
||||
- id: kyberforge-skill-size-check
|
||||
name: SKILL.md size and context-budget ceilings
|
||||
description: Enforce agentskills.io's 500-line/2,770-whole-file-word spec ceilings plus ADR-0020's context budget (description 250 chars SUGGESTION / 400 FAIL, body-only 600 words SUGGESTION / 900 FAIL, resolvable boundary-clause routing targets)
|
||||
entry: scripts/skill-size-check.sh
|
||||
language: script
|
||||
files: '(^|/)SKILL\.md$'
|
||||
# verbose so the SUGGESTION tier reaches a human -- pre-commit prints
|
||||
# nothing for a passing hook, and a SUGGESTION deliberately does not fail.
|
||||
verbose: true
|
||||
@@ -32,7 +32,7 @@ Install all of these before setting up. Each one is a hard dependency of a git h
|
||||
| --- | --- | --- |
|
||||
| `apm` CLI | Two pre-push hooks shell out to it (`apm-audit-ci` and `apm-pack-check-clean`) | The `apm-install` skill, or `curl -sSL https://aka.ms/apm-unix \| sh`. Verify with `apm --version` |
|
||||
| `python3` + PyYAML | Required by `scripts/skill-size-check.sh` (the `skill-size-check` pre-commit hook) and `scripts/check-skill-version-bump.sh` (the `check-skill-version-bump` pre-push hook), which both parse YAML frontmatter | `python3` is usually present — pre-commit is itself a Python application. `pip install pyyaml` if the hook reports PyYAML missing |
|
||||
| `vale` | Required by the `vale-audit-prefilter-skill` / `-agent` pre-commit hooks, and by the `test-vale-wrap.sh` / `test-vale-hooks-consumer.sh` suites that `run-tests --strict` runs at pre-push | `brew install vale` (macOS), `snap install vale` (Linux), `choco install vale` (Windows), or https://vale.sh/docs/vale-cli/installation/ |
|
||||
| `vale` | Required by the `vale-audit-prefilter-skill` / `-agent` pre-commit hooks, and by the `test-vale-wrap.sh` suite that `run-tests --strict` runs at pre-push | `brew install vale` (macOS), `snap install vale` (Linux), `choco install vale` (Windows), or https://vale.sh/docs/vale-cli/installation/ |
|
||||
| `claude` CLI | Required by the `validate-marketplace` pre-push hook | Claude Code |
|
||||
|
||||
Two notes worth reading before you skip one:
|
||||
@@ -84,11 +84,6 @@ Run the pre-push gate locally in one command:
|
||||
pre-commit run --hook-stage pre-push --all-files
|
||||
```
|
||||
|
||||
One caveat: `check-release-needed` is a silent no-op under this invocation. It exits 0 unless
|
||||
`PRE_COMMIT_REMOTE_BRANCH` is `refs/heads/main`, and pre-commit exports that only from the real
|
||||
pre-push git hook during an actual `git push` — so the hook reports `Passed` having checked nothing.
|
||||
Every other pre-push hook does run.
|
||||
|
||||
See [`docs/spec/gates.md`](docs/spec/gates.md) for what each hook enforces and why.
|
||||
|
||||
**Offline?** No pre-push hook needs the network: root `apm.yml`'s marketplace has no remote package entries (the last one, `mattpocock-skills`, was removed), so `apm-pack-check-clean` resolves everything from local sources. All pre-push hooks pass offline.
|
||||
|
||||
@@ -27,6 +27,14 @@ them by name — and the argument-free `entry:` contract is untouched. Read the
|
||||
sync-check paragraph, and the `tests/test-vale-wrap.sh` Consequences bullet below as the state this
|
||||
ADR established, not as current layout.
|
||||
|
||||
**Amended (2026-09-16): the `.pre-commit-hooks.yaml` export and its release tags are retired.**
|
||||
The runtime half of this ADR — Vale config, styles and wrapper bundled inside the skill (now
|
||||
`factory-audit`), self-located from `${BASH_SOURCE[0]}` — stands. The external git-hook/CI half does
|
||||
not: the manifest, `check-release-needed` and the tag-cutting consequence are gone. See
|
||||
[the amendment at the end of this file](#amendment-2026-09-16-the-external-hook-contract-is-retired)
|
||||
before reading any paragraph below that names `.pre-commit-hooks.yaml`, a `rev:` tag, or
|
||||
`check-release-needed` as current.
|
||||
|
||||
`skill-audit`/`agent-audit`'s Step 1 called
|
||||
`"$(git rev-parse --show-toplevel)/scripts/vale-wrap.sh" --config "$(git rev-parse --show-toplevel)/.vale.ini"`
|
||||
— which resolves to whichever repo the skill happens to be running in. Inside `ai-development`
|
||||
@@ -219,3 +227,51 @@ only when the original span is already two or more lines, so the pad count stays
|
||||
`tests/test-vale-wrap.sh` case 20 asserts an apostrophe-bearing token actually fires on a flattened
|
||||
description in all three apostrophe-carrying branches, and case 20b pins the pad arithmetic against
|
||||
a body line's true line number.
|
||||
|
||||
## Amendment (2026-09-16): the external hook contract is retired
|
||||
|
||||
Root `.pre-commit-hooks.yaml`, `scripts/check-release-needed.sh`,
|
||||
`tests/test-check-release-needed.sh` and `tests/test-vale-hooks-consumer.sh` are deleted, and the
|
||||
`check-release-needed` pre-push hook is removed from `.pre-commit-config.yaml`. The three exported
|
||||
hook IDs — `kyberforge-vale-audit-skill`, `kyberforge-vale-audit-agent` and
|
||||
`kyberforge-skill-size-check` — no longer exist, and no new `vX.Y.Z` tag is cut when hook files
|
||||
change. (Simplification audit finding 36.)
|
||||
|
||||
Three reasons, any one of which would have been enough to ask the question:
|
||||
|
||||
- **No consumer was found.** The Gitea instance holds two repos. The other one pins seven hook
|
||||
repos, and none of them is this one. None of the 13 commits that touched the mechanism came from a
|
||||
consumer report; all were found by this repo's own tests. Clones outside the instance cannot be
|
||||
counted, but ADR-0024 accepted the same standard when it deleted the mirror.
|
||||
- **The mechanism was already failing at its one job.** `scripts/skill-size-check.sh` changed on
|
||||
`main` after `v2.0.1`, and no tag was cut, so a consumer pinning `rev: v2.0.1` already ran a
|
||||
stale hook. The gate could not have caught it. It acted only when pre-commit reported a push to
|
||||
`refs/heads/main`, and PRs here merge through Gitea's server-side merge button, which runs no
|
||||
local hook. The script's own header said that closing the gap needed a server-side CI job the
|
||||
repo does not have.
|
||||
- **The README already contradicted it.** Its "For external consumers" section says apm is the only
|
||||
supported install path and never mentions `.pre-commit-hooks.yaml` or `rev:` pinning.
|
||||
|
||||
**What is unaffected.** This repo's `repo: local` hooks — `skill-size-check`,
|
||||
`vale-audit-prefilter-skill` and `vale-audit-prefilter-agent` — were always wired separately from
|
||||
the export, so no internal lint coverage is lost. The two prefilter hook IDs stay separate for the
|
||||
file-scope reason in "One hook per file-scope" above, not for an external contract.
|
||||
`tests/test-vale-wrap.sh` case 33, the cross-manifest `files:` drift check that ADR-0025 ported,
|
||||
went with the manifest it compared against. Its one guard that did not need a second manifest, a
|
||||
local regex narrowed to a single plugin, is now a third property of case 32. The `v1.0.0`, `v2.0.0`
|
||||
and `v2.0.1` tags are left in place. They are inert: nothing reads them, and apm's `per_package`
|
||||
versioning never consults `tagPattern`.
|
||||
|
||||
**What is preserved for a return.** The `entry[0]`-only constraint in the Consequences above, and
|
||||
its incident records at `LESSONS.md:101` and `:105`, stay as written. That constraint says a
|
||||
published entry is a bare script path, with every bundled file located from `${BASH_SOURCE[0]}`,
|
||||
and it took three review rounds to find. Both hook scripts still meet it: `vale-wrap.sh` takes no
|
||||
`--config`, and `skill-size-check.sh` keeps its embedded resolver copy. If a consumer appears,
|
||||
restore the manifest under that constraint, and restore `test-vale-hooks-consumer.sh` with it: it
|
||||
was the only test that exercised the entry-resolution path that once shipped broken. Restore a
|
||||
release gate only once a server-side job can run it on merge.
|
||||
|
||||
**Superseded statements elsewhere.** ADR-0022's notes that the version-bump gate "is not exported
|
||||
through `.pre-commit-hooks.yaml`" and that it shares its gaps with `check-release-needed`, and
|
||||
ADR-0025's point 5 ("Both exported Vale hook IDs survive unchanged") and its case-33 port, describe
|
||||
the state before this amendment.
|
||||
|
||||
@@ -5,6 +5,11 @@ exact pair, scoped itself to them, and then deferred the work as issue #101. The
|
||||
here. `skill-author` and `agent-author` stay separate — ADR-0020 excluded the author pair
|
||||
deliberately, and nothing in this change touches that exclusion.
|
||||
|
||||
**Amended by ADR-0014 (2026-09-16).** The published `.pre-commit-hooks.yaml` is retired. Point 5
|
||||
below (both exported hook IDs survive) and the case 33 port no longer describe the repo. Case 33 is
|
||||
deleted, and its one-plugin narrowing guard is now a property of case 32. See
|
||||
[ADR-0014's amendment](0014-vale-prefilter-ships-from-the-plugin.md#amendment-2026-09-16-the-external-hook-contract-is-retired).
|
||||
|
||||
## Context
|
||||
|
||||
Every figure below was measured against the worktree on 2026-09-15. Re-derive rather than quote; the
|
||||
|
||||
@@ -21,37 +21,28 @@ Install hooks via `pc-run`, wiring **all three stages**. This repo's `.pre-commi
|
||||
`default_install_hook_types`, so a plain install silently skips `commit-msg` (Conventional Commits)
|
||||
and `pre-push` (everything below).
|
||||
|
||||
The pre-push command reports **11** hooks, not 9. The extra two are pre-commit's own `meta` hooks,
|
||||
The pre-push command reports **10** hooks, not 8. The extra two are pre-commit's own `meta` hooks,
|
||||
`check-hooks-apply` and `check-useless-excludes`: they declare no `stages:`, so they run at every
|
||||
stage including this one. Both are declared in this repo's `.pre-commit-config.yaml` like everything
|
||||
else — what separates them is `repo: meta` (pre-commit's own built-ins) from `repo: local`. Nine
|
||||
is the count of hooks this repo authors itself.
|
||||
|
||||
**The caveat: one of those 9 is a silent no-op under that invocation.**
|
||||
`check-release-needed` exits 0 immediately unless `PRE_COMMIT_REMOTE_BRANCH` equals
|
||||
`refs/heads/main`, and pre-commit exports that variable only from the real pre-push git hook during
|
||||
an actual `git push`. Running the stage by hand — or from a CI runner — therefore reports it
|
||||
`Passed` having checked nothing. That is by design for feature branches — pushing WIP must not be
|
||||
blocked on cutting a premature tag — but it means `--hook-stage pre-push --all-files` is a full
|
||||
rehearsal of 8 hooks and a skip of the ninth. The script's own header records the same gap for
|
||||
a PR merged through Gitea's merge button, where no local push happens at all.
|
||||
else — what separates them is `repo: meta` (pre-commit's own built-ins) from `repo: local`. Eight
|
||||
is the count of hooks this repo authors itself, and `--hook-stage pre-push --all-files` is a full
|
||||
rehearsal of all eight. A PR merged through Gitea's merge button runs none of them: no local push
|
||||
happens at all.
|
||||
|
||||
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 one ref.
|
||||
In pre-commit 4.6.1, `_pre_push_ns` in `hook_impl.py` skips delete lines and returns on the first
|
||||
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.
|
||||
The one hook that reads the pushed ref, `check-skill-version-bump`, therefore checks 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
|
||||
all. Push one ref at a time when the gate matters.
|
||||
is checked out. Push one ref at a time when the gate matters.
|
||||
|
||||
## The pre-push gate
|
||||
|
||||
Nine hooks, grouped below by what they guard rather than by the order `.pre-commit-config.yaml` declares them in.
|
||||
Eight hooks, grouped below by what they guard rather than by the order `.pre-commit-config.yaml` declares them in.
|
||||
|
||||
**Core checks**
|
||||
|
||||
@@ -94,12 +85,6 @@ drift in generated text.
|
||||
|---|---|
|
||||
| `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**
|
||||
|
||||
| Hook | Guards |
|
||||
|---|---|
|
||||
| `check-release-needed` | on a real `git push` to `main` only — fails if files exposed via `.pre-commit-hooks.yaml` changed since the last tag. A no-op everywhere else, including under `pre-commit run --hook-stage pre-push` (see [the caveat above](#running-the-gates)) |
|
||||
|
||||
Two of these shell out to `apm`: `apm-audit-ci` and `apm-pack-check-clean`. The second is a bare
|
||||
`apm …` entry and the first is a `bash -c` loop calling `apm` once per package, so without the CLI
|
||||
the push dies with an unhelpful "command not found". Install with `apm-install`, or
|
||||
@@ -111,8 +96,7 @@ ADR-0022 makes `metadata.version` mandatory and says a skill change carries a bu
|
||||
`skill-size-check` only checks the field's presence and shape, so this hook holds the bump itself.
|
||||
|
||||
- **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
|
||||
checks it. The pushed commit is `PRE_COMMIT_TO_REF`, or `HEAD` when that is unset.
|
||||
read `PRE_COMMIT_REMOTE_BRANCH`, so the manual rehearsal really checks it. The pushed commit is `PRE_COMMIT_TO_REF`, or `HEAD` when that is unset.
|
||||
- **"Changed" is measured from the merge-base of the pushed commit with `origin/main`** (local
|
||||
`main` if `origin/main` does not resolve). Readers install from `main`, so "changed" means
|
||||
changed against the `main` the branch started from. The remote branch tip is not the baseline:
|
||||
@@ -147,7 +131,7 @@ ADR-0022 makes `metadata.version` mandatory and says a skill change carries a bu
|
||||
- **It needs `python3` and PyYAML** to read the frontmatter, and fails with a clear message if
|
||||
either is missing, for the reasons in
|
||||
[`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:** a PR merged through Gitea's merge button
|
||||
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)).
|
||||
@@ -157,9 +141,7 @@ ADR-0022 makes `metadata.version` mandatory and says a skill change carries a bu
|
||||
## Skill and agent context gates (ADR-0020)
|
||||
|
||||
The `skill-size-check` pre-commit hook, scoped to `^plugins/[^/]+/\.apm/skills/[^/]+/SKILL\.md$`,
|
||||
runs `scripts/skill-size-check.sh`. It is also shipped to external repos as
|
||||
`kyberforge-skill-size-check` (see
|
||||
[External consumers](#external-consumers-the-root-pre-commit-hooksyaml)). Besides the ADR-0020
|
||||
runs `scripts/skill-size-check.sh`. Besides the ADR-0020
|
||||
gates below, it also asserts required frontmatter is present: `name`, a non-empty `description`, and
|
||||
a `metadata.version` matching three-part semver (`1.0.0`) — folded in from a formerly standalone
|
||||
`skill-frontmatter` hook that parsed the same fields with a shell script.
|
||||
@@ -724,27 +706,20 @@ rule at a blocking bare `YES`/`error`. It is not redundant with the probes above
|
||||
(`DescriptionOpener`, `PaddingPhrase`, `SentenceOpenerThereIs`, `CompositionNote`) can each be
|
||||
overridden out of `error` underneath a passing probe. That gap is closed.
|
||||
|
||||
Two cases cover the hook manifests.
|
||||
**Case 32** covers the prefilter hooks' own scope in `.pre-commit-config.yaml`, with three
|
||||
properties. Each vale hook's `files:` regex must still match at least one tracked file; every path it
|
||||
matches must be in that hook's own artifact class; and it must match **every** tracked file of that
|
||||
class under `plugins/*/.apm/`. A hook narrowed to zero files never runs, and pre-commit reports no
|
||||
error. A hook narrowed to one plugin still matches files of the right class, which is why the third
|
||||
property exists: narrowing `vale-audit-prefilter-skill` from `^plugins/[^/]+/...` to
|
||||
`^plugins/kyberforge/...` once left 6 of 38 skills prefiltered and the whole suite green. Part B
|
||||
narrows both regexes to zero files and Part C narrows both to one plugin, each in a copy of the
|
||||
config, and requires Part A to fail by hook name.
|
||||
|
||||
**Case 33** is the original's cross-manifest `files:` drift check, ported. It extracts each vale
|
||||
hook's `files:` regex from `.pre-commit-hooks.yaml` and from `.pre-commit-config.yaml`
|
||||
*independently*, compares them per hook and never as a union, and asserts that each shared probe path
|
||||
is in scope of both or neither. The original selected each hook's record by matching `entry:`
|
||||
against the owning skill's `vale-wrap.sh` path. After the merge both hook IDs share one `entry:`, so
|
||||
the port pairs them by `id:` from an explicit table: `kyberforge-vale-audit-skill` ↔
|
||||
`vale-audit-prefilter-skill`, and `kyberforge-vale-audit-agent` ↔ `vale-audit-prefilter-agent`. A
|
||||
missing hook id or a class with no shared probe fails by name. Part B requires three mutations to
|
||||
fail: the skill hook narrowed to one plugin, the agent hook narrowed the same way, and a renamed
|
||||
local hook id.
|
||||
|
||||
This was briefly a real hole. Narrowing `vale-audit-prefilter-skill` from `^plugins/[^/]+/...` to
|
||||
`^plugins/kyberforge/...` left 6 of 38 skills prefiltered, and the whole suite green, before case 33
|
||||
existed.
|
||||
|
||||
**Case 32** covers the separate zero-match question on the local manifest alone. Each
|
||||
`.pre-commit-config.yaml` vale hook's `files:` regex must still match at least one tracked file, and
|
||||
every path it matches must be in that hook's own artifact class. A hook narrowed to zero files never
|
||||
runs, and pre-commit reports no error.
|
||||
Case 32 used to have a partner, case 33, which compared each hook's `files:` regex against the
|
||||
published `.pre-commit-hooks.yaml`. It went with that manifest (ADR-0014's 2026-09-16 amendment),
|
||||
and its one guard that did not need a second manifest, the one-plugin narrowing, is case 32's third
|
||||
property.
|
||||
|
||||
**Case 34** asks, statically and with no Vale binary, whether the shipped `.vale.ini` could load a
|
||||
style at all. Four assertions: every `[glob]` section declares a **non-empty** `BasedOnStyles`; every
|
||||
@@ -753,8 +728,8 @@ absolute**; and at least one `[glob]` section exists, so the check cannot pass v
|
||||
with nothing in it. A section whose `BasedOnStyles` is empty is the silent case — Vale lints every
|
||||
file that glob matches with no rule loaded, prints `0 errors` and exits 0. The absolute-path clause
|
||||
is the one that is not obvious: an absolute `StylesPath` passes on the machine that wrote it and
|
||||
hard-fails for every external consumer of `.pre-commit-hooks.yaml`, which is the only reason those
|
||||
styles ship at all. Part B is a mutation self-test against the same function Part A calls — it empties
|
||||
hard-fails for every repo that installs `factory-audit`, which is the only reason those styles ship
|
||||
with the skill at all. Part B is a mutation self-test against the same function Part A calls — it empties
|
||||
each section's `BasedOnStyles` in a copy of the assets, and absolutizes `StylesPath` in another
|
||||
pointed at that copy's own real `styles/` directory, and requires each to fail by name.
|
||||
|
||||
@@ -805,27 +780,6 @@ from the hook definitions. Under this model they are no-ops; adding one is not a
|
||||
The `verbose: true` escape hatch that makes `skill-size-check`'s SUGGESTION tier audible has no
|
||||
analogue here — Vale has no tier to make audible.
|
||||
|
||||
### External consumers: the root `.pre-commit-hooks.yaml`
|
||||
|
||||
The root `.pre-commit-hooks.yaml` exposes two Vale hook IDs (`kyberforge-vale-audit-skill`,
|
||||
`kyberforge-vale-audit-agent`) plus `kyberforge-skill-size-check`, so any external repo can enforce
|
||||
the same rules with `repo: <this-repo-url>, rev: <tag>` in its own `.pre-commit-config.yaml`.
|
||||
pre-commit clones the pinned rev into its own cache, independent of whether Claude Code or the
|
||||
`kyberforge` plugin is installed at all; the same mechanism covers CI via `pre-commit run
|
||||
--all-files`. `skill-size-check` has no external asset dependency, so it needed no relocation under
|
||||
ADR-0014 — only exposure.
|
||||
|
||||
**The two IDs survive the merge even though they now point at the same wrapper.** Both
|
||||
`kyberforge-vale-audit-skill` and `kyberforge-vale-audit-agent` keep their IDs and their `files:`
|
||||
regexes, because an external repo pins them by name in its own `.pre-commit-config.yaml` and
|
||||
collapsing them to one would break every such consumer silently. What changed is only the `entry:`
|
||||
target: both now name `factory-audit/scripts/vale-wrap.sh`.
|
||||
|
||||
This repo's own `vale-audit-prefilter-skill` / `-agent` hooks consume the **identical**
|
||||
plugin-bundled copy via `repo: local`. Deliberately not a second root copy, and deliberately **not a
|
||||
pinned self-reference** — a pinned self-reference would lint working-tree edits against the last
|
||||
tagged release rather than against the change being made.
|
||||
|
||||
### Pre-commit
|
||||
|
||||
Two prefilter hooks, with `.apm/`-scoped `files:` patterns:
|
||||
@@ -845,9 +799,10 @@ authors. Without the binary the hooks fail with a bare "command not found" and n
|
||||
reason was mechanical: with a config per skill, a single hook could point at only one copy and would
|
||||
silently 0-file-skip the other file shape (see
|
||||
[A 0-file Vale run is NOT RUN](#a-0-file-vale-run-is-not-run)). One `.vale.ini` carrying all three
|
||||
sections removes that constraint. The split stays anyway because the two IDs are an exported
|
||||
contract external consumers pin by name, and because the `files:` regexes still have to differ —
|
||||
each hook hands Vale only the file shape it is scoped to.
|
||||
sections removes that constraint. The split stays anyway because the `files:` regexes still have to
|
||||
differ — each hook hands Vale only the file shape it is scoped to. Both hooks name the same
|
||||
plugin-bundled `factory-audit/scripts/vale-wrap.sh` through `repo: local`; there is no second root
|
||||
copy.
|
||||
|
||||
### The `.vale.ini` globs do no scoping
|
||||
|
||||
@@ -856,15 +811,12 @@ The `.vale.ini`'s section globs are **path-agnostic** — `[**/SKILL.md]`, `[**/
|
||||
location: Vale's `*` crosses `/`. A `SKILL.md` outside `plugins/` (a project-scope
|
||||
`.claude/skills/foo/SKILL.md`, say) still matches `[**/SKILL.md]` and gets linted normally.
|
||||
|
||||
All scoping therefore comes from the pre-commit hook's own `files:` regex and from `factory-audit`
|
||||
passing one explicit file per invocation. The two manifests scope **differently on purpose**:
|
||||
All scoping therefore comes from the pre-commit hooks' own `files:` regexes, which pin this repo's
|
||||
layout (see [Pre-commit](#pre-commit)), and from `factory-audit` passing one explicit file per
|
||||
invocation — in this repo or in any repo that installs it, whatever that repo's layout.
|
||||
|
||||
| Manifest | `-skill` | `-agent` |
|
||||
|---|---|---|
|
||||
| `.pre-commit-config.yaml` (pins this repo's layout) | `^plugins/[^/]+/\.apm/skills/[^/]+/SKILL\.md$` | `^plugins/[^/]+/\.apm/agents/[^/]+\.agent\.md$` |
|
||||
| `.pre-commit-hooks.yaml` (layout-agnostic for consumers) | `(^\|/)SKILL\.md$` | `(^\|/)agents/[^/]+\.md$\|\.agent\.md$` |
|
||||
|
||||
Narrowing a `.vale.ini` glob to a `plugins/`-shaped path to "tighten" it breaks the consumer case.
|
||||
Narrowing a `.vale.ini` glob to a `plugins/`-shaped path to "tighten" it breaks the consumer case:
|
||||
`factory-audit` run against a project-scope `.claude/skills/` tree would lint nothing.
|
||||
`check-vale-style-sync`'s probe set was built to catch exactly that; it moved to
|
||||
`tests/test-vale-wrap.sh` with the hook's deletion, and two of the six probes exist specifically to
|
||||
pin this location independence — see [One copy, one config](#one-copy-one-config).
|
||||
@@ -897,10 +849,6 @@ config declines to match, which is the [0-file NOT RUN](#a-0-file-vale-run-is-no
|
||||
green run that measured nothing. **Issue #117** records the style-scope half; the hook half has to
|
||||
land in the same change or the fix is cosmetic.
|
||||
|
||||
The consumer manifest is a third axis and does not rescue this either: `.pre-commit-hooks.yaml`'s
|
||||
`(^|/)SKILL\.md$` is layout-agnostic but still filename-shaped, so an external repo running
|
||||
`kyberforge-vale-audit-skill` has the same gap.
|
||||
|
||||
### `vale-wrap.sh`, never bare `vale`
|
||||
|
||||
`factory-audit`'s Step 1 and both pre-commit hooks call
|
||||
@@ -925,19 +873,19 @@ wrapper a bad description in any of the three broken forms sailed straight throu
|
||||
### The `--config` argv defect
|
||||
|
||||
Handed **no `--config` at all**, the wrapper falls back to its own sibling `assets/vale/.vale.ini`,
|
||||
located from `${BASH_SOURCE[0]}` rather than from the cwd. That is why both manifests' `entry:` is
|
||||
now the bare script path with **no argument after it**.
|
||||
located from `${BASH_SOURCE[0]}` rather than from the cwd. That is why both hooks' `entry:` is
|
||||
the bare script path with **no argument after it**.
|
||||
|
||||
pre-commit prefixes only `entry[0]` with the hook-repo clone path (`cmd = (prefix.path(cmd[0]),
|
||||
*cmd[1:])`), so every later argument resolves against the **consuming** repo's root. A `--config` in
|
||||
`.pre-commit-hooks.yaml` therefore pointed at a path no consumer has and hard-failed every external
|
||||
run with `E100 [--config] Runtime error`.
|
||||
the since-retired `.pre-commit-hooks.yaml` therefore pointed at a path no consumer has and
|
||||
hard-failed every external run with `E100 [--config] Runtime error`.
|
||||
|
||||
`.pre-commit-config.yaml` drops the argument too, deliberately keeping the two entries identical.
|
||||
`.pre-commit-config.yaml` drops the argument too, deliberately matching that entry.
|
||||
The local `repo: local` hook resolved its `--config` correctly only because the consuming repo *was*
|
||||
this repo — and that divergence is why three review rounds exercised a path no external consumer
|
||||
takes and missed the defect. **Do not reintroduce a `--config` to either manifest to make the local
|
||||
run "explicit".**
|
||||
takes and missed the defect. **Do not reintroduce a `--config` to either hook to make the local run
|
||||
"explicit"**, and keep a restored published manifest to `entry[0]` alone (ADR-0014).
|
||||
|
||||
An explicit `--config` from any other caller still wins, in all three argv forms (`--config X`,
|
||||
`--config=/abs`, `--config=rel`), and a relative one resolves against the caller's cwd — matching
|
||||
@@ -973,12 +921,12 @@ clean.
|
||||
`CHECK_VALE_STYLE_SYNC_ALLOW_MISSING_VALE=1` opt-out downgraded them audibly rather than skipping the
|
||||
hook — is deleted with the second Vale copy (ADR-0025). The six glob probes survive it inside
|
||||
`test-vale-wrap.sh`, so `run-tests --strict` is now the gate that runs them. That is also what keeps
|
||||
`vale` a pre-push requirement: `test-vale-hooks-consumer.sh` exits 77 without the binary, and so does
|
||||
`test-vale-wrap.sh` once its static cases pass, and a skip fails the push.
|
||||
`vale` a pre-push requirement: `test-vale-wrap.sh` exits 77 without the binary once its static cases
|
||||
pass, and a skip fails the push.
|
||||
|
||||
`test-vale-wrap.sh` without Vale skips only its Vale-dependent cases, not the whole suite. The cases
|
||||
that are plain greps and awk over the config and the two hook manifests still run: case 0, 16, 26,
|
||||
27, the static halves of 28, 31 Parts A and B, 32, 33 and 34. A static failure exits 1, because a
|
||||
that are plain greps and awk over the Vale config and `.pre-commit-config.yaml` still run: case 0, 16, 26,
|
||||
27, the static half of 28, 31 Parts A and B, 32 and 34. A static failure exits 1, because a
|
||||
real defect is not a setup error. Only an all-static-pass run exits 77.
|
||||
|
||||
### Mentioning banned phrasing without tripping the rule
|
||||
@@ -1005,10 +953,11 @@ run. The pre-push hook invokes the same script as `--strict` (`RUN_TESTS_STRICT=
|
||||
where a skip **does** fail the push: at pre-push a skip means one of the documented dependencies is
|
||||
absent on this machine, so the gate would otherwise report success having run fewer suites than it
|
||||
appears to. Without `--strict` the gate once went green having verified 15 of 17 suites on a
|
||||
vale-less PATH, with the skip list swallowed. Without vale, two suites skip —
|
||||
`test-vale-hooks-consumer.sh` and `test-vale-wrap.sh` — and the strict failure names each one and
|
||||
what to install. (It was three until `test-check-vale-style-sync.sh` was deleted with its hook; see
|
||||
[One copy, one config](#one-copy-one-config).)
|
||||
vale-less PATH, with the skip list swallowed. Without vale, one suite skips — `test-vale-wrap.sh` —
|
||||
and the strict failure names it and what to install. (It was three until
|
||||
`test-check-vale-style-sync.sh` was deleted with its hook — see
|
||||
[One copy, one config](#one-copy-one-config) — and `test-vale-hooks-consumer.sh` with the published
|
||||
hook manifest.)
|
||||
|
||||
**Output assertions use a here-string, never a pipe.** Write `grep -q PATTERN <<< "$OUT"`, not
|
||||
`echo "$OUT" | grep -q PATTERN`. Under `set -o pipefail` the pipe form fails depending on timing:
|
||||
|
||||
@@ -45,8 +45,8 @@ IFS='' read -r -d '' KYBERFORGE_RESOLVER_PY <<'KYBERFORGE_ADR0020_RESOLVER_PY' |
|
||||
# plugins/kyberforge/.apm/skills/factory-audit/scripts/lib-boundary-resolver.sh
|
||||
# The block between these markers must stay byte-identical in both. It is copied
|
||||
# rather than imported because a cache-installed plugin's scripts cannot read
|
||||
# files outside their own plugin directory, and this repo-root hook resolves via
|
||||
# .pre-commit-hooks.yaml, where entry[0] is the only token pre-commit rewrites --
|
||||
# files outside their own plugin directory, and this repo-root hook is kept fit for
|
||||
# a published hook manifest (retired; ADR-0014), where only entry[0] is rewritten --
|
||||
# so no single file is reachable by both (the same constraint that duplicates the
|
||||
# ADR-0020 constants). Edit one copy, then paste it over the other.
|
||||
#
|
||||
|
||||
@@ -29,10 +29,11 @@ set -euo pipefail
|
||||
#
|
||||
# Divergence 1: with no `--config` at all, this script's own sibling
|
||||
# `assets/vale/.vale.ini` is used instead of vale's upward search. pre-commit
|
||||
# prefixes only `entry[0]` with the hook-repo clone path, so a `--config` in
|
||||
# `.pre-commit-hooks.yaml` would resolve against the *consuming* repo and
|
||||
# hard-fail (E100) for every external consumer. The manifest therefore passes the
|
||||
# script alone, and an explicit `--config` from any other caller still wins.
|
||||
# prefixes only `entry[0]` with the hook-repo clone path, so a `--config` in a
|
||||
# published `.pre-commit-hooks.yaml` would resolve against the *consuming* repo
|
||||
# and hard-fail (E100) for every external consumer. That manifest is retired
|
||||
# (ADR-0014, 2026-09-16 amendment), but the script still needs no `--config` so it
|
||||
# can return; an explicit `--config` from any other caller still wins.
|
||||
#
|
||||
# Divergence 2: a path-shaped argument that does not exist is a hard error
|
||||
# (exit 2). Bare vale drops it, falls back to reading stdin, and prints
|
||||
|
||||
@@ -1989,7 +1989,7 @@ EOF
|
||||
# NEW with the merge and additive: pre-merge, handing the SKILL.md itself to
|
||||
# skill-audit's validate-provenance.sh hit the "not a directory" precondition
|
||||
# and died. It matters because pre-commit `files:` hooks match FILES — the
|
||||
# exported kyberforge-vale-audit-skill hook's regex is (^|/)SKILL\.md$ — so
|
||||
# vale-audit-prefilter-skill hook's regex ends in /SKILL\.md$ — so
|
||||
# every hook-driven invocation hands over a SKILL.md path, never its
|
||||
# directory. The entry point rewrites the token to the directory in place.
|
||||
local skill="$TMPDIR/my-skill"
|
||||
|
||||
@@ -1016,8 +1016,8 @@ EOF
|
||||
# NEW with the merge and additive rather than ported: pre-merge, handing the
|
||||
# SKILL.md itself to skill-audit's validate.sh hit the directory precondition
|
||||
# and gave a useless exit 1. It matters because pre-commit `files:` hooks
|
||||
# match FILES — the exported kyberforge-vale-audit-skill hook's regex is
|
||||
# (^|/)SKILL\.md$ — so every hook-driven invocation hands over a SKILL.md
|
||||
# match FILES — the vale-audit-prefilter-skill hook's regex ends in
|
||||
# /SKILL\.md$ — so every hook-driven invocation hands over a SKILL.md
|
||||
# path, never the directory above it. The entry point resolves the file to
|
||||
# its directory before dispatching.
|
||||
local skill="$TMPDIR/my-skill"
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Hard-fails only when pushing to main: if any file covered by .pre-commit-hooks.yaml
|
||||
# (the external git-hook/CI contract, see ADR-0014) changed since the last tag,
|
||||
# a release must be cut before landing on main, or external consumers pinning
|
||||
# `rev: <tag>` silently miss the change. Pre-commit sets PRE_COMMIT_REMOTE_BRANCH
|
||||
# for pre-push hooks; on every other branch (feature work mid-review) this is a
|
||||
# silent no-op — pushing WIP commits there must not be blocked on cutting a
|
||||
# premature tag (see ADR-0014's repo: local vs pinned self-reference decision).
|
||||
#
|
||||
# Known gap: this only fires on a local `git push` through pre-commit's pre-push
|
||||
# hook. A PR merged via Gitea's merge button (server-side, no local push) or a
|
||||
# CI runner invoking `pre-commit run --hook-stage pre-push` directly does not set
|
||||
# PRE_COMMIT_REMOTE_BRANCH and will not trigger this check — closing that
|
||||
# requires a server-side CI job, which this repo does not have yet.
|
||||
|
||||
TARGET_BRANCH="refs/heads/main"
|
||||
|
||||
if [[ "${PRE_COMMIT_REMOTE_BRANCH:-}" != "$TARGET_BRANCH" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# What is actually being pushed, which is only HEAD for the common
|
||||
# `git push <remote> <current-branch>` case. pre-commit's pre-push hook-impl
|
||||
# exports the local sha of each pushed ref as PRE_COMMIT_TO_REF; a
|
||||
# `git push <remote> topic:main` from a different checkout would otherwise be
|
||||
# gated on the wrong tip — a false negative when HEAD is behind the pushed ref
|
||||
# (unreleased changes sail through), a false positive when it is ahead.
|
||||
# PRE_COMMIT_FROM_REF, the *remote's* current tip, is deliberately not used
|
||||
# anywhere here: the baseline is the last release tag, not what the remote
|
||||
# already has. Diffing from the remote tip would let an untagged
|
||||
# release-relevant commit already on main excuse the next push from cutting a
|
||||
# tag, which is precisely the drift this gate exists to catch.
|
||||
PUSHED_REF="${PRE_COMMIT_TO_REF:-HEAD}"
|
||||
|
||||
# pre-commit passes an all-zeros sha (40 hex zeros under sha1, 64 under sha256)
|
||||
# as the "to" ref when the push deletes a branch. Nothing is being shipped, and
|
||||
# every rev-taking command below would fail on an unresolvable sha, so bail out
|
||||
# rather than turning a branch deletion into a confusing "could not diff".
|
||||
if [[ "$PUSHED_REF" =~ ^0+$ ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
HOOKS_MANIFEST=".pre-commit-hooks.yaml"
|
||||
|
||||
if [[ ! -f "$HOOKS_MANIFEST" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Only vX.Y.Z release tags count as a baseline — an incidental checkpoint or
|
||||
# experiment tag reachable from the pushed ref must not shift the diff baseline.
|
||||
# The tag is resolved from $PUSHED_REF, not HEAD, for the same reason the diff
|
||||
# is: a tag reachable only from HEAD is not part of the history being pushed.
|
||||
# --match is a shell glob, not a regex: its trailing `*`s match any suffix, so
|
||||
# without --exclude a pre-release/checkpoint tag like v1.2.3-checkpoint or
|
||||
# v1.2.3-rc1 also satisfies 'v[0-9]*.[0-9]*.[0-9]*' and could be picked over the
|
||||
# true last release tag. --exclude is glob syntax too, so '*-*' is what actually
|
||||
# rules out any tag carrying a hyphenated suffix, leaving only bare vMAJOR.MINOR.PATCH.
|
||||
LAST_TAG="$(git describe --tags --abbrev=0 --match 'v[0-9]*.[0-9]*.[0-9]*' --exclude '*-*' "$PUSHED_REF" 2>/dev/null || true)"
|
||||
|
||||
if [[ -z "$LAST_TAG" ]]; then
|
||||
echo "FAIL: no release tag exists yet, but .pre-commit-hooks.yaml already exposes hooks to external consumers." >&2
|
||||
echo " Fix: cut the first release tag (e.g. v1.0.0) before this lands on main." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Derive release-relevant paths from .pre-commit-hooks.yaml's own entry: lines
|
||||
# instead of hand-maintaining a parallel list — the manifest is the single
|
||||
# source of truth for what external consumers actually pull at a pinned rev,
|
||||
# so a hook added/removed/renamed there can't silently drift out of sync here.
|
||||
# Everything is derived from tokens[0], the hook's script: pre-commit prefixes
|
||||
# only entry[0] with the hook-repo clone path, so any later token that looks
|
||||
# like a path resolves against the *consuming* repo and can never name a file
|
||||
# this repo ships. A hook's bundled data therefore has to be self-located
|
||||
# relative to the script — vale-wrap.sh reads its own
|
||||
# <script-dir>/../assets/vale/.vale.ini plus the sibling styles/ tree — which
|
||||
# makes <script-dir>/../assets release-relevant alongside the script itself.
|
||||
# The ../ is normalised by stripping a path component rather than with
|
||||
# `realpath -m`, which is a GNU-only extension. Two guards keep the derivation
|
||||
# from inventing paths: a bundle root of "." is skipped, because a script in a
|
||||
# top-level directory (scripts/skill-size-check.sh) would derive the repo's own
|
||||
# shared assets/, which no hook owns and whose churn must not demand a release;
|
||||
# and the assets/ directory is added only where it is known to exist, since a
|
||||
# hook that bundles nothing must not contribute a pathspec matching nothing.
|
||||
RELEASE_PATHS=("$HOOKS_MANIFEST")
|
||||
|
||||
add_release_path() {
|
||||
local candidate="$1" existing
|
||||
for existing in "${RELEASE_PATHS[@]}"; do
|
||||
[[ "$existing" == "$candidate" ]] && return 0
|
||||
done
|
||||
RELEASE_PATHS+=("$candidate")
|
||||
}
|
||||
|
||||
# Emits one "<hook id><TAB><entry value>" line per hook so a rejected entry can
|
||||
# name the hook a human has to go fix. The id sits on its own line above its
|
||||
# entry: in YAML, so it is carried forward and then cleared; a hook that somehow
|
||||
# has no id still reports something printable rather than an empty name. Kept in
|
||||
# bash rather than awk: matching `[[:space:]]` inside a bracket expression is
|
||||
# reliable in bash's own globs but not in the BWK awk macOS ships. `read -r` with
|
||||
# a single variable is the trimmer — it strips leading and trailing whitespace
|
||||
# while preserving anything in between, so a multi-token entry survives intact
|
||||
# for the error message to quote back.
|
||||
manifest_entries() {
|
||||
local line id="" value
|
||||
while IFS= read -r line; do
|
||||
# Drop the indentation and the optional list dash, so that `- id: x` and
|
||||
# ` entry: y` both reduce to the same bare "key: value" shape.
|
||||
line="${line#"${line%%[![:space:]]*}"}"
|
||||
if [[ "$line" == -* ]]; then
|
||||
line="${line#-}"
|
||||
line="${line#"${line%%[![:space:]]*}"}"
|
||||
fi
|
||||
case "$line" in
|
||||
id:*)
|
||||
read -r id <<< "${line#id:}"
|
||||
;;
|
||||
entry:*)
|
||||
read -r value <<< "${line#entry:}"
|
||||
printf '%s\t%s\n' "${id:-(unnamed hook)}" "$value"
|
||||
id=""
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# A hook's script is legitimate if it exists in the working tree *or* at
|
||||
# $LAST_TAG — the same union the pathspec itself spans. Checking per-scope
|
||||
# instead would reject exactly the case this gate exists to flag: a script
|
||||
# deleted since the tag while its entry survives (see the no -e filtering note
|
||||
# further down) is a real deletion to report, not a malformed manifest.
|
||||
entry_path_exists() {
|
||||
local candidate="$1"
|
||||
[[ -e "$candidate" ]] && return 0
|
||||
git cat-file -e "$LAST_TAG:$candidate" 2>/dev/null && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
# $1 selects where the "does this hook bundle an assets/ tree?" guard looks:
|
||||
# "worktree" probes the filesystem, anything else is a rev whose tree is probed
|
||||
# with git plumbing. Reading entry lines from stdin keeps one derivation for
|
||||
# both the tagged manifest and the current one.
|
||||
collect_release_paths() {
|
||||
local scope="$1" line hook_id entry bundle_root where
|
||||
local -a tokens
|
||||
if [[ "$scope" == "worktree" ]]; then
|
||||
where="the working tree's $HOOKS_MANIFEST"
|
||||
else
|
||||
where="$HOOKS_MANIFEST at $scope"
|
||||
fi
|
||||
while IFS= read -r line; do
|
||||
hook_id="${line%%$'\t'*}"
|
||||
entry="${line#*$'\t'}"
|
||||
read -ra tokens <<< "$entry"
|
||||
[[ ${#tokens[@]} -eq 0 ]] && continue
|
||||
# ADR-0014 binds every entry to a bare script path and nothing else, because
|
||||
# pre-commit rewrites only entry[0] into the hook-repo clone. That is a
|
||||
# constraint nothing else enforces, and the sibling .pre-commit-config.yaml
|
||||
# already ships the multi-token `bash <script>` shape one copy-paste away —
|
||||
# so an entry like `bash scripts/foo.sh` would add "bash" as a pathspec that
|
||||
# matches nothing and derive a bundle root of ".", dropping that hook's
|
||||
# entire surface out of the gate silently. Both malformed shapes below fail
|
||||
# loudly instead: silent degradation here is the same class of defect as the
|
||||
# --config token already recorded in LESSONS.md.
|
||||
if [[ ${#tokens[@]} -gt 1 ]]; then
|
||||
echo "FAIL: hook '$hook_id' in $where has a multi-token entry: $entry" >&2
|
||||
echo " Why: pre-commit rewrites only entry[0] into the hook-repo clone, so every later" >&2
|
||||
echo " token resolves against the *consuming* repo and can never name a file this" >&2
|
||||
echo " repo ships — and this gate would derive its release paths from '${tokens[0]}'." >&2
|
||||
echo " Fix: make the entry a bare script path and have the script self-locate anything" >&2
|
||||
echo " else from \${BASH_SOURCE[0]} (see ADR-0014, 'Consequences')." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! entry_path_exists "${tokens[0]}"; then
|
||||
echo "FAIL: hook '$hook_id' in $where names a path that exists neither in the working tree nor at $LAST_TAG: ${tokens[0]}" >&2
|
||||
echo " Why: this gate derives its release-relevant pathspec from that path, so a name" >&2
|
||||
echo " that resolves to no file silently drops the hook's whole surface from the diff." >&2
|
||||
echo " Fix: point the entry at a script path this repo actually ships (see ADR-0014," >&2
|
||||
echo " 'Consequences'); a bare command name is not a valid entry here." >&2
|
||||
exit 1
|
||||
fi
|
||||
add_release_path "${tokens[0]}"
|
||||
bundle_root="$(dirname "$(dirname "${tokens[0]}")")"
|
||||
[[ "$bundle_root" == "." ]] && continue
|
||||
if [[ "$scope" == "worktree" ]]; then
|
||||
[[ -d "$bundle_root/assets" ]] && add_release_path "$bundle_root/assets"
|
||||
else
|
||||
git cat-file -e "$scope:$bundle_root/assets" 2>/dev/null && add_release_path "$bundle_root/assets"
|
||||
fi
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
# The worktree alone is not enough: a path is release-relevant if it was part of
|
||||
# the contract at $LAST_TAG *or* is part of it now, so both trees have to be
|
||||
# derived and unioned. Deriving only from the worktree meant that deleting a
|
||||
# hook's entire assets/ tree made the `-d` guard drop the path from the pathspec
|
||||
# altogether, and the deletion — which breaks every consumer at the next rev —
|
||||
# diffed clean. The two manifests can genuinely disagree (an entry added,
|
||||
# removed, or renamed since the tag), and the union is the conservative side of
|
||||
# that disagreement: a path the tag exposed and HEAD no longer does is a removal
|
||||
# consumers must be told about, and a path only HEAD exposes is new contract
|
||||
# surface they cannot reach without a new tag. The union never over-fires on its
|
||||
# own, either — any manifest edit that makes the two disagree already changes
|
||||
# $HOOKS_MANIFEST, which is itself a release-relevant path.
|
||||
collect_release_paths worktree < <(manifest_entries < "$HOOKS_MANIFEST")
|
||||
|
||||
# A missing manifest at the tag is legitimate (the manifest was added since) but
|
||||
# is indistinguishable from an unreadable tagged tree by its exit status alone,
|
||||
# so the tag's root tree is verified separately. An absent tree object — a
|
||||
# shallow clone, a truncated fetch — fails closed exactly like a `git diff`
|
||||
# failure does, rather than silently degrading to worktree-only derivation.
|
||||
if MANIFEST_AT_TAG="$(git cat-file -p "$LAST_TAG:$HOOKS_MANIFEST" 2>/dev/null)"; then
|
||||
collect_release_paths "$LAST_TAG" < <(printf '%s\n' "$MANIFEST_AT_TAG" | manifest_entries)
|
||||
elif ! git cat-file -e "$LAST_TAG^{tree}" 2>/dev/null; then
|
||||
echo "FAIL: could not read the tree at $LAST_TAG to determine which paths that release exposed." >&2
|
||||
echo " Fix: ensure full tag history is available (e.g. git fetch --unshallow) and retry." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# No -e/existence filtering on the pathspec: a path deleted since $LAST_TAG is
|
||||
# exactly the case that must be caught (external consumers pinning the old tag
|
||||
# would hit a missing file), and `git diff` reports deletions fine without it
|
||||
# existing at the pushed ref. A git failure (e.g. a shallow clone missing
|
||||
# $LAST_TAG's history) must fail closed, not be swallowed into an empty,
|
||||
# falsely-clean diff.
|
||||
if ! CHANGED="$(git diff --name-only "$LAST_TAG".."$PUSHED_REF" -- "${RELEASE_PATHS[@]}")"; then
|
||||
echo "FAIL: could not diff $LAST_TAG..$PUSHED_REF to check for release-relevant changes (see git error above)." >&2
|
||||
echo " Fix: ensure full tag history is available (e.g. git fetch --unshallow) and retry." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -n "$CHANGED" ]]; then
|
||||
echo "FAIL: files covered by .pre-commit-hooks.yaml changed since $LAST_TAG:" >&2
|
||||
echo "$CHANGED" | sed 's/^/ /' >&2
|
||||
echo " Fix: cut a new release tag — external consumers pinning rev: $LAST_TAG would miss this change." >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -79,9 +79,9 @@ set -euo pipefail
|
||||
# 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
|
||||
# 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
|
||||
# gap check-release-needed has). Closing it requires a server-side CI job,
|
||||
# which this repo does not have yet.
|
||||
# - A PR merged via Gitea's merge button runs no local hook at all.
|
||||
# Closing it requires a server-side CI job, which this repo does not have
|
||||
# yet.
|
||||
|
||||
# Byte-wise regex matching and messages: path bytes are matched against
|
||||
# SKILL_PATH_RE below and must not depend on the caller's locale.
|
||||
|
||||
@@ -109,10 +109,9 @@ FAIL=0
|
||||
|
||||
# ZERO ARGUMENTS IS A USAGE ERROR, exit 2 — not a clean run.
|
||||
#
|
||||
# This hook is `pass_filenames: true` in both .pre-commit-config.yaml and
|
||||
# .pre-commit-hooks.yaml, and pre-commit skips a filename-passing hook entirely
|
||||
# when nothing matches its `files:` pattern, so it never invokes this script
|
||||
# with an empty argument list. Every no-argument invocation therefore comes from
|
||||
# This hook is `pass_filenames: true` in .pre-commit-config.yaml, and
|
||||
# pre-commit skips a filename-passing hook entirely when nothing matches its
|
||||
# `files:` pattern, so it never invokes this script with an empty argument list. Every no-argument invocation therefore comes from
|
||||
# somewhere else — a hand-run command, a wrapper, or a `files:` pattern edited
|
||||
# into matching nothing — and printing nothing and exiting 0 made all three
|
||||
# indistinguishable from a clean corpus. A mis-scoped pattern would have
|
||||
@@ -236,8 +235,8 @@ def info(msg):
|
||||
# plugins/kyberforge/.apm/skills/factory-audit/scripts/lib-boundary-resolver.sh
|
||||
# The block between these markers must stay byte-identical in both. It is copied
|
||||
# rather than imported because a cache-installed plugin's scripts cannot read
|
||||
# files outside their own plugin directory, and this repo-root hook resolves via
|
||||
# .pre-commit-hooks.yaml, where entry[0] is the only token pre-commit rewrites --
|
||||
# files outside their own plugin directory, and this repo-root hook is kept fit for
|
||||
# a published hook manifest (retired; ADR-0014), where only entry[0] is rewritten --
|
||||
# so no single file is reachable by both (the same constraint that duplicates the
|
||||
# ADR-0020 constants). Edit one copy, then paste it over the other.
|
||||
#
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
# ADR-0025 merged skill-audit and agent-audit, which dropped the count from
|
||||
# three copies to two: factory-audit now holds ONE copy in a sourced
|
||||
# lib-boundary-resolver.sh, and scripts/skill-size-check.sh keeps its
|
||||
# embedded copy because it is a repo-root hook consumed through
|
||||
# .pre-commit-hooks.yaml, where entry[0] is the only token pre-commit
|
||||
# rewrites — it cannot reach a file inside the plugin at a path any consumer
|
||||
# has. Nothing but this file asserts the two copies are still identical, and
|
||||
# embedded copy because it is a repo-root hook kept fit for a published
|
||||
# hook manifest (retired; ADR-0014), where entry[0] is the only token
|
||||
# pre-commit rewrites — it could not reach a file inside the plugin at a
|
||||
# path any consumer has. Nothing but this file asserts the two copies are still identical, and
|
||||
# a one-line edit to a single copy is invisible: every constant-agreement
|
||||
# assertion in tests/test-skill-size-check.sh still passes, because the
|
||||
# CONSTANTS are not what drifted.
|
||||
@@ -590,14 +590,14 @@ for probe in "scripts/skill-size-check.sh|$HOOK|$SUBJECT_SKILL_DIR/SKILL.md" \
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. verbose: true on the skill-size-check hook, in BOTH manifests
|
||||
# 3. verbose: true on the skill-size-check hook
|
||||
# ---------------------------------------------------------------------------
|
||||
# .pre-commit-config.yaml governs this repo; .pre-commit-hooks.yaml is what a
|
||||
# CONSUMER repo gets when it points at this one. Dropping the flag from either
|
||||
# silences the SUGGESTION tier for that audience alone, which is the hardest
|
||||
# version of the defect to notice.
|
||||
# pre-commit prints nothing for a passing hook, so dropping the flag silences
|
||||
# the SUGGESTION tier without failing anything. The published
|
||||
# .pre-commit-hooks.yaml that once carried a second copy of this hook was
|
||||
# retired (ADR-0014, 2026-09-16 amendment); if it returns, assert it here too.
|
||||
echo ""
|
||||
echo "--- the skill-size-check hook declares verbose: true in both manifests ---"
|
||||
echo "--- the skill-size-check hook declares verbose: true ---"
|
||||
VERBOSE_REPORT="$(python3 - "$REPO_ROOT" <<'PY'
|
||||
import os
|
||||
import sys
|
||||
@@ -632,27 +632,6 @@ else:
|
||||
emit('FAIL', '.pre-commit-config.yaml: skill-size-check has verbose=%r — '
|
||||
'pre-commit prints nothing for a passing hook, so every '
|
||||
'ADR-0020 SUGGESTION is swallowed' % (found.get('verbose'),))
|
||||
|
||||
# Consumer manifest: a flat list of hooks.
|
||||
path = os.path.join(root, '.pre-commit-hooks.yaml')
|
||||
try:
|
||||
with open(path, encoding='utf-8') as fh:
|
||||
hooks = yaml.safe_load(fh) or []
|
||||
except Exception as exc:
|
||||
emit('FAIL', '.pre-commit-hooks.yaml did not parse: %s' % exc)
|
||||
hooks = []
|
||||
found = None
|
||||
for hook in hooks:
|
||||
if isinstance(hook, dict) and hook.get('id') == 'kyberforge-skill-size-check':
|
||||
found = hook
|
||||
if found is None:
|
||||
emit('FAIL', '.pre-commit-hooks.yaml declares no hook with id kyberforge-skill-size-check')
|
||||
elif found.get('verbose') is True:
|
||||
emit('PASS', '.pre-commit-hooks.yaml: kyberforge-skill-size-check is verbose: true')
|
||||
else:
|
||||
emit('FAIL', '.pre-commit-hooks.yaml: kyberforge-skill-size-check has verbose=%r — '
|
||||
'a consumer repo would never see the SUGGESTION tier'
|
||||
% (found.get('verbose'),))
|
||||
PY
|
||||
)"
|
||||
while IFS=$'\t' read -r status msg; do
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# and agent-audit behind one auto-detecting entry point; every fixture here is a
|
||||
# skill directory, so every invocation below runs the skill flow. The agent flow
|
||||
# has no counterpart hook to differ from — there is no agent-file size gate in
|
||||
# .pre-commit-hooks.yaml — so it is out of this suite's scope, not dropped from it.
|
||||
# .pre-commit-config.yaml — so it is out of this suite's scope, not dropped from it.
|
||||
#
|
||||
# Why this exists as a separate suite. tests/test-skill-size-check.sh already
|
||||
# asserts the two agree on their CONSTANTS, and that assertion is necessary but
|
||||
|
||||
@@ -1,449 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
SCRIPT="$REPO_ROOT/scripts/check-release-needed.sh"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
pass() { echo " PASS: $1"; PASS=$((PASS + 1)); }
|
||||
fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); }
|
||||
|
||||
# Output assertions are `grep -q PATTERN <<< "$OUT"`, never `echo "$OUT" | grep -q`.
|
||||
# Under pipefail the pipe form is scheduling-dependent: bash's echo writes a
|
||||
# multi-line value one line at a time, `grep -q` exits on its first match, and a
|
||||
# later line then hits a closed pipe. echo dies of SIGPIPE, pipefail reports the
|
||||
# pipeline as failed, and a correct output reads as a missing match. The
|
||||
# here-string has no writer process to race.
|
||||
|
||||
# Both entry shapes the real .pre-commit-hooks.yaml ships: a bare script with no
|
||||
# bundled data, and a bare script whose sibling assets/ tree it self-locates at
|
||||
# runtime. Neither carries arguments — pre-commit only rewrites entry[0] to the
|
||||
# hook-repo clone path, so an argument path would resolve against the consuming
|
||||
# repo. RELEASE_PATHS is derived from the manifest rather than hand-maintained,
|
||||
# so it has to cope with both.
|
||||
HOOK_DIR="plugins/demo/skills/demo-audit"
|
||||
|
||||
write_manifest() {
|
||||
local dir="$1"
|
||||
cat > "$dir/.pre-commit-hooks.yaml" <<EOF
|
||||
- id: fake-size-check
|
||||
entry: scripts/skill-size-check.sh
|
||||
language: script
|
||||
- id: fake-vale-check
|
||||
entry: $HOOK_DIR/scripts/vale-wrap.sh
|
||||
language: script
|
||||
EOF
|
||||
}
|
||||
|
||||
# Helper: writes the files both manifest entries expose — the two hook scripts
|
||||
# plus the bundled Vale config and style rule the second one self-locates.
|
||||
write_release_paths() {
|
||||
local dir="$1"
|
||||
mkdir -p "$dir/scripts" "$dir/$HOOK_DIR/scripts" "$dir/$HOOK_DIR/assets/vale/styles/Kyberforge"
|
||||
echo "v1" > "$dir/scripts/skill-size-check.sh"
|
||||
echo "v1" > "$dir/$HOOK_DIR/scripts/vale-wrap.sh"
|
||||
echo "cfg" > "$dir/$HOOK_DIR/assets/vale/.vale.ini"
|
||||
echo "rule: v1" > "$dir/$HOOK_DIR/assets/vale/styles/Kyberforge/DemoRule.yml"
|
||||
}
|
||||
|
||||
# Helper: a fixture repo with a manifest and every release-relevant path it
|
||||
# exposes, committed and tagged v1.0.0.
|
||||
make_tagged_fixture() {
|
||||
local dir
|
||||
dir="$(mktemp -d)"
|
||||
(cd "$dir" && git init -q && git config user.email t@t.t && git config user.name t)
|
||||
write_manifest "$dir"
|
||||
write_release_paths "$dir"
|
||||
(cd "$dir" && git add -A && git commit -q -m "initial" && git tag v1.0.0)
|
||||
echo "$dir"
|
||||
}
|
||||
|
||||
# Helper: a fixture whose manifest carries one malformed entry: at the tag *and*
|
||||
# at HEAD, plus a post-tag change to the file that entry was meant to cover.
|
||||
# Committing the bad entry before the tag is what makes the assertion sharp — an
|
||||
# edited manifest is itself release-relevant, so the gate would fail for the
|
||||
# wrong reason and hide a parser that degrades silently.
|
||||
make_malformed_fixture() {
|
||||
local entry="$1" dir
|
||||
dir="$(mktemp -d)"
|
||||
(cd "$dir" && git init -q && git config user.email t@t.t && git config user.name t)
|
||||
write_release_paths "$dir"
|
||||
cat > "$dir/.pre-commit-hooks.yaml" <<EOF
|
||||
- id: fake-size-check
|
||||
entry: $entry
|
||||
language: script
|
||||
EOF
|
||||
(cd "$dir" && git add -A && git commit -q -m "initial" && git tag v1.0.0)
|
||||
echo "v2" > "$dir/scripts/skill-size-check.sh"
|
||||
(cd "$dir" && git add -A && git commit -q -m "change the file the malformed entry should cover")
|
||||
echo "$dir"
|
||||
}
|
||||
|
||||
# $3 is optional: pre-commit's PRE_COMMIT_TO_REF, the local sha being pushed.
|
||||
# Left off entirely, the variable stays unset and the script falls back to HEAD,
|
||||
# exactly as a plain `git push <remote> <current-branch>` behaves.
|
||||
# The fixture repo is the subject under test, so every PRE_COMMIT_* input must
|
||||
# come from this function and nowhere else. Any such variable already in the
|
||||
# environment belongs to the *caller's* repo: run under the pre-push hook this
|
||||
# suite guards, PRE_COMMIT_TO_REF holds a sha of the real repo, which does not
|
||||
# exist in the fixture, and the script resolves against the wrong rev. Clearing
|
||||
# them is what makes a standalone run and a pre-push run the same test — this
|
||||
# suite passed everywhere except under the hook it exists to protect.
|
||||
run_check() {
|
||||
local dir="$1" branch="$2"
|
||||
if [[ $# -ge 3 ]]; then
|
||||
(cd "$dir" && unset PRE_COMMIT_FROM_REF \
|
||||
&& PRE_COMMIT_REMOTE_BRANCH="$branch" PRE_COMMIT_TO_REF="$3" bash "$SCRIPT" 2>&1)
|
||||
else
|
||||
(cd "$dir" && unset PRE_COMMIT_FROM_REF PRE_COMMIT_TO_REF \
|
||||
&& PRE_COMMIT_REMOTE_BRANCH="$branch" bash "$SCRIPT" 2>&1)
|
||||
fi
|
||||
}
|
||||
|
||||
CLEANUP_DIRS=()
|
||||
trap 'rm -rf ${CLEANUP_DIRS[@]+"${CLEANUP_DIRS[@]}"}' EXIT
|
||||
track() { CLEANUP_DIRS+=("$1"); }
|
||||
|
||||
# --- 1. Not targeting main: silent no-op regardless of state ---
|
||||
echo ""
|
||||
echo "--- exits 0 when not pushing to main, even with no tags ---"
|
||||
FIXTURE1="$(mktemp -d)"; track "$FIXTURE1"
|
||||
(cd "$FIXTURE1" && git init -q)
|
||||
if run_check "$FIXTURE1" "refs/heads/feature-branch" > /dev/null; then
|
||||
pass "exits 0 when target branch isn't main"
|
||||
else
|
||||
fail "exited non-zero on a non-main target branch"
|
||||
fi
|
||||
|
||||
# --- 2. Targeting main, no tag exists at all: hard fail ---
|
||||
echo ""
|
||||
echo "--- exits 1 when targeting main and no tag exists ---"
|
||||
FIXTURE2="$(mktemp -d)"; track "$FIXTURE2"
|
||||
(cd "$FIXTURE2" && git init -q && git config user.email t@t.t && git config user.name t)
|
||||
write_manifest "$FIXTURE2"
|
||||
write_release_paths "$FIXTURE2"
|
||||
(cd "$FIXTURE2" && git add -A && git commit -q -m "initial")
|
||||
if run_check "$FIXTURE2" "refs/heads/main" > /dev/null; then
|
||||
fail "exited 0 when targeting main with no tag — expected exit 1"
|
||||
else
|
||||
pass "exits non-zero when targeting main and no tag exists yet"
|
||||
fi
|
||||
|
||||
# --- 3. Targeting main, tag exists, no release-relevant changes since: passes ---
|
||||
echo ""
|
||||
echo "--- exits 0 when targeting main and nothing release-relevant changed since the tag ---"
|
||||
FIXTURE3="$(make_tagged_fixture)"; track "$FIXTURE3"
|
||||
echo "unrelated" > "$FIXTURE3/README.md"
|
||||
(cd "$FIXTURE3" && git add -A && git commit -q -m "unrelated change")
|
||||
if run_check "$FIXTURE3" "refs/heads/main" > /dev/null; then
|
||||
pass "exits 0 when only unrelated files changed since the tag"
|
||||
else
|
||||
fail "exited non-zero despite no release-relevant changes since the tag"
|
||||
fi
|
||||
|
||||
# --- 4. Targeting main, tag exists, a release-relevant file changed since: hard fail ---
|
||||
echo ""
|
||||
echo "--- exits 1 when a release-relevant file changed since the tag ---"
|
||||
FIXTURE4="$(make_tagged_fixture)"; track "$FIXTURE4"
|
||||
echo "v2" > "$FIXTURE4/scripts/skill-size-check.sh"
|
||||
(cd "$FIXTURE4" && git add -A && git commit -q -m "update release-relevant script")
|
||||
OUT4=$(run_check "$FIXTURE4" "refs/heads/main" || true)
|
||||
if grep -q "skill-size-check.sh" <<< "$OUT4"; then
|
||||
pass "exits non-zero and names the changed file when a release-relevant path changed since the tag"
|
||||
else
|
||||
fail "did not flag the release-relevant file that changed since the tag"
|
||||
fi
|
||||
|
||||
# --- 5. Not targeting main even with release-relevant changes and a tag: still a no-op ---
|
||||
echo ""
|
||||
echo "--- exits 0 on a feature branch even with release-relevant changes since the tag ---"
|
||||
FIXTURE5="$(make_tagged_fixture)"; track "$FIXTURE5"
|
||||
echo "v2" > "$FIXTURE5/scripts/skill-size-check.sh"
|
||||
(cd "$FIXTURE5" && git add -A && git commit -q -m "update release-relevant script")
|
||||
if run_check "$FIXTURE5" "refs/heads/some-feature" > /dev/null; then
|
||||
pass "exits 0 on a feature branch regardless of un-tagged release-relevant changes"
|
||||
else
|
||||
fail "hard-failed on a feature branch — should only ever fail when targeting main"
|
||||
fi
|
||||
|
||||
# --- 6. A release-relevant path deleted since the tag is still flagged ---
|
||||
echo ""
|
||||
echo "--- exits 1 when a release-relevant path was deleted since the tag, not just modified ---"
|
||||
FIXTURE6="$(make_tagged_fixture)"; track "$FIXTURE6"
|
||||
rm -f "$FIXTURE6/$HOOK_DIR/assets/vale/.vale.ini"
|
||||
(cd "$FIXTURE6" && git add -A && git commit -q -m "delete the bundled vale config")
|
||||
OUT6=$(run_check "$FIXTURE6" "refs/heads/main" || true)
|
||||
if grep -q "assets/vale/.vale.ini" <<< "$OUT6"; then
|
||||
pass "flags a deleted release-relevant path instead of silently dropping it from the diff"
|
||||
else
|
||||
fail "did not flag deletion of a release-relevant path since the tag"
|
||||
fi
|
||||
|
||||
# --- 7. A git diff failure hard-fails instead of reading as a clean pass ---
|
||||
echo ""
|
||||
echo "--- exits 1 (not a silent pass) when the underlying git diff errors out ---"
|
||||
FIXTURE7="$(make_tagged_fixture)"; track "$FIXTURE7"
|
||||
TAG_TREE="$(cd "$FIXTURE7" && git rev-parse 'v1.0.0^{tree}')"
|
||||
echo "v2" > "$FIXTURE7/scripts/skill-size-check.sh"
|
||||
(cd "$FIXTURE7" && git add -A && git commit -q -m "advance past the tag")
|
||||
rm -f "$FIXTURE7/.git/objects/${TAG_TREE:0:2}/${TAG_TREE:2}"
|
||||
if run_check "$FIXTURE7" "refs/heads/main" > /dev/null; then
|
||||
fail "silently exited 0 when the underlying git diff failed"
|
||||
else
|
||||
pass "hard-fails instead of silently passing when git diff can't be computed"
|
||||
fi
|
||||
|
||||
# --- 8. A non-version tag reachable from HEAD does not become the diff baseline ---
|
||||
echo ""
|
||||
echo "--- ignores a non-vX.Y.Z tag and still flags a change since the real release tag ---"
|
||||
FIXTURE8="$(make_tagged_fixture)"; track "$FIXTURE8"
|
||||
echo "checkpoint" > "$FIXTURE8/scripts/skill-size-check.sh"
|
||||
(cd "$FIXTURE8" && git add -A && git commit -q -m "checkpoint work" && git tag checkpoint-1)
|
||||
echo "v2" > "$FIXTURE8/scripts/skill-size-check.sh"
|
||||
(cd "$FIXTURE8" && git add -A && git commit -q -m "real release-relevant change")
|
||||
OUT8=$(run_check "$FIXTURE8" "refs/heads/main" || true)
|
||||
if grep -q "skill-size-check.sh" <<< "$OUT8"; then
|
||||
pass "still flags the release-relevant change since v1.0.0, ignoring the non-version checkpoint tag"
|
||||
else
|
||||
fail "an incidental non-version tag shifted the baseline and hid a real release-relevant change"
|
||||
fi
|
||||
|
||||
# --- 9. A file outside every manifest entry does not trigger a fail ---
|
||||
echo ""
|
||||
echo "--- exits 0 when a changed file sits near, but isn't referenced by, a manifest entry ---"
|
||||
FIXTURE9="$(make_tagged_fixture)"; track "$FIXTURE9"
|
||||
echo "irrelevant" > "$FIXTURE9/scripts/unrelated-helper.sh"
|
||||
(cd "$FIXTURE9" && git add -A && git commit -q -m "add an unrelated script alongside the exposed one")
|
||||
if run_check "$FIXTURE9" "refs/heads/main" > /dev/null; then
|
||||
pass "exits 0 for a file that lives alongside, but isn't referenced by, any manifest entry"
|
||||
else
|
||||
fail "flagged a file that no .pre-commit-hooks.yaml entry actually exposes"
|
||||
fi
|
||||
|
||||
# --- 10. A change confined to a hook's bundled styles/ tree is release-relevant ---
|
||||
# The manifest entry names only the wrapper script; the Vale rules it enforces
|
||||
# live in the sibling assets/ tree it self-locates at runtime. If that tree is
|
||||
# not covered, editing a rule and landing it on main demands no new tag, and a
|
||||
# consumer pinned to the old rev keeps the stale rules forever.
|
||||
echo ""
|
||||
echo "--- exits 1 when only a bundled Vale style rule changed since the tag ---"
|
||||
FIXTURE10="$(make_tagged_fixture)"; track "$FIXTURE10"
|
||||
echo "rule: v2" > "$FIXTURE10/$HOOK_DIR/assets/vale/styles/Kyberforge/DemoRule.yml"
|
||||
(cd "$FIXTURE10" && git add -A && git commit -q -m "tighten a vale rule")
|
||||
OUT10=$(run_check "$FIXTURE10" "refs/heads/main" || true)
|
||||
if grep -q "assets/vale/styles/Kyberforge/DemoRule.yml" <<< "$OUT10"; then
|
||||
pass "flags a change confined to a hook's bundled assets/vale/styles/ tree"
|
||||
else
|
||||
fail "a bundled Vale style rule changed since the tag without demanding a release"
|
||||
fi
|
||||
|
||||
# --- 11. The assets/ derivation must not invent a path for a bundle-less hook ---
|
||||
# scripts/skill-size-check.sh has no sibling assets/ tree, so its derived
|
||||
# candidate normalises to a bare top-level assets/ — a directory this repo does
|
||||
# not ship. Adding it unconditionally would make any unrelated repo-root
|
||||
# assets/ file falsely demand a release.
|
||||
echo ""
|
||||
echo "--- exits 0 when a top-level assets/ file changed but no hook bundles one ---"
|
||||
FIXTURE11="$(make_tagged_fixture)"; track "$FIXTURE11"
|
||||
mkdir -p "$FIXTURE11/assets"
|
||||
echo "unrelated" > "$FIXTURE11/assets/logo.txt"
|
||||
(cd "$FIXTURE11" && git add -A && git commit -q -m "add an unrelated top-level assets file")
|
||||
if run_check "$FIXTURE11" "refs/heads/main" > /dev/null; then
|
||||
pass "exits 0 for a top-level assets/ file that no manifest entry bundles"
|
||||
else
|
||||
fail "invented a bogus assets/ path for a hook script with no bundled tree"
|
||||
fi
|
||||
|
||||
# --- 12. Deleting a hook's entire bundled assets/ tree is release-relevant ---
|
||||
# The worktree-only derivation guarded the assets/ path on the directory still
|
||||
# existing, so wiping the whole tree removed the path from the pathspec instead
|
||||
# of diffing it: the single most consumer-breaking change possible diffed clean.
|
||||
# The path list therefore has to be unioned with what $LAST_TAG exposed.
|
||||
echo ""
|
||||
echo "--- exits 1 when a hook's entire bundled assets/ tree was deleted since the tag ---"
|
||||
FIXTURE12="$(make_tagged_fixture)"; track "$FIXTURE12"
|
||||
rm -rf "${FIXTURE12:?}/$HOOK_DIR/assets"
|
||||
(cd "$FIXTURE12" && git add -A && git commit -q -m "delete the whole bundled assets tree")
|
||||
OUT12=$(run_check "$FIXTURE12" "refs/heads/main" || true)
|
||||
if grep -q "assets/vale/.vale.ini" <<< "$OUT12"; then
|
||||
pass "flags a wholesale deletion of a hook's bundled assets/ tree"
|
||||
else
|
||||
fail "a hook's entire bundled assets/ tree vanished since the tag without demanding a release"
|
||||
fi
|
||||
|
||||
# --- 13. A hook script deleted while its manifest entry survives is flagged ---
|
||||
# Characterisation test, not a bug fix: tokens[0] is added to the pathspec
|
||||
# unconditionally (no existence guard), so this case was already covered. It is
|
||||
# pinned here so the tagged-tree union can't accidentally introduce an existence
|
||||
# guard on tokens[0] and reopen the hole its assets/ sibling had.
|
||||
echo ""
|
||||
echo "--- exits 1 when a hook script was deleted but its manifest entry remains ---"
|
||||
FIXTURE13="$(make_tagged_fixture)"; track "$FIXTURE13"
|
||||
rm -f "$FIXTURE13/$HOOK_DIR/scripts/vale-wrap.sh"
|
||||
(cd "$FIXTURE13" && git add -A && git commit -q -m "delete a hook script, keep its manifest entry")
|
||||
OUT13=$(run_check "$FIXTURE13" "refs/heads/main" || true)
|
||||
if grep -q "vale-wrap.sh" <<< "$OUT13"; then
|
||||
pass "flags a hook script deleted out from under a surviving manifest entry"
|
||||
else
|
||||
fail "a manifest entry's script vanished since the tag without demanding a release"
|
||||
fi
|
||||
|
||||
# --- 14. Retiring a whole hook names what the tag exposed, not just the manifest ---
|
||||
# Removing the entry and everything it shipped changes $HOOKS_MANIFEST, so the
|
||||
# gate fires either way — but a derivation that only reads the current manifest
|
||||
# can no longer name the retired script or its assets, and the failure message
|
||||
# understates the breakage to consumers pinned at the old rev. The tagged
|
||||
# manifest is what makes those paths reportable.
|
||||
echo ""
|
||||
echo "--- names the retired hook's own paths when an entry and its files are removed together ---"
|
||||
FIXTURE14="$(make_tagged_fixture)"; track "$FIXTURE14"
|
||||
cat > "$FIXTURE14/.pre-commit-hooks.yaml" <<'EOF'
|
||||
- id: fake-size-check
|
||||
entry: scripts/skill-size-check.sh
|
||||
language: script
|
||||
EOF
|
||||
rm -rf "${FIXTURE14:?}/$HOOK_DIR"
|
||||
(cd "$FIXTURE14" && git add -A && git commit -q -m "retire the vale hook entirely")
|
||||
OUT14=$(run_check "$FIXTURE14" "refs/heads/main" || true)
|
||||
if grep -q "vale-wrap.sh" <<< "$OUT14" && grep -q "assets/vale/.vale.ini" <<< "$OUT14"; then
|
||||
pass "names the retired hook's script and bundled assets, not just the manifest edit"
|
||||
else
|
||||
fail "reported only the manifest change and hid which shipped paths the retirement removed"
|
||||
fi
|
||||
|
||||
# --- 15. A multi-token entry: is rejected loudly, not silently mis-parsed ---
|
||||
# ADR-0014 binds entries to a bare script path, but nothing enforced it, and the
|
||||
# sibling .pre-commit-config.yaml already ships `entry: bash <script>`. Under the
|
||||
# old parser tokens[0] became "bash": a pathspec matching nothing (which git diff
|
||||
# accepts in silence) and a bundle root of "." (skipped), so the hook's whole
|
||||
# surface dropped out of the gate and the post-tag change below diffed clean.
|
||||
echo ""
|
||||
echo "--- exits 1 naming the hook when an entry: carries more than one token ---"
|
||||
# The entry is quoted back verbatim, not just its first token: that is what makes
|
||||
# the diagnostic point at the argument the author has to remove, and what
|
||||
# distinguishes this from the unresolvable-path rejection test 16 covers.
|
||||
FIXTURE15="$(make_malformed_fixture "bash scripts/skill-size-check.sh")"; track "$FIXTURE15"
|
||||
OUT15=$(run_check "$FIXTURE15" "refs/heads/main" || true)
|
||||
if run_check "$FIXTURE15" "refs/heads/main" > /dev/null; then
|
||||
fail "silently exited 0 on a multi-token entry, dropping that hook's paths from the gate"
|
||||
elif grep -q "fake-size-check" <<< "$OUT15" \
|
||||
&& grep -q "bash scripts/skill-size-check.sh" <<< "$OUT15" \
|
||||
&& grep -q "ADR-0014" <<< "$OUT15"; then
|
||||
pass "rejects a multi-token entry, quoting it back and naming the hook and ADR-0014"
|
||||
else
|
||||
fail "rejected the multi-token entry without naming the hook, the entry, and ADR-0014"
|
||||
fi
|
||||
|
||||
# --- 16. An entry naming no file this repo ships is rejected loudly ---
|
||||
# The token-count guard alone still lets a single bare command name (`entry:
|
||||
# vale`, valid for language: system) through as a pathspec matching nothing.
|
||||
# Existence is checked against the union of the worktree and $LAST_TAG, so this
|
||||
# cannot misfire on the deletion cases tests 12-14 pin.
|
||||
echo ""
|
||||
echo "--- exits 1 naming the hook when an entry: names no file in the worktree or at the tag ---"
|
||||
FIXTURE16="$(make_malformed_fixture "vale")"; track "$FIXTURE16"
|
||||
OUT16=$(run_check "$FIXTURE16" "refs/heads/main" || true)
|
||||
if run_check "$FIXTURE16" "refs/heads/main" > /dev/null; then
|
||||
fail "silently exited 0 on an entry that names no shipped file"
|
||||
elif grep -q "fake-size-check" <<< "$OUT16" && grep -q "ADR-0014" <<< "$OUT16"; then
|
||||
pass "rejects an entry that resolves to no file, naming the hook and the ADR-0014 constraint"
|
||||
else
|
||||
fail "rejected the unresolvable entry without naming the hook and the ADR-0014 constraint"
|
||||
fi
|
||||
|
||||
# --- 17. The pushed ref, not HEAD, is what gets gated ---
|
||||
# pre-commit exports the local sha of each pushed ref as PRE_COMMIT_TO_REF.
|
||||
# `git push <remote> pushed-tip:main` from a checkout sitting on an older commit
|
||||
# is the false-negative direction: HEAD is still at the tag and diffs clean while
|
||||
# the branch actually landing on main carries an untagged, release-relevant
|
||||
# change. HEAD is reset back to the tag so the two genuinely differ.
|
||||
echo ""
|
||||
echo "--- exits 1 on a release-relevant change reachable only from PRE_COMMIT_TO_REF ---"
|
||||
FIXTURE17="$(make_tagged_fixture)"; track "$FIXTURE17"
|
||||
echo "v2" > "$FIXTURE17/scripts/skill-size-check.sh"
|
||||
(cd "$FIXTURE17" && git add -A && git commit -q -m "release-relevant change" \
|
||||
&& git branch pushed-tip && git reset -q --hard v1.0.0)
|
||||
OUT17=$(run_check "$FIXTURE17" "refs/heads/main" "pushed-tip" || true)
|
||||
if grep -q "skill-size-check.sh" <<< "$OUT17"; then
|
||||
pass "gates the pushed ref's tip, not HEAD, when HEAD is behind it"
|
||||
else
|
||||
fail "diffed HEAD instead of PRE_COMMIT_TO_REF and missed a release-relevant change"
|
||||
fi
|
||||
|
||||
# --- 18. Neither the diff tip nor the tag baseline may come from a newer HEAD ---
|
||||
# The false-positive direction: HEAD has moved past a v2.0.0 that the pushed ref
|
||||
# never saw. Reading either end of the diff off HEAD fails a push that is clean
|
||||
# since its own baseline — diffing v2.0.0..HEAD flags HEAD's untagged commit, and
|
||||
# resolving the tag from HEAD while diffing pushed-tip flags v2.0.0's change.
|
||||
echo ""
|
||||
echo "--- exits 0 when the pushed ref is clean since its own tag but HEAD has moved on ---"
|
||||
FIXTURE18="$(make_tagged_fixture)"; track "$FIXTURE18"
|
||||
(cd "$FIXTURE18" && git branch pushed-tip)
|
||||
echo "v2" > "$FIXTURE18/scripts/skill-size-check.sh"
|
||||
(cd "$FIXTURE18" && git add -A && git commit -q -m "released change" && git tag v2.0.0)
|
||||
echo "v3" > "$FIXTURE18/scripts/skill-size-check.sh"
|
||||
(cd "$FIXTURE18" && git add -A && git commit -q -m "unreleased change on HEAD's line")
|
||||
if run_check "$FIXTURE18" "refs/heads/main" "pushed-tip" > /dev/null; then
|
||||
pass "exits 0 for a pushed ref clean since the tag reachable from it, ignoring HEAD's line"
|
||||
else
|
||||
fail "gated HEAD's tag or tip and falsely demanded a release for a clean pushed ref"
|
||||
fi
|
||||
|
||||
# --- 19. A branch deletion is a no-op, not a confusing git failure ---
|
||||
# pre-commit sets PRE_COMMIT_TO_REF to an all-zeros sha when the push deletes a
|
||||
# branch. Nothing is being shipped, and the sha resolves to nothing, so without
|
||||
# an explicit guard the gate reports "could not diff" on an unrelated operation.
|
||||
echo ""
|
||||
echo "--- exits 0 when PRE_COMMIT_TO_REF is the all-zeros branch-deletion sha ---"
|
||||
FIXTURE19="$(make_tagged_fixture)"; track "$FIXTURE19"
|
||||
echo "v2" > "$FIXTURE19/scripts/skill-size-check.sh"
|
||||
(cd "$FIXTURE19" && git add -A && git commit -q -m "release-relevant change")
|
||||
if run_check "$FIXTURE19" "refs/heads/main" "0000000000000000000000000000000000000000" > /dev/null; then
|
||||
pass "treats an all-zeros PRE_COMMIT_TO_REF as a branch deletion and exits 0"
|
||||
else
|
||||
fail "turned a branch deletion into a failure instead of a no-op"
|
||||
fi
|
||||
|
||||
# --- 20. The repo's own .pre-commit-hooks.yaml satisfies the entry constraints ---
|
||||
# The parser guards above are only safe to ship if the manifest actually in tree
|
||||
# passes them. It is replayed into a fixture (with the paths its entries name
|
||||
# created) rather than run against the real repo, which has no release tag yet.
|
||||
echo ""
|
||||
echo "--- accepts the real .pre-commit-hooks.yaml this repo ships ---"
|
||||
FIXTURE20="$(mktemp -d)"; track "$FIXTURE20"
|
||||
(cd "$FIXTURE20" && git init -q && git config user.email t@t.t && git config user.name t)
|
||||
cp "$REPO_ROOT/.pre-commit-hooks.yaml" "$FIXTURE20/.pre-commit-hooks.yaml"
|
||||
while IFS= read -r real_entry; do
|
||||
mkdir -p "$FIXTURE20/$(dirname "$real_entry")"
|
||||
echo "v1" > "$FIXTURE20/$real_entry"
|
||||
done < <(sed -n 's/^[[:space:]]*entry:[[:space:]]*//p' "$REPO_ROOT/.pre-commit-hooks.yaml")
|
||||
(cd "$FIXTURE20" && git add -A && git commit -q -m "initial" && git tag v1.0.0)
|
||||
OUT20=$(run_check "$FIXTURE20" "refs/heads/main" || true)
|
||||
if [[ -z "$OUT20" ]]; then
|
||||
pass "parses every entry in the repo's real .pre-commit-hooks.yaml without complaint"
|
||||
else
|
||||
fail "the repo's own .pre-commit-hooks.yaml no longer satisfies the entry constraints: $OUT20"
|
||||
fi
|
||||
|
||||
# --- 21. A vX.Y.Z-suffixed checkpoint tag must not satisfy the release gate ---
|
||||
# git describe --match uses shell-glob semantics, not regex: the trailing `*` in
|
||||
# 'v[0-9]*.[0-9]*.[0-9]*' matches any suffix, so a pre-release/checkpoint tag like
|
||||
# v1.0.1-checkpoint also satisfies the glob and can be picked as LAST_TAG instead
|
||||
# of the true last release tag — hiding a real release-relevant change that landed
|
||||
# before the checkpoint tag from the diff.
|
||||
echo ""
|
||||
echo "--- ignores a vX.Y.Z-checkpoint tag and still flags the change since the real release tag ---"
|
||||
FIXTURE21="$(make_tagged_fixture)"; track "$FIXTURE21"
|
||||
echo "v2" > "$FIXTURE21/scripts/skill-size-check.sh"
|
||||
(cd "$FIXTURE21" && git add -A && git commit -q -m "real release-relevant change" && git tag v1.0.1-checkpoint)
|
||||
OUT21=$(run_check "$FIXTURE21" "refs/heads/main" || true)
|
||||
if grep -q "skill-size-check.sh" <<< "$OUT21"; then
|
||||
pass "still flags the release-relevant change since v1.0.0, ignoring the vX.Y.Z-checkpoint tag"
|
||||
else
|
||||
fail "a vX.Y.Z-checkpoint tag satisfied the glob and hid a real release-relevant change"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
[[ $FAIL -eq 0 ]]
|
||||
@@ -738,8 +738,8 @@ expect_gate "a fixture with no authoring root reports DID NOT RUN and exits 0" \
|
||||
# provider-adapter-author's validate-adapter.sh and the one vale-wrap.sh already
|
||||
# used: {0,1} are verdicts, 2 is "you invoked this wrong".
|
||||
#
|
||||
# SAFE FOR THE HOOK. Both manifests declare pass_filenames: true and neither
|
||||
# sets always_run, and pre-commit skips a filename-passing hook outright when
|
||||
# SAFE FOR THE HOOK. The skill-size-check hook declares pass_filenames: true
|
||||
# and does not set always_run, and pre-commit skips a filename-passing hook outright when
|
||||
# its `files:` pattern matches nothing, so pre-commit never invokes this script
|
||||
# with an empty argument list. That claim is asserted below rather than left in
|
||||
# prose, so a config edit that turns it false fails here.
|
||||
@@ -773,7 +773,7 @@ if [[ $CLEAN_RC -eq 0 && $FINDING_RC -eq 1 && $USAGE_RC -eq 2 ]]; then
|
||||
else
|
||||
fail "exit codes collide — clean=$CLEAN_RC findings=$FINDING_RC usage=$USAGE_RC"
|
||||
fi
|
||||
# The hook contract the usage exit depends on. If either manifest ever stops
|
||||
# The hook contract the usage exit depends on. If the hook ever stops
|
||||
# passing filenames, or starts always_run, pre-commit could invoke the script
|
||||
# with no paths and exit 2 would break the hook rather than diagnose a caller.
|
||||
HOOK_CONTRACT="$(python3 - "$REPO_ROOT" <<'PYHOOK'
|
||||
@@ -805,19 +805,11 @@ for repo in cfg.get('repos') or []:
|
||||
found = hook
|
||||
check('.pre-commit-config.yaml skill-size-check', found)
|
||||
|
||||
with open(os.path.join(root, '.pre-commit-hooks.yaml'), encoding='utf-8') as fh:
|
||||
hooks = yaml.safe_load(fh) or []
|
||||
found = None
|
||||
for hook in hooks:
|
||||
if isinstance(hook, dict) and hook.get('id') == 'kyberforge-skill-size-check':
|
||||
found = hook
|
||||
check('.pre-commit-hooks.yaml kyberforge-skill-size-check', found)
|
||||
|
||||
print('; '.join(problems))
|
||||
PYHOOK
|
||||
)"
|
||||
if [[ -z "$HOOK_CONTRACT" ]]; then
|
||||
pass "both manifests pass filenames and neither is always_run, so pre-commit never invokes the script with no paths"
|
||||
pass "the hook passes filenames and is not always_run, so pre-commit never invokes the script with no paths"
|
||||
else
|
||||
fail "the usage exit would break the hook: $HOOK_CONTRACT"
|
||||
fi
|
||||
|
||||
@@ -18,7 +18,7 @@ if ! python3 -c 'import yaml' 2>/dev/null; then
|
||||
fi
|
||||
|
||||
# Output assertions use here-strings, never `echo | grep -q` (pipefail race;
|
||||
# see tests/test-check-release-needed.sh).
|
||||
# see docs/spec/gates.md, Tests).
|
||||
|
||||
CLEANUP_DIRS=()
|
||||
trap 'rm -rf ${CLEANUP_DIRS[@]+"${CLEANUP_DIRS[@]}"}' EXIT
|
||||
|
||||
@@ -1,276 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Integration test for .pre-commit-hooks.yaml as an EXTERNAL hook repo — the
|
||||
# contract ADR-0014 exists to provide, and the one thing running pre-commit
|
||||
# inside this repo can never exercise: `repo: local` makes pre-commit's clone
|
||||
# prefix equal to the consuming repo's root, so a hook entry that only works
|
||||
# because those two coincide passes here and hard-fails everywhere else.
|
||||
# (It did: every argument after entry[0] resolves against the CONSUMING repo,
|
||||
# so a `--config plugins/.../.vale.ini` argument gave external consumers
|
||||
# `E100 [--config] Runtime error ... does not exist`, exit 2, on both Vale hooks.)
|
||||
#
|
||||
# The hook repo is built from the WORKING TREE, not from HEAD, so an uncommitted
|
||||
# change to the manifest or the wrapper is what gets tested.
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
pass() { echo " PASS: $1"; PASS=$((PASS + 1)); }
|
||||
fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); }
|
||||
|
||||
for bin in pre-commit vale git; do
|
||||
if ! command -v "$bin" &>/dev/null; then
|
||||
echo "SKIP: $bin is not installed — cannot stand up a consumer repo"
|
||||
exit 77
|
||||
fi
|
||||
done
|
||||
|
||||
WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
HOOK_REPO="$WORK/hookrepo"
|
||||
CONSUMER="$WORK/consumer"
|
||||
export PRE_COMMIT_HOME="$WORK/pc-home"
|
||||
|
||||
mkdir -p "$HOOK_REPO/plugins/kyberforge/.apm/skills" "$HOOK_REPO/scripts"
|
||||
cp "$REPO_ROOT/.pre-commit-hooks.yaml" "$HOOK_REPO/"
|
||||
cp "$REPO_ROOT/scripts/skill-size-check.sh" "$HOOK_REPO/scripts/"
|
||||
# One skill since ADR-0025 merged skill-audit and agent-audit into factory-audit,
|
||||
# and one vale-wrap.sh with it. Both Vale hook IDs still ship and both are still
|
||||
# registered by the consumer below — they now point at the same entry and differ
|
||||
# only in their `files:` scope, which is exactly what the per-hook attribution in
|
||||
# case 1 exists to prove is still true.
|
||||
skill=factory-audit
|
||||
mkdir -p "$HOOK_REPO/plugins/kyberforge/.apm/skills/$skill"
|
||||
cp -R "$REPO_ROOT/plugins/kyberforge/.apm/skills/$skill/scripts" \
|
||||
"$REPO_ROOT/plugins/kyberforge/.apm/skills/$skill/assets" \
|
||||
"$HOOK_REPO/plugins/kyberforge/.apm/skills/$skill/"
|
||||
git -C "$HOOK_REPO" init -q
|
||||
git -C "$HOOK_REPO" add -A
|
||||
git -C "$HOOK_REPO" -c user.email=test@example.invalid -c user.name=test commit -qm "hook repo"
|
||||
HOOK_REV="$(git -C "$HOOK_REPO" rev-parse HEAD)"
|
||||
|
||||
# Every hook scopes by filename, so the consumer needs one file of each shape:
|
||||
# a hook with nothing to match reports `Skipped` and proves nothing. All three
|
||||
# hooks .pre-commit-hooks.yaml ships are registered — an unregistered one would
|
||||
# let a regression (a lost `100755` bit, a bad entry path) reach every external
|
||||
# consumer while this repo's own `repo: local` runs stayed green.
|
||||
mkdir -p "$CONSUMER/skills/demo" "$CONSUMER/agents"
|
||||
git -C "$CONSUMER" init -q
|
||||
cat > "$CONSUMER/.pre-commit-config.yaml" <<EOF
|
||||
repos:
|
||||
- repo: file://$HOOK_REPO
|
||||
rev: $HOOK_REV
|
||||
hooks:
|
||||
- id: kyberforge-vale-audit-skill
|
||||
- id: kyberforge-vale-audit-agent
|
||||
- id: kyberforge-skill-size-check
|
||||
EOF
|
||||
|
||||
# The two fixtures carry DIFFERENT flagged tokens so an alert can never be
|
||||
# credited to the hook that did not raise it. Both bodies land mid-sentence in a
|
||||
# folded block scalar that still spans two physical lines, which is the
|
||||
# flattening the wrapper exists to do.
|
||||
write_fixtures() {
|
||||
local skill_body="$1"
|
||||
local agent_body="${2:-$1}"
|
||||
cat > "$CONSUMER/skills/demo/SKILL.md" <<EOF
|
||||
---
|
||||
name: demo
|
||||
description: >
|
||||
Use when the caller wants a demonstration skill $skill_body across two
|
||||
physical lines of one folded block scalar.
|
||||
metadata:
|
||||
version: "1.0.0"
|
||||
---
|
||||
|
||||
Body.
|
||||
EOF
|
||||
cat > "$CONSUMER/agents/demo.md" <<EOF
|
||||
---
|
||||
name: demo
|
||||
description: >
|
||||
Use when the caller wants a demonstration agent $agent_body across two
|
||||
physical lines of one folded block scalar.
|
||||
---
|
||||
|
||||
Body.
|
||||
EOF
|
||||
git -C "$CONSUMER" add -A
|
||||
}
|
||||
|
||||
# Vale prints each linted path as its own header line with that file's alerts
|
||||
# indented beneath it, so an alert belongs to the nearest preceding path line.
|
||||
# Reads a hook log on stdin and prints only the alert lines filed under `$1`.
|
||||
# The `sed` strips vale's ANSI colouring, which it emits into pre-commit's pipe
|
||||
# too, so the header lines compare as plain paths.
|
||||
alerts_for() {
|
||||
sed $'s/\033\\[[0-9;]*m//g' | awk -v want="$1" '
|
||||
/^[^[:space:]].*\.md$/ { cur = $0; next }
|
||||
/^[[:space:]]*[0-9]+:[0-9]+[[:space:]]/ { if (cur == want) print }
|
||||
'
|
||||
}
|
||||
|
||||
# --- 1. Each Vale hook resolves its config and gates its own file shape ---
|
||||
# Asserted per hook, against that hook's own fixture path and its own token. An
|
||||
# aggregate alert count over both hooks' combined output does not prove this:
|
||||
# one fixture description carries every flagged token, so ONE working hook
|
||||
# already clears a `>= 2` threshold. And a hook whose .vale.ini globs match
|
||||
# nothing reaches neither of the guards below — it still MATCHES the file via
|
||||
# its `files:` regex, so pre-commit does not report `Skipped`; vale simply lints
|
||||
# nothing, prints `0 errors ... in 1 file` and exits 0, and the hook shows
|
||||
# `Passed`. Attribution is the only thing that catches it.
|
||||
echo ""
|
||||
echo "--- each Vale hook flags its own fixture in an external consumer repo ---"
|
||||
write_fixtures "that helps with things" "that will utilize things"
|
||||
while IFS='|' read -r HOOK_ID FIXTURE TOKEN; do
|
||||
[[ -n "$HOOK_ID" ]] || continue
|
||||
LOG="$WORK/$HOOK_ID.log"
|
||||
set +e
|
||||
(cd "$CONSUMER" && pre-commit run "$HOOK_ID" --all-files > "$LOG" 2>&1)
|
||||
RC_HOOK=$?
|
||||
set -e
|
||||
if grep -q "does not exist" "$LOG"; then
|
||||
fail "$HOOK_ID hard-errored on a path resolved against the consumer repo (E100) — the bug this test guards against"
|
||||
sed 's/^/ /' "$LOG"
|
||||
elif grep -q "Skipped" "$LOG"; then
|
||||
fail "$HOOK_ID matched no files, so it proved nothing"
|
||||
sed 's/^/ /' "$LOG"
|
||||
elif [[ $RC_HOOK -eq 0 ]]; then
|
||||
fail "$HOOK_ID passed $FIXTURE despite its flagged '$TOKEN' — a .vale.ini glob matching nothing lints zero files and exits 0"
|
||||
sed 's/^/ /' "$LOG"
|
||||
elif alerts_for "$FIXTURE" < "$LOG" | grep -qF "'$TOKEN'"; then
|
||||
pass "$HOOK_ID flattens $FIXTURE and flags its '$TOKEN' in a consumer repo"
|
||||
else
|
||||
fail "$HOOK_ID failed, but no alert quoting '$TOKEN' was filed under $FIXTURE"
|
||||
sed 's/^/ /' "$LOG"
|
||||
fi
|
||||
done <<'EOF'
|
||||
kyberforge-vale-audit-skill|skills/demo/SKILL.md|helps with
|
||||
kyberforge-vale-audit-agent|agents/demo.md|utilize
|
||||
EOF
|
||||
|
||||
# --- 1b. Every rule in the shipped style is asserted to FIRE, not merely to
|
||||
# exist. A Vale rule can be well-formed, load without a diagnostic, and match
|
||||
# nothing at all: `extends: existence` CONCATENATES multiple `raw:` entries
|
||||
# rather than alternating them, so a rule written as a list of alternatives
|
||||
# silently becomes one impossible expression, lints every file clean and exits
|
||||
# 0 — indistinguishable from a corpus with no violations. `Kyberforge.CompositionNote`
|
||||
# was written that way first and passed all 43 skill and agent files before the
|
||||
# defect was found by hand. Each rule gets its own fixture pass, with the token
|
||||
# it must quote attributed to the file that raised it, so one rule's alert can
|
||||
# never stand in for another's.
|
||||
echo ""
|
||||
echo "--- each Kyberforge description rule fires through both shipped hooks ---"
|
||||
write_desc_fixtures() {
|
||||
local skill_desc="$1" agent_desc="$2"
|
||||
cat > "$CONSUMER/skills/demo/SKILL.md" <<EOF
|
||||
---
|
||||
name: demo
|
||||
description: >
|
||||
$skill_desc across two
|
||||
physical lines of one folded block scalar.
|
||||
---
|
||||
|
||||
Body.
|
||||
EOF
|
||||
cat > "$CONSUMER/agents/demo.md" <<EOF
|
||||
---
|
||||
name: demo
|
||||
description: >
|
||||
$agent_desc across two
|
||||
physical lines of one folded block scalar.
|
||||
---
|
||||
|
||||
Body.
|
||||
EOF
|
||||
git -C "$CONSUMER" add -A
|
||||
}
|
||||
|
||||
run_rule_case() {
|
||||
local label="$1" hook_id="$2" fixture="$3" token="$4"
|
||||
local log="$WORK/rule-$label.log"
|
||||
set +e
|
||||
(cd "$CONSUMER" && pre-commit run "$hook_id" --all-files > "$log" 2>&1)
|
||||
local rc=$?
|
||||
set -e
|
||||
if grep -q "Skipped" "$log"; then
|
||||
fail "$hook_id matched no files for $label, so it proved nothing"
|
||||
sed 's/^/ /' "$log"
|
||||
elif [[ $rc -eq 0 ]]; then
|
||||
fail "$hook_id passed $fixture despite its flagged '$token' — $label matches nothing"
|
||||
sed 's/^/ /' "$log"
|
||||
elif alerts_for "$fixture" < "$log" | grep -qF "'$token'"; then
|
||||
pass "$label fires through $hook_id and quotes '$token' under $fixture"
|
||||
else
|
||||
fail "$hook_id failed, but no $label alert quoting '$token' was filed under $fixture"
|
||||
sed 's/^/ /' "$log"
|
||||
fi
|
||||
}
|
||||
|
||||
# CompositionNote: a distinct banned token per file shape.
|
||||
write_desc_fixtures \
|
||||
"Use when the caller wants a demo skill that composes other skills" \
|
||||
"Use when the caller wants a cross-cutting demo agent"
|
||||
run_rule_case "Kyberforge.CompositionNote" kyberforge-vale-audit-skill skills/demo/SKILL.md "composes"
|
||||
run_rule_case "Kyberforge.CompositionNote" kyberforge-vale-audit-agent agents/demo.md "cross-cutting"
|
||||
|
||||
# DescriptionOpener: the widened pattern catches every non-imperative "This..."
|
||||
# opener, not only the literal "This skill"/"This agent" pair it was anchored to
|
||||
# before. Both fixtures open with "This is", the form two shipped descriptions
|
||||
# used mid-sentence and which the old pattern could not express.
|
||||
write_desc_fixtures \
|
||||
"This is a demo skill for callers who want one" \
|
||||
"This is a demo agent for callers who want one"
|
||||
run_rule_case "Kyberforge.DescriptionOpener" kyberforge-vale-audit-skill skills/demo/SKILL.md "This"
|
||||
run_rule_case "Kyberforge.DescriptionOpener" kyberforge-vale-audit-agent agents/demo.md "This"
|
||||
|
||||
# --- 2. Clean files pass — the hooks gate, they don't just always fail ---
|
||||
echo ""
|
||||
echo "--- all three hooks pass clean files in an external consumer repo ---"
|
||||
write_fixtures "of the packaged hook contract"
|
||||
set +e
|
||||
(cd "$CONSUMER" && pre-commit run --all-files > "$WORK/clean.log" 2>&1)
|
||||
RC_CLEAN=$?
|
||||
set -e
|
||||
if grep -q "Skipped" "$WORK/clean.log"; then
|
||||
fail "a hook matched no files on the clean run, so it proved nothing"
|
||||
sed 's/^/ /' "$WORK/clean.log"
|
||||
elif [[ $RC_CLEAN -eq 0 ]]; then
|
||||
pass "all three hooks exit 0 on clean files"
|
||||
else
|
||||
fail "hooks failed on clean files (rc=$RC_CLEAN)"
|
||||
sed 's/^/ /' "$WORK/clean.log"
|
||||
fi
|
||||
|
||||
# --- 3. The size hook gates too. It ran clean above, which is what proves it
|
||||
# is executable and its entry path resolves; this half proves it still fails a
|
||||
# file that breaks the ceiling rather than passing everything. ---
|
||||
echo ""
|
||||
echo "--- kyberforge-skill-size-check fails an oversized SKILL.md in an external consumer repo ---"
|
||||
mkdir -p "$CONSUMER/skills/oversized"
|
||||
{
|
||||
echo "---"
|
||||
echo "name: oversized"
|
||||
echo "description: Use when the caller wants an oversized fixture."
|
||||
echo "---"
|
||||
for ((i = 1; i <= 600; i++)); do
|
||||
echo "word"
|
||||
done
|
||||
} > "$CONSUMER/skills/oversized/SKILL.md"
|
||||
git -C "$CONSUMER" add -A
|
||||
set +e
|
||||
(cd "$CONSUMER" && pre-commit run kyberforge-skill-size-check --all-files > "$WORK/size.log" 2>&1)
|
||||
RC_SIZE=$?
|
||||
set -e
|
||||
if [[ $RC_SIZE -ne 0 ]] && grep -q "500-line ceiling" "$WORK/size.log"; then
|
||||
pass "kyberforge-skill-size-check exits non-zero and names the ceiling it broke"
|
||||
else
|
||||
fail "kyberforge-skill-size-check did not gate an oversized SKILL.md (rc=$RC_SIZE)"
|
||||
sed 's/^/ /' "$WORK/size.log"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
[[ $FAIL -eq 0 ]]
|
||||
@@ -21,8 +21,8 @@ fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); }
|
||||
|
||||
# Vale absent skips the Vale-DEPENDENT cases, not the suite. An early `exit 77`
|
||||
# here used to skip everything, including the checks that are plain greps and
|
||||
# awk over the config and the two hook manifests (cases 16, 26-28's static
|
||||
# halves, 31 Parts A/B, 32, 33) -- so a machine without vale reported a skip
|
||||
# awk over the config and the hook config (cases 16, 26-28's static
|
||||
# halves, 31 Parts A/B, 32) -- so a machine without vale reported a skip
|
||||
# while never looking at a manifest it could have read. Those still run; the
|
||||
# suite exits 77 at the end only if they all passed, so run-tests.sh keeps
|
||||
# reporting SKIPPED and `--strict` keeps turning that skip into a failure. A
|
||||
@@ -452,10 +452,11 @@ else
|
||||
fi
|
||||
|
||||
# --- 12. With no --config at all, the wrapper falls back to its own sibling
|
||||
# assets/vale/.vale.ini. `.pre-commit-hooks.yaml` relies on this: pre-commit
|
||||
# prefixes only entry[0] with the hook-repo clone path, so a --config argument
|
||||
# there resolves against the consuming repo and hard-errors (E100) for every
|
||||
# external consumer.
|
||||
# assets/vale/.vale.ini. factory-audit's Step 1 and both prefilter hooks rely
|
||||
# on this: they pass no --config. A published hook manifest would too, were it
|
||||
# restored (ADR-0014): pre-commit prefixes only entry[0] with the hook-repo clone
|
||||
# path, so a --config argument there resolves against the consuming repo and
|
||||
# hard-errors (E100) for every external consumer.
|
||||
echo ""
|
||||
echo "--- defaults --config to the wrapper's own sibling assets/vale/.vale.ini ---"
|
||||
FIXTURE12="$(make_fixture 2)"
|
||||
@@ -464,7 +465,7 @@ OUT12=$(run_wrap "$FIXTURE12" plugins/testplugin/skills/zzzskill/SKILL.md)
|
||||
if grep -q "VagueWording" <<< "$OUT12"; then
|
||||
pass "a --config-less invocation uses the wrapper's bundled config"
|
||||
else
|
||||
fail "a --config-less invocation found no config — external pre-commit consumers get E100, the bug this test guards against"
|
||||
fail "a --config-less invocation found no config — factory-audit's Step 1 and both prefilter hooks pass no --config, so they would get E100"
|
||||
fi
|
||||
|
||||
# --- 13. No GNU-only `realpath -m`. macOS ships the BSD realpath, which has no
|
||||
@@ -1080,7 +1081,7 @@ elif [[ "$WRAPPED21_FILES" != "$BARE21_FILES" ]]; then
|
||||
fail "the directory walk dropped a symlinked file: wrapper saw '$WRAPPED21_FILES', bare vale '$BARE21_FILES'"
|
||||
# A here-string, not `echo "$WRAPPED21" | grep -q`: the match sits on line 3 of
|
||||
# 8, and under pipefail grep -q exiting early can SIGPIPE echo mid-write and
|
||||
# fail this branch on correct output (see tests/test-check-release-needed.sh).
|
||||
# fail this branch on correct output (see docs/spec/gates.md, Tests).
|
||||
elif grep -q "VagueWording" <<< "$WRAPPED21"; then
|
||||
pass "a symlinked file under a directory argument is mirrored, flattened and flagged"
|
||||
else
|
||||
@@ -1341,7 +1342,7 @@ fi
|
||||
# exits 0. Every gate in this repo reads that as a pass.
|
||||
#
|
||||
# ADR-0014's reason for two hook IDs was this same problem, and .pre-commit-
|
||||
# hooks.yaml still carries both IDs after the merge for that reason.
|
||||
# config.yaml still carries both IDs after the merge for that reason.
|
||||
|
||||
# One representative path per file shape the prefilter is supposed to cover,
|
||||
# tagged with the `.vale.ini` section that is supposed to cover it and with
|
||||
@@ -1365,8 +1366,15 @@ fi
|
||||
#
|
||||
# `demo.md` (bare, no `.agent.md` suffix) exercises `[**/agents/*.md]` in
|
||||
# isolation, not because any current `.apm/agents/*` file has that shape -- per
|
||||
# ADR-0016 they are all `*.agent.md`. `.pre-commit-hooks.yaml`'s agent regex
|
||||
# still covers the bare shape, which is what keeps the row honest.
|
||||
# ADR-0016 they are all `*.agent.md`. factory-audit's agent flow can still be
|
||||
# handed that shape in a consuming repo, which is what keeps the row honest.
|
||||
#
|
||||
# These rows describe what factory-audit's own Vale call can be handed at
|
||||
# runtime in any repo, not what this repo's hooks select: most of them sit
|
||||
# outside `.pre-commit-config.yaml`'s `^plugins/`-anchored `files:` regexes on
|
||||
# purpose. The hook-scope half that once held each row to a hook regex read the
|
||||
# published `.pre-commit-hooks.yaml`, which is retired (ADR-0014, 2026-09-16
|
||||
# amendment); case 32 owns the local hooks' scope.
|
||||
PROBE_TABLE28="$(cat <<'EOF_PROBE28'
|
||||
plugins/demo/.apm/skills/demo/SKILL.md|[**/SKILL.md]|isolating
|
||||
.claude/skills/demo/SKILL.md|[**/SKILL.md]|isolating
|
||||
@@ -1428,12 +1436,11 @@ files_scanned28() {
|
||||
| tail -1
|
||||
}
|
||||
|
||||
# Prints `<id>|<files regex>` for every hook in the given pre-commit manifest
|
||||
# whose entry is factory-audit's vale-wrap.sh -- either manifest, since the two
|
||||
# carry the same two hooks in the same shape. Records are delimited by their
|
||||
# Prints `<id>|<files regex>` for every hook in the given pre-commit config
|
||||
# whose entry is factory-audit's vale-wrap.sh. Records are delimited by their
|
||||
# `- id:` line, so this does not depend on `entry:` preceding `files:` within a
|
||||
# record. Case 28 wants the regexes alone and case 32 needs to know which hook
|
||||
# each belongs to, so the id is carried here and dropped by the wrapper below.
|
||||
# record. Case 32 needs to know which hook each regex belongs to, so the id is
|
||||
# carried here.
|
||||
hook_records28() {
|
||||
local manifest="$1" id raw
|
||||
[[ -f "$manifest" ]] || return 0
|
||||
@@ -1456,29 +1463,8 @@ hook_records28() {
|
||||
done
|
||||
}
|
||||
|
||||
# Prints the `files:` regex of every hook in the given manifest whose entry is
|
||||
# factory-audit's vale-wrap.sh.
|
||||
hook_file_regexes28() {
|
||||
hook_records28 "$1" | cut -d'|' -f2-
|
||||
}
|
||||
|
||||
matches_any_regex28() {
|
||||
local rel="$1" regexes="$2" re
|
||||
[[ -n "$regexes" ]] || return 1
|
||||
while IFS= read -r re; do
|
||||
[[ -n "$re" ]] || continue
|
||||
if grep -Eq "$re" <<< "$rel"; then
|
||||
return 0
|
||||
fi
|
||||
done <<EOF_RE28
|
||||
$regexes
|
||||
EOF_RE28
|
||||
return 1
|
||||
}
|
||||
|
||||
TREE28="$(build_probe_tree28)"
|
||||
new_fixture "$TREE28"
|
||||
HOOK_REGEXES28="$(hook_file_regexes28 "$REPO_ROOT/.pre-commit-hooks.yaml")"
|
||||
|
||||
echo ""
|
||||
echo "--- every .vale.ini glob section actually scans a real file shape ---"
|
||||
@@ -1526,24 +1512,13 @@ fi
|
||||
# One `PASS|<rel>|<message>` or `FAIL|<rel>|<message>` line per probe row, for
|
||||
# the config at $1. A function rather than an inline loop so Part B can hold a
|
||||
# mutated copy to this exact logic -- a second, "equivalent" loop for the
|
||||
# fixture would prove nothing about the live check. With $2 = false only the
|
||||
# hook-scope half runs: that half reads .pre-commit-hooks.yaml, not vale, so a
|
||||
# machine without vale still gets it.
|
||||
# fixture would prove nothing about the live check. With $2 = false it prints
|
||||
# nothing: every remaining half of it needs vale.
|
||||
probe_coverage28() {
|
||||
local cfg="$1" with_vale="$2" rel sec report count
|
||||
[[ "$with_vale" == true ]] || return 0
|
||||
while IFS='|' read -r rel sec _; do
|
||||
[[ -n "$rel" ]] || continue
|
||||
if ! matches_any_regex28 "$rel" "$HOOK_REGEXES28"; then
|
||||
# Original wording: the probe path is stale, or the hook was rescoped away
|
||||
# from a shape it still needs to lint. Either way the row below stops
|
||||
# describing anything the push gate actually hands to vale.
|
||||
echo "FAIL|$rel|$rel matches no 'files:' regex of any factory-audit vale hook in .pre-commit-hooks.yaml — the probe path is stale, or the hook was rescoped away from a shape it still needs to lint"
|
||||
continue
|
||||
fi
|
||||
if [[ "$with_vale" != true ]]; then
|
||||
echo "PASS|$rel|$rel is in scope of a published factory-audit vale hook (glob coverage not checked: Vale-dependent half held back)"
|
||||
continue
|
||||
fi
|
||||
report="$(vale_report28 "$cfg" "$TREE28" "$rel")"
|
||||
count="$(files_scanned28 "$report")"
|
||||
if [[ -z "$count" ]]; then
|
||||
@@ -1995,25 +1970,29 @@ fi
|
||||
# which worked only because the two skills gave the two hooks two distinct entry
|
||||
# paths. After the merge both hooks share one `entry:`, so that selector can no
|
||||
# longer tell them apart and a faithful port would have to key on hook `id:`
|
||||
# instead. Case 33 is that port; this case covers the separate question of
|
||||
# whether each local hook selects a live corpus at all. The zero-match half of the hole stands on its own, and nothing
|
||||
# else in the repo covers it: tests/test-vale-hooks-consumer.sh synthesises its
|
||||
# own consumer config out of `.pre-commit-hooks.yaml` and never reads the local
|
||||
# one, and cases 28-30 read `.pre-commit-hooks.yaml` too. This repo's OWN
|
||||
# prefilter regexes -- `.pre-commit-config.yaml`'s vale-audit-prefilter-skill and
|
||||
# vale-audit-prefilter-agent -- are therefore asserted by no test at all. Narrow
|
||||
# either one to match zero files and every gate still passes: pre-commit does not
|
||||
# instead. That port was case 33, deleted with the published
|
||||
# `.pre-commit-hooks.yaml` it compared against (ADR-0014, 2026-09-16
|
||||
# amendment); its one guard that did not depend on the second manifest -- a
|
||||
# local regex narrowed to a single plugin -- is property 3 below. Nothing else
|
||||
# in the repo asserts this repo's OWN prefilter regexes --
|
||||
# `.pre-commit-config.yaml`'s vale-audit-prefilter-skill and
|
||||
# vale-audit-prefilter-agent. Narrow either one to match zero files and every gate still passes: pre-commit does not
|
||||
# error on a hook that matches nothing, it simply never runs it. That is the same
|
||||
# silent-zero failure mode case 28 guards on the vale side of this pipeline, one
|
||||
# layer up -- there the glob scans 0 files and exits 0, here the hook is handed 0
|
||||
# files and never starts.
|
||||
#
|
||||
# Two properties, because matching SOMETHING is not the same as matching the
|
||||
# Three properties, because matching SOMETHING is not the same as matching the
|
||||
# right thing: a regex loosened to `^plugins/` would match hundreds of files and
|
||||
# clear a bare non-emptiness check while handing vale a corpus it has no glob
|
||||
# for. So each hook must also select only its own artifact class -- ADR-0014's
|
||||
# reason for two hook IDs, carried across the merge by ADR-0025's comment in the
|
||||
# config, is precisely that the two scopes stay independently addressable.
|
||||
# And each hook must select ALL of its class's authoring source: a regex
|
||||
# narrowed from `^plugins/[^/]+/...` to `^plugins/kyberforge/...` still matches
|
||||
# tracked files, all of the right class, while silently dropping every other
|
||||
# plugin out of the prefilter. Measured before any case caught it: that exact
|
||||
# narrowing left 6 of 38 skills prefiltered and the whole suite green.
|
||||
|
||||
echo ""
|
||||
echo "--- each .pre-commit-config.yaml vale prefilter hook matches a real, correctly-classed file ---"
|
||||
@@ -2031,7 +2010,7 @@ REPO_FILES32="$(cd "$REPO_ROOT" && git ls-files)"
|
||||
# the live check.
|
||||
prefilter_scope_failures32() {
|
||||
local config="$1" files="$2"
|
||||
local records id re class matched count offenders offending m
|
||||
local records id re class matched count offenders offending m corpus missing nmissing
|
||||
local seen_skill=false seen_agent=false bad=""
|
||||
records="$(hook_records28 "$config")"
|
||||
if [[ -z "$records" ]]; then
|
||||
@@ -2082,6 +2061,19 @@ EOF_MATCHED32
|
||||
if [[ "$offending" -gt 0 ]]; then
|
||||
bad+="[$id: 'files: $re' selects $count file(s), $offending of them outside the $class artifact class, so the two prefilter scopes are no longer independently addressable and vale is handed files no glob in its config covers — first: $offenders] "
|
||||
fi
|
||||
# The class's full authoring-source corpus, per the layout AGENTS.md fixes
|
||||
# (`plugins/<name>/.apm/` is the only authoring source). Every path in it
|
||||
# must be selected, or part of the corpus is silently unprefiltered.
|
||||
if [[ "$class" == skill ]]; then
|
||||
corpus="$(printf '%s\n' "$files" | { grep -E '^plugins/[^/]+/\.apm/skills/[^/]+/SKILL\.md$' || true; })"
|
||||
else
|
||||
corpus="$(printf '%s\n' "$files" | { grep -E '^plugins/[^/]+/\.apm/agents/[^/]+\.agent\.md$' || true; })"
|
||||
fi
|
||||
missing="$(comm -23 <(printf '%s\n' "$corpus" | grep . | sort) <(printf '%s\n' "$matched" | grep . | sort) || true)"
|
||||
nmissing="$(printf '%s\n' "$missing" | grep -c . || true)"
|
||||
if [[ "$nmissing" -gt 0 ]]; then
|
||||
bad+="[$id: 'files: $re' misses $nmissing tracked $class file(s) under plugins/*/.apm/, so part of the corpus is never prefiltered while every gate still reports a pass — first: $(printf '%s\n' "$missing" | head -3 | tr '\n' ' ')] "
|
||||
fi
|
||||
done <<EOF_RECORDS32
|
||||
$records
|
||||
EOF_RECORDS32
|
||||
@@ -2129,205 +2121,35 @@ else
|
||||
pass "narrowing either hook's 'files:' regex to match zero files is caught by Part A, which is what makes its pass mean something"
|
||||
fi
|
||||
|
||||
# --- 33. The local and published vale hooks agree on every shared file shape --
|
||||
#
|
||||
# The cross-manifest `files:` agreement check of the deleted
|
||||
# scripts/check-vale-style-sync.sh (ADR-0025), ported. Case 32 does not cover
|
||||
# it: a local regex narrowed from `^plugins/[^/]+/...` to
|
||||
# `^plugins/kyberforge/...` still matches tracked files, all of them SKILL.md,
|
||||
# so it clears both of 32's properties while silently dropping every other
|
||||
# plugin's skills out of this repo's prefilter. Measured before this case
|
||||
# existed: that exact narrowing left the whole suite green.
|
||||
#
|
||||
# The original's comment on why the two manifests are compared per hook rather
|
||||
# than unioned, verbatim in substance: "A union here previously let a probe that
|
||||
# matched only the older, looser .pre-commit-hooks.yaml pattern read as 'in
|
||||
# scope' even after .pre-commit-config.yaml's copy of the same hook had been
|
||||
# narrowed away from it -- silently masking exactly the kind of hook-rescoping
|
||||
# drift this script exists to catch."
|
||||
#
|
||||
# What changed in the port is the selector and nothing else. The original found
|
||||
# each manifest's hook by `entry ~ skill "/scripts/vale-wrap.sh"`, which told the
|
||||
# two hooks apart only because two skills gave them two entry paths; after the
|
||||
# merge both hooks share one entry. They are paired by `id:` instead, from an
|
||||
# explicit table. The pairing is explicit rather than inferred from an id suffix
|
||||
# so that renaming either id fails here by name instead of quietly dropping a
|
||||
# class out of the comparison. The probe table and its shared/hooks-only scopes
|
||||
# are the original's rows, unchanged:
|
||||
#
|
||||
# shared -- a shape this repo's own layout has, so both manifests must
|
||||
# agree on it. This is what catches the narrowing above.
|
||||
# hooks-only -- a shape only the layout-agnostic published manifest has to
|
||||
# cover. `.pre-commit-config.yaml` pinning this repo's own
|
||||
# `plugins/*/.apm/` layout is by design, not drift; bare
|
||||
# `agents/demo.md` is hooks-only because per ADR-0016 every
|
||||
# `.apm/agents/` file is `*.agent.md`.
|
||||
#
|
||||
# A probe in scope of neither manifest fails too, exactly as in the original:
|
||||
# the probe path is stale, or both hooks were rescoped away from it.
|
||||
|
||||
HOOK_PAIRS33="$(cat <<'EOF_PAIRS33'
|
||||
skill|kyberforge-vale-audit-skill|vale-audit-prefilter-skill
|
||||
agent|kyberforge-vale-audit-agent|vale-audit-prefilter-agent
|
||||
EOF_PAIRS33
|
||||
)"
|
||||
|
||||
PROBES33="$(cat <<'EOF_PROBES33'
|
||||
skill|plugins/demo/.apm/skills/demo/SKILL.md|shared
|
||||
skill|.claude/skills/demo/SKILL.md|hooks-only
|
||||
agent|plugins/demo/.apm/agents/demo.md|hooks-only
|
||||
agent|plugins/demo/.apm/agents/demo.agent.md|shared
|
||||
agent|.claude/agents/demo.md|hooks-only
|
||||
agent|copilot/demo.agent.md|hooks-only
|
||||
EOF_PROBES33
|
||||
)"
|
||||
|
||||
# Prints the `files:` regex of the hook whose id is exactly $2 in manifest $1,
|
||||
# or nothing. Records are delimited by their `- id:` line, as in
|
||||
# hook_records28, but selected by id alone: after the merge `entry:` no longer
|
||||
# distinguishes them.
|
||||
hook_regex_by_id33() {
|
||||
local manifest="$1" want="$2" raw
|
||||
[[ -f "$manifest" ]] || return 0
|
||||
raw="$(WANT="$want" awk '
|
||||
function flush() {
|
||||
if (id == ENVIRON["WANT"] && files != "") print files
|
||||
id = ""; files = ""
|
||||
}
|
||||
/^[ \t]*-[ \t]*id:/ { flush(); id = $0; sub(/^[ \t]*-[ \t]*id:[ \t]*/, "", id); sub(/[ \t]+$/, "", id) }
|
||||
/^[ \t]*files:/ { files = $0; sub(/^[ \t]*files:[ \t]*/, "", files) }
|
||||
END { flush() }
|
||||
' "$manifest" | head -1)"
|
||||
raw="${raw%\'}"; raw="${raw#\'}"
|
||||
raw="${raw%\"}"; raw="${raw#\"}"
|
||||
printf '%s' "$raw"
|
||||
}
|
||||
|
||||
# Prints one failure token per defect for published manifest $1 against local
|
||||
# manifest $2; empty output means every probe is in scope of at least one of its
|
||||
# class's hooks and every shared probe is in scope of both. Manifest paths are
|
||||
# arguments so Part B runs this exact function against mutated copies.
|
||||
cross_manifest_failures33() {
|
||||
local published="$1" local_cfg="$2"
|
||||
local class pub_id loc_id rel scope pub_re loc_re in_hooks in_config
|
||||
local bad="" checked=0 shared_classes="" missing_classes=""
|
||||
while IFS='|' read -r class pub_id loc_id; do
|
||||
[[ -n "$class" ]] || continue
|
||||
if [[ -z "$(hook_regex_by_id33 "$published" "$pub_id")" ]]; then
|
||||
bad+="[no hook with id '$pub_id' and a files: regex in ${published##*/}, so the $class class is compared against nothing] "
|
||||
missing_classes+="$class "
|
||||
fi
|
||||
if [[ -z "$(hook_regex_by_id33 "$local_cfg" "$loc_id")" ]]; then
|
||||
bad+="[no hook with id '$loc_id' and a files: regex in ${local_cfg##*/}, so the $class class is compared against nothing] "
|
||||
missing_classes+="$class "
|
||||
fi
|
||||
done <<EOF_PAIRCHK33
|
||||
$HOOK_PAIRS33
|
||||
EOF_PAIRCHK33
|
||||
while IFS='|' read -r class rel scope; do
|
||||
[[ -n "$class" ]] || continue
|
||||
pub_id="$(printf '%s\n' "$HOOK_PAIRS33" | awk -F'|' -v c="$class" '$1 == c { print $2 }')"
|
||||
loc_id="$(printf '%s\n' "$HOOK_PAIRS33" | awk -F'|' -v c="$class" '$1 == c { print $3 }')"
|
||||
if [[ -z "$pub_id" || -z "$loc_id" ]]; then
|
||||
bad+="[probe $rel names class '$class', which has no row in the hook pair table] "
|
||||
continue
|
||||
fi
|
||||
pub_re="$(hook_regex_by_id33 "$published" "$pub_id")"
|
||||
loc_re="$(hook_regex_by_id33 "$local_cfg" "$loc_id")"
|
||||
# A missing hook was reported above; comparing against it would add a
|
||||
# second message for the same defect.
|
||||
[[ -n "$pub_re" && -n "$loc_re" ]] || continue
|
||||
checked=$((checked + 1))
|
||||
[[ "$scope" != shared ]] || shared_classes+="$class "
|
||||
in_hooks=false
|
||||
matches_any_regex28 "$rel" "$pub_re" && in_hooks=true
|
||||
in_config=false
|
||||
matches_any_regex28 "$rel" "$loc_re" && in_config=true
|
||||
if [[ "$in_hooks" == false && "$in_config" == false ]]; then
|
||||
bad+="[$rel matches neither $pub_id's nor $loc_id's 'files:' regex — the probe path is stale, or the hook was rescoped away from a shape it still needs to lint] "
|
||||
elif [[ "$scope" == shared && "$in_hooks" != "$in_config" ]]; then
|
||||
bad+="[$rel is in scope of $pub_id in ${published##*/} (hooks=$in_hooks) but not of $loc_id in ${local_cfg##*/} (config=$in_config), or vice versa — the local and published 'files:' regexes have drifted, and the narrower one silently stops prefiltering that shape] "
|
||||
fi
|
||||
done <<EOF_PROBECHK33
|
||||
$PROBES33
|
||||
EOF_PROBECHK33
|
||||
# The original's PROBES_CHECKED floor, plus one it lacked: a class with no
|
||||
# shared probe is never compared across manifests at all.
|
||||
if [[ "$checked" -eq 0 && -z "$missing_classes" ]]; then
|
||||
bad+="[no probe row was checked, so the agreement check verified nothing] "
|
||||
fi
|
||||
while IFS='|' read -r class _ _; do
|
||||
[[ -n "$class" ]] || continue
|
||||
# A class whose hook is missing was already reported, and its probes were
|
||||
# skipped for that reason, not for want of a shared row.
|
||||
[[ " $missing_classes" != *" $class "* ]] || continue
|
||||
[[ " $shared_classes" == *" $class "* ]] \
|
||||
|| bad+="[the $class class has no shared probe row, so its two hooks are never compared] "
|
||||
done <<EOF_CLASSCHK33
|
||||
$HOOK_PAIRS33
|
||||
EOF_CLASSCHK33
|
||||
printf '%s' "$bad"
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "--- the local and published vale hooks agree on every shared file shape ---"
|
||||
|
||||
PUBLISHED33="$REPO_ROOT/.pre-commit-hooks.yaml"
|
||||
|
||||
# Part A: the live assertion, against the two real manifests.
|
||||
AGREE_FAILS33="$(cross_manifest_failures33 "$PUBLISHED33" "$PC_CONFIG32")"
|
||||
if [[ -n "$AGREE_FAILS33" ]]; then
|
||||
fail ".pre-commit-config.yaml and .pre-commit-hooks.yaml disagree on the vale hooks' scope: $AGREE_FAILS33"
|
||||
else
|
||||
pass "every probe is in scope of its class's hooks, and both manifests agree on every shared shape"
|
||||
fi
|
||||
|
||||
# Part B: proof that Part A can fail. Each mutation lands in a COPY of
|
||||
# .pre-commit-config.yaml, never the real file.
|
||||
#
|
||||
# skill -- the exact narrowing that slipped through before this case existed.
|
||||
# agent -- the same narrowing applied to the agent hook.
|
||||
# id -- the local skill hook renamed, which must fail by name rather than
|
||||
# drop the skill class out of the comparison.
|
||||
#
|
||||
# Replacement is fixed-string, via ENVIRON, so the regex's backslashes reach awk
|
||||
# literally; `awk -v` would process them as escape sequences.
|
||||
narrow_config33() {
|
||||
OLD33="$2" NEW33="$3" awk '
|
||||
$0 ~ ENVIRON["LINE33"] {
|
||||
i = index($0, ENVIRON["OLD33"])
|
||||
if (i) $0 = substr($0, 1, i - 1) ENVIRON["NEW33"] substr($0, i + length(ENVIRON["OLD33"]))
|
||||
# Part C: proof that property 3 can fail. Each hook's regex is narrowed to one
|
||||
# plugin in a COPY of the config -- the exact narrowing that once slipped
|
||||
# through. Replacement is fixed-string, via ENVIRON, so the regex's backslashes
|
||||
# reach awk literally; `awk -v` would process them as escape sequences.
|
||||
narrow_config32() {
|
||||
OLD32="$2" NEW32="$3" awk '
|
||||
/^[ \t]*files:/ {
|
||||
i = index($0, ENVIRON["OLD32"])
|
||||
if (i) $0 = substr($0, 1, i - 1) ENVIRON["NEW32"] substr($0, i + length(ENVIRON["OLD32"]))
|
||||
}
|
||||
{ print }
|
||||
' "$1"
|
||||
}
|
||||
MUT33="$(mktemp -d)"
|
||||
new_fixture "$MUT33"
|
||||
LINE33='^[ \t]*files:' narrow_config33 "$PC_CONFIG32" \
|
||||
'^plugins/[^/]+/\.apm/skills/' '^plugins/kyberforge/\.apm/skills/' > "$MUT33/skill.yaml"
|
||||
LINE33='^[ \t]*files:' narrow_config33 "$PC_CONFIG32" \
|
||||
'^plugins/[^/]+/\.apm/agents/' '^plugins/kyberforge/\.apm/agents/' > "$MUT33/agent.yaml"
|
||||
LINE33='^[ \t]*-[ \t]*id:' narrow_config33 "$PC_CONFIG32" \
|
||||
'vale-audit-prefilter-skill' 'vale-audit-prefilter-skill-renamed' > "$MUT33/id.yaml"
|
||||
SKILL_FAILS33="$(cross_manifest_failures33 "$PUBLISHED33" "$MUT33/skill.yaml")"
|
||||
AGENT_FAILS33="$(cross_manifest_failures33 "$PUBLISHED33" "$MUT33/agent.yaml")"
|
||||
ID_FAILS33="$(cross_manifest_failures33 "$PUBLISHED33" "$MUT33/id.yaml")"
|
||||
if ! grep -qF '^plugins/kyberforge/\.apm/skills/' "$MUT33/skill.yaml" \
|
||||
|| ! grep -qF '^plugins/kyberforge/\.apm/agents/' "$MUT33/agent.yaml" \
|
||||
|| ! grep -qF 'vale-audit-prefilter-skill-renamed' "$MUT33/id.yaml"; then
|
||||
fail "a mutation never reached its copied config, so Part B mutated nothing and proves nothing about Part A"
|
||||
elif ! grep -qF "[plugins/demo/.apm/skills/demo/SKILL.md is in scope of kyberforge-vale-audit-skill" <<< "$SKILL_FAILS33"; then
|
||||
fail "narrowing the local skill hook to ^plugins/kyberforge/ did not fail Part A, so the prefilter can drop every other plugin's skills with every gate green: ${SKILL_FAILS33:-<no failure>}"
|
||||
elif [[ -z "$AGREE_FAILS33" ]] && grep -qF "kyberforge-vale-audit-agent" <<< "$SKILL_FAILS33"; then
|
||||
# Only meaningful against a clean base: when Part A already failed, the copy
|
||||
# inherits that defect, and reporting it again here would be one defect twice.
|
||||
fail "narrowing only the skill hook also reported an agent-class defect, so the comparison is not confined to its class: $SKILL_FAILS33"
|
||||
elif ! grep -qF "[plugins/demo/.apm/agents/demo.agent.md is in scope of kyberforge-vale-audit-agent" <<< "$AGENT_FAILS33"; then
|
||||
fail "narrowing the local agent hook to ^plugins/kyberforge/ did not fail Part A: ${AGENT_FAILS33:-<no failure>}"
|
||||
elif ! grep -qF "[no hook with id 'vale-audit-prefilter-skill' and a files: regex in id.yaml" <<< "$ID_FAILS33"; then
|
||||
fail "renaming the local skill hook's id did not fail Part A by name, so the skill class could fall out of the comparison silently: ${ID_FAILS33:-<no failure>}"
|
||||
narrow_config32 "$PC_CONFIG32" \
|
||||
'^plugins/[^/]+/\.apm/skills/' '^plugins/kyberforge/\.apm/skills/' > "$MUT32/skill.yaml"
|
||||
narrow_config32 "$PC_CONFIG32" \
|
||||
'^plugins/[^/]+/\.apm/agents/' '^plugins/kyberforge/\.apm/agents/' > "$MUT32/agent.yaml"
|
||||
NARROW_SKILL32="$(prefilter_scope_failures32 "$MUT32/skill.yaml" "$REPO_FILES32")"
|
||||
NARROW_AGENT32="$(prefilter_scope_failures32 "$MUT32/agent.yaml" "$REPO_FILES32")"
|
||||
if cmp -s "$PC_CONFIG32" "$MUT32/skill.yaml" || cmp -s "$PC_CONFIG32" "$MUT32/agent.yaml"; then
|
||||
fail "a one-plugin narrowing left the copied config unchanged, so Part C narrowed nothing and proves nothing about property 3"
|
||||
elif ! grep -qF "vale-audit-prefilter-skill: 'files: ^plugins/kyberforge/" <<< "$NARROW_SKILL32" \
|
||||
|| ! grep -qF "tracked skill file(s)" <<< "$NARROW_SKILL32"; then
|
||||
fail "narrowing the skill hook to ^plugins/kyberforge/ did not fail Part A as an incomplete corpus: ${NARROW_SKILL32:-<no failure>}"
|
||||
elif ! grep -qF "vale-audit-prefilter-agent: 'files: ^plugins/kyberforge/" <<< "$NARROW_AGENT32" \
|
||||
|| ! grep -qF "tracked agent file(s)" <<< "$NARROW_AGENT32"; then
|
||||
fail "narrowing the agent hook to ^plugins/kyberforge/ did not fail Part A as an incomplete corpus: ${NARROW_AGENT32:-<no failure>}"
|
||||
else
|
||||
pass "narrowing either local hook to one plugin, or renaming one, is caught by Part A"
|
||||
pass "narrowing either hook's 'files:' regex to one plugin is caught by Part A as an incomplete corpus"
|
||||
fi
|
||||
|
||||
# --- 34. Every glob section loads a real style, asserted without vale --------
|
||||
@@ -2342,7 +2164,7 @@ fi
|
||||
# file the section matches with NO rule, prints `0 errors ... in 1 file` and
|
||||
# exits 0, and every gate in this repo reads that as a pass. That is the
|
||||
# silent-pass class ADR-0013 exists to prevent, and `--strict` -- which a
|
||||
# consumer's clone does not run -- is the only thing standing in front of it.
|
||||
# consuming repo's install does not run -- is the only thing standing in front of it.
|
||||
#
|
||||
# So this case asks the same question of the config TEXT, with no dependency on
|
||||
# vale being installed. It is a separate case rather than an addition to either
|
||||
@@ -2354,9 +2176,9 @@ fi
|
||||
# `[[ "$sp" == /* ]] || sp="$dir/$sp"`, which ACCEPTS an absolute StylesPath --
|
||||
# a path that resolves on the machine that wrote it and on no other. Relative
|
||||
# resolution against the config's own directory is the only reason the bundled
|
||||
# styles are found under a CONSUMING repo's clone prefix, so an absolute one
|
||||
# passes every check here and hard-fails every external consumer of
|
||||
# .pre-commit-hooks.yaml. It is asserted here, not in case 0, to keep case 0's
|
||||
# styles are found under a CONSUMING repo's install of factory-audit, so an
|
||||
# absolute one passes every check here and hard-fails every repo that installs
|
||||
# the plugin. It is asserted here, not in case 0, to keep case 0's
|
||||
# scope the one its comment describes.
|
||||
|
||||
VALE_ASSETS34="$FACTORY_AUDIT/assets/vale"
|
||||
@@ -2383,7 +2205,7 @@ section_styles34() {
|
||||
|
||||
# One failure token per defect in the config at $1; empty output means every
|
||||
# section would load at least one real style for a consumer. The section floor
|
||||
# is here for the same reason case 28 Part A and case 33 carry theirs: a config
|
||||
# is here for the same reason case 28 Part A carries its own: a config
|
||||
# whose sections were all deleted lints nothing at all, and without a floor this
|
||||
# function would report it clean.
|
||||
style_load_defects34() {
|
||||
@@ -2402,7 +2224,7 @@ style_load_defects34() {
|
||||
return 0
|
||||
fi
|
||||
if [[ "$sp" == /* ]]; then
|
||||
printf '%s' "[${cfg##*/} sets the ABSOLUTE StylesPath '$sp'; it resolves only on the machine that wrote it, and a consuming repo's clone -- which is the only reason .pre-commit-hooks.yaml ships these styles -- gets 'path does not exist'] "
|
||||
printf '%s' "[${cfg##*/} sets the ABSOLUTE StylesPath '$sp'; it resolves only on the machine that wrote it, and a consuming repo's install of factory-audit -- which is the only reason these styles ship -- gets 'path does not exist'] "
|
||||
return 0
|
||||
fi
|
||||
if [[ ! -d "$dir/$sp" ]]; then
|
||||
@@ -2490,7 +2312,7 @@ EOF_MUT34
|
||||
ABS34="$(mktemp -d)"
|
||||
new_fixture "$ABS34"
|
||||
cp -r "$VALE_ASSETS34/." "$ABS34/"
|
||||
# Written through ENVIRON, as in narrow_config33, so the path reaches awk
|
||||
# Written through ENVIRON, as in narrow_config32, so the path reaches awk
|
||||
# literally rather than through `-v`'s escape processing.
|
||||
ABS_SP34="$ABS34/styles" awk '
|
||||
/^[ \t]*StylesPath[ \t]*=/ { print "StylesPath = " ENVIRON["ABS_SP34"]; next }
|
||||
|
||||
Reference in New Issue
Block a user