7 Commits

Author SHA1 Message Date
9a3f72b696 test(lint): stop the release-gate suite inheriting the caller's PRE_COMMIT refs
run_check set PRE_COMMIT_REMOTE_BRANCH and, when asked, PRE_COMMIT_TO_REF, but
never cleared what was already in the environment. Standalone that is invisible
— nothing sets those vars. Under the pre-push hook this suite exists to guard,
pre-commit exports PRE_COMMIT_TO_REF and PRE_COMMIT_FROM_REF as shas of the
real repo; the fixtures inherited them, the script resolved a rev that does not
exist in the fixture, and 13 of 20 cases failed. The suite passed in every
context except the only one that matters.

The variables are now cleared in both branches, so a standalone run and a
pre-push run are the same test. Verified 20/20 with the vars unset and with
them set to real shas of this repo.

Found by the pre-push hook rejecting the push, not by any test — the same shape
as the --config regression: the local invocation exercised a different thing
than the shipped one, and the two were indistinguishable by reading the file.

Refs: #85
2026-08-09 17:28:07 +00:00
7cf9a98509 docs(lessons): record two patterns from PR #85's round 6
The aggregate-assertion failure joins the "a clean result can mean nothing ran"
family as its fifth instance: a total over N subjects is satisfiable by a
proper subset, so it proves nothing about any individual subject. Records the
reverse mutation sweep — neuter each assertion, confirm exactly one case fails
— as standing practice for checks whose failure mode is silence.

The second entry is about accepted residuals: the U+2019 rewrite survived
review because its justification was documented in the same breath as the
workaround, and the covering test asserted the residual's presence rather than
the behaviour it cost. Documentation records a belief; a belief adjacent to a
workaround is the one most worth attacking.

Refs: #85
2026-08-09 17:24:08 +00:00
997f0df23b chore(plugins): patch-bump kyberforge and lint for shipped changes
kyberforge 1.2.7 -> 1.2.8: both vale-wrap.sh copies, skill-audit's validate.sh
and its SKILL.md changed after the last bump. lint 1.1.4 -> 1.1.5: the Vale
research troubleshooting doc changed after its last bump.

Without the bump, installed copies keep serving the cached version. This is the
fourth time in this PR the bump was missed after shipped content changed —
check-manifests.sh validates parity between the two manifests but not that a
content change was accompanied by a bump, which is the gap that keeps letting
it through.

Refs: #85
2026-08-09 17:24:08 +00:00
302f6d0c19 fix(lint): tighten the SKILL.md word ceiling to 2770
MAX_WORDS=2900 was calibrated to the corpus median density and carried no
margin: at the densest observed 7.22 chars/word (~1.81 tokens/word) it permits
~5,240 tokens against the 5,000 it proxies for. 2770 holds the worst observed
density under the ceiling. The largest SKILL.md is 2,489 words, so the change
costs nothing today — 281 words of margin — and the header comment now argues
the new calibration rather than swapping the digits.

Both enforcement points move together, and a new test asserts they agree, since
a SKILL.md passing its own audit while the commit hook blocks it is the
disagreement this pair exists to prevent.

CONTEXT.md is deliberately left ungated: it is 2,816 words, and gating it would
block the build. Recorded here so the omission reads as a decision rather than
an oversight.

skill-audit's manual-fallback path listed only the line ceiling, so an agent
taking that path passed an oversized SKILL.md the hook then rejected. The word
ceiling is now named alongside it. agent-audit is deliberately unchanged: the
size hook scopes to SKILL.md only and agent-audit's validate.sh has no word
gate, so claiming it there would be false.

The Vale research doc still showed the MDX {/* vale off */} form under a
Markdown heading, contradicting CONTEXT.md and vale-run's troubleshooting
reference — that form suppresses nothing in plain .md. Fixed in both places it
appeared.

tests/run-tests.sh used mapfile (bash 4.0+) with unguarded array expansion,
though AGENTS.md tells contributors to run it and macOS ships bash 3.2. It now
collects via a while-read loop over process substitution and guards every
expansion. The newline-delimited find|sort pipeline is kept rather than -print0
with sort -z, whose BSD portability is the weaker link, and which matches
mapfile -t's previous behaviour exactly.

Refs: #85
ADR: 0013
2026-08-09 17:23:51 +00:00
f6eb0d295e fix(lint): derive the release gate from the pushed ref, reject multi-token entries
The gate hardcoded HEAD as its diff tip, but pre-commit exports
PRE_COMMIT_TO_REF for exactly this. Pushing "somebranch:main" from another
checkout diffed the wrong tip — a false negative when HEAD is older, a false
positive when newer. Fixing only the diff tip leaves a second bug: git describe
took the tag baseline from HEAD too, so a tag reachable only from HEAD becomes
a baseline the pushed ref never saw. Both now resolve from the pushed ref, and
an all-zeros ref (branch deletion) short-circuits before any rev resolution
rather than surfacing as "could not diff".

PRE_COMMIT_FROM_REF is deliberately not used: it is the remote's current tip,
so diffing from it would let an untagged release-relevant commit already on
main excuse the next push from cutting a tag — the drift this gate exists to
catch. The baseline must stay the last release tag.

collect_release_paths took tokens[0] as a path unconditionally. ADR-0014 makes
bare single-path entries a binding constraint, but nothing enforced it, and the
sibling .pre-commit-config.yaml already ships "entry: bash <script>". Under
that shape add_release_path takes "bash", git diff accepts the non-matching
pathspec silently, bundle_root becomes "." and is skipped — the hook's whole
surface leaves the gate with no error, the same shape as the --config
regression in LESSONS.md. Multi-token entries now fail loudly naming the hook
and the ADR, and tokens[0] must resolve at HEAD or at the tag (the union is
load-bearing: a per-scope check would reject the deletion cases).

Six mutations verified, each restored. One correction worth recording: the
first multi-token test passed with its guard removed, because the existence
guard caught "bash" and printed a similar message. It now requires the verbatim
entry text that only the multi-token diagnostic emits.

Refs: #85
ADR: 0014
2026-08-09 17:23:36 +00:00
ad1e5aaa9b fix(kyberforge): carry apostrophes verbatim through a |- literal block
The flattener's last-resort branch rewrote ASCII ' to U+2019, justified as the
one combination no YAML scalar can carry verbatim. That claim was false: a |-
literal block with a single indented content line carries ', ", \ and ": "
verbatim and keeps text.frontmatter.description matching — as the wrapper's own
docstring already said of literal blocks. The rewrite fired on 12 of 54
in-scope files, silently disabling every rule whose token contains an
apostrophe. Case 20 pinned only that the scope stayed alive, so it passed
either way.

The emission site now splits the emitted scalar on its first newline so a
carried-over trailing comment stays on the "description: |-" header line rather
than becoming part of the value, and pads by span_lines - 1 - newlines. The pad
stays non-negative because the branch is only reachable when the original span
is at least two lines. Verified across all 73 in-scope files: no line-count
changes, and exactly the 12 expected files take the new branch.

One reported position moves: an alert on a description that is itself flagged
shifts from the key line to the block's content line, both inside the original
span. YAML cannot put a literal block's content on the key's own line, so this
is unavoidable; no line at or after the end of any description span moves.

Also: --output no longer absolutises the built-in style names line, JSON and
CLI, which a same-named file or directory in cwd turned into a template path
(exit 2, E100 Runtime error). And case 19's empty-baseline guard no longer
lets five dependent comparisons print vacuous passes — while fixing it the
guard turned out to be unreachable, since under pipefail an alert-free report
aborted the script at the assignment.

Refs: #85
ADR: 0014
2026-08-09 17:23:24 +00:00
d25355077f fix(lint): attribute Vale alerts per hook and cover .vale.ini in the sync check
The external-consumer test asserted a combined alert count (>=2) across both
shipped Vale hooks, but the SKILL.md fixture alone raises two alerts — so one
working hook satisfied the threshold. Retargeting agent-audit's glob to match
nothing left the suite reporting "3 passed" under the message "both hooks
flatten and flag". The Skipped guard does not catch this: the hook still
matches the file, Vale lints nothing, reports 0 errors in 1 file and exits 0,
which pre-commit renders as Passed. An assertion aggregating over N subjects
proves nothing about any individual subject.

Each hook now runs individually and its alerts are attributed to the nearest
preceding path header, so an alert is checked by path rather than by presence
in the combined blob. The two fixtures carry distinct VagueWording tokens, so
one hook's alert cannot be credited to another.

Nothing in the repo read either .vale.ini — the sync check diffed only
vale-wrap.sh and styles/Kyberforge, so a one-line glob typo silently disabled
the prefilter for a whole file type. That was the enabling half of the same
defect. The check now asserts the shared lines both copies must carry
(StylesPath, a section naming Kyberforge as a whole word) without flagging
their intentional divergence, and probes each glob section by asking Vale
itself to lint a representative path. Regex-to-glob comparison was rejected as
it means reimplementing doublestar semantics in bash; a file-count dry-run was
rejected because a section whose glob matches but whose BasedOnStyles lost
Kyberforge reports "1 file" with no alerts and would pass it.

Every new assertion is bound to a failing case in both directions: breaking the
artifact fails the suite, and neutering the assertion fails exactly one case.
That reverse sweep exposed two assertions bound to no failing case at all, one
masked by a stronger check running first.

Refs: #85
2026-08-09 17:23:10 +00:00
22 changed files with 994 additions and 158 deletions

View File

@@ -74,7 +74,7 @@ Wiring Vale as a deterministic prefilter for `skill-audit`/`agent-audit`'s Descr
Both skills' Step 1, and the `vale-audit-prefilter-skill`/`-agent` pre-commit hooks, call each copy's own `scripts/vale-wrap.sh` rather than `vale` directly — a workaround for a confirmed Vale 3.15.2 limitation (see `vale-config`'s Gotchas): `text.frontmatter.description` silently stops matching on most — not all — multi-line descriptions. Verified by reproduction, not assumed: `>` folded scalars, plain (unquoted) continuation lines, and single- or double-quoted multi-line scalars all yield 0 alerts and exit 0 on a deliberately-bad fixture, while a `|` literal block spanning the same 2+ lines lints normally (alerts fire, exit 1). The wrapper flattens those three broken forms to one physical line in a scratch copy (padding with blank lines so every other line number is unchanged) before handing off to real `vale`; `|` literal blocks and single-line descriptions pass through untouched, already linting correctly. The plain and quoted forms previously passed silently — unflattened and unmatched — so a bad description in either sailed through the prefilter. 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 — which is why both manifests' `entry:` is now 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` 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 — 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. An explicit `--config` still wins, in all three argv forms (`--config X`, `--config=/abs`, `--config=rel`), and a relative one still resolves against the caller's cwd, matching bare `vale`, not the repo root. Both audit skills' Step 1 now passes no `--config` either: it resolves the script relative to the skill's own directory so the call works from an installed plugin cache, but a relative `--config` alongside it would still resolve against the cwd, yielding `E100 Runtime error ... does not exist` and exit 2 — which both skills' fallback misreads as "vale unavailable" and silently downgrades to full LLM judgment. `tests/test-vale-wrap.sh` regression-tests this against skill-audit's copy specifically (its fixtures are all `SKILL.md`-shaped, and only skill-audit's `.vale.ini` has that glob section). Each `.vale.ini`'s section globs are path-agnostic (`[**/SKILL.md]` for skill-audit's copy; `[**/agents/*.md]`/`[**/*.agent.md]` for agent-audit's) and do no scoping on their own: Vale's `*` crosses `/`. Scoping comes from each pre-commit hook's own `files:` regex and from the audit skills passing one explicit file per invocation. The two manifests scope differently on purpose: this repo's `.pre-commit-config.yaml` pins its own layout — `^plugins/[^/]+/skills/[^/]+/SKILL\.md$` for `-skill`, `^plugins/[^/]+/agents/[^/]+\.md$` for `-agent` — while the shipped `.pre-commit-hooks.yaml` stays layout-agnostic for external consumers whose skills live anywhere, using `(^|/)SKILL\.md$` and `(^|/)agents/[^/]+\.md$|\.agent\.md$`. Both manifests split the prefilter into two hooks precisely because one combined hook pointed at only one copy would silently 0-file-skip the other file type. A `SKILL.md` outside `plugins/` (e.g. project-scope `.claude/skills/foo/SKILL.md`) still matches `[**/SKILL.md]` and gets linted normally — the globs constrain filename shape, not location. Vale reports 0 files only when the path it is handed matches no glob section at all: a differently-named file, or a directory argument holding nothing that matches. That run prints `✔ 0 errors ... in 0 files.` and exits 0, indistinguishable from a clean pass, so both audits treat a 0-file Vale run as NOT RUN and fall back to full LLM judgment. Both skills' Step 1, and the `vale-audit-prefilter-skill`/`-agent` pre-commit hooks, call each copy's own `scripts/vale-wrap.sh` rather than `vale` directly — a workaround for a confirmed Vale 3.15.2 limitation (see `vale-config`'s Gotchas): `text.frontmatter.description` silently stops matching on most — not all — multi-line descriptions. Verified by reproduction, not assumed: `>` folded scalars, plain (unquoted) continuation lines, and single- or double-quoted multi-line scalars all yield 0 alerts and exit 0 on a deliberately-bad fixture, while a `|` literal block spanning the same 2+ lines lints normally (alerts fire, exit 1). The wrapper flattens those three broken forms to one physical line in a scratch copy (padding with blank lines so every other line number is unchanged) before handing off to real `vale`; `|` literal blocks and single-line descriptions pass through untouched, already linting correctly. The plain and quoted forms previously passed silently — unflattened and unmatched — so a bad description in either sailed through the prefilter. 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 — which is why both manifests' `entry:` is now 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` 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 — 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. An explicit `--config` still wins, in all three argv forms (`--config X`, `--config=/abs`, `--config=rel`), and a relative one still resolves against the caller's cwd, matching bare `vale`, not the repo root. Both audit skills' Step 1 now passes no `--config` either: it resolves the script relative to the skill's own directory so the call works from an installed plugin cache, but a relative `--config` alongside it would still resolve against the cwd, yielding `E100 Runtime error ... does not exist` and exit 2 — which both skills' fallback misreads as "vale unavailable" and silently downgrades to full LLM judgment. `tests/test-vale-wrap.sh` regression-tests this against skill-audit's copy specifically (its fixtures are all `SKILL.md`-shaped, and only skill-audit's `.vale.ini` has that glob section). Each `.vale.ini`'s section globs are path-agnostic (`[**/SKILL.md]` for skill-audit's copy; `[**/agents/*.md]`/`[**/*.agent.md]` for agent-audit's) and do no scoping on their own: Vale's `*` crosses `/`. Scoping comes from each pre-commit hook's own `files:` regex and from the audit skills passing one explicit file per invocation. The two manifests scope differently on purpose: this repo's `.pre-commit-config.yaml` pins its own layout — `^plugins/[^/]+/skills/[^/]+/SKILL\.md$` for `-skill`, `^plugins/[^/]+/agents/[^/]+\.md$` for `-agent` — while the shipped `.pre-commit-hooks.yaml` stays layout-agnostic for external consumers whose skills live anywhere, using `(^|/)SKILL\.md$` and `(^|/)agents/[^/]+\.md$|\.agent\.md$`. Both manifests split the prefilter into two hooks precisely because one combined hook pointed at only one copy would silently 0-file-skip the other file type. A `SKILL.md` outside `plugins/` (e.g. project-scope `.claude/skills/foo/SKILL.md`) still matches `[**/SKILL.md]` and gets linted normally — the globs constrain filename shape, not location. Vale reports 0 files only when the path it is handed matches no glob section at all: a differently-named file, or a directory argument holding nothing that matches. That run prints `✔ 0 errors ... in 0 files.` and exits 0, indistinguishable from a clean pass, so both audits treat a 0-file Vale run as NOT RUN and fall back to full LLM judgment.
This scope expands per ADR-0013: one cherry-picked low-noise `write-good`/`alex` rule landed in `styles/Kyberforge`, `Kyberforge.SentenceOpenerThereIs` (22 held-out hits, both in-corpus hits clean rewrites, zero suppressions). A second, `Kyberforge.VagueQualifier`, was cherry-picked and then deleted: 2 hits across the 41 skill/agent files, one marginal and one an unfixable false positive (`caveman/SKILL.md` quotes `of course` as an example of filler — a mention, not a use) that forced the repo's only Vale suppression comments. Also new is a sibling pre-commit hook, `skill-size-check` (`scripts/skill-size-check.sh`), enforcing agentskills.io's `SKILL.md` ceiling as two blocking gates: `MAX_LINES=500` and `MAX_WORDS=2900` (a word-count proxy for the 5,000-token limit, calibrated from this repo's measured ~1.6-1.7 tokens per word). Both are inclusive, and `skill-audit/scripts/validate.sh` checks the same pair on the same terms, so a `SKILL.md` can no longer pass its own audit yet be blocked by the commit hook. Scoped to `^plugins/[^/]+/skills/[^/]+/SKILL\.md$` only, same as `vale-audit-prefilter-skill`, so it never lints `docs/research/examples/` reference skills. It's also exposed in the root-level `.pre-commit-hooks.yaml` as `kyberforge-skill-size-check` — it has no external asset dependency, so it needed no relocation, only exposure to external consumers. File scope (`SKILL.md` + agent files) and enforcement model (rules land directly in `styles/Kyberforge`, blocking immediately, no trial tier) stay unchanged; governance.md/CONTROLS.md were evaluated and excluded as rule sources (nothing prose-pattern-matchable to mine). House convention: banned phrasing that must be mentioned rather than used goes in backticks or a fenced code block — Vale skips code spans and fences, so no suppression is needed; inline `<!-- vale Rule = NO -->` (HTML-comment form; the MDX `{/* */}` form does not work in plain Markdown) is the fallback only where backticking is impossible. This scope expands per ADR-0013: one cherry-picked low-noise `write-good`/`alex` rule landed in `styles/Kyberforge`, `Kyberforge.SentenceOpenerThereIs` (22 held-out hits, both in-corpus hits clean rewrites, zero suppressions). A second, `Kyberforge.VagueQualifier`, was cherry-picked and then deleted: 2 hits across the 41 skill/agent files, one marginal and one an unfixable false positive (`caveman/SKILL.md` quotes `of course` as an example of filler — a mention, not a use) that forced the repo's only Vale suppression comments. Also new is a sibling pre-commit hook, `skill-size-check` (`scripts/skill-size-check.sh`), enforcing agentskills.io's `SKILL.md` ceiling as two blocking gates: `MAX_LINES=500` and `MAX_WORDS=2770` (a word-count proxy for the 5,000-token limit, calibrated to the densest prose measured in this repo — 1.81 tokens per word — so even a worst-case `SKILL.md` at the ceiling stays under 5,000 tokens). Both are inclusive, and `skill-audit/scripts/validate.sh` checks the same pair on the same terms, so a `SKILL.md` can no longer pass its own audit yet be blocked by the commit hook. Scoped to `^plugins/[^/]+/skills/[^/]+/SKILL\.md$` only, same as `vale-audit-prefilter-skill`, so it never lints `docs/research/examples/` reference skills. It's also exposed in the root-level `.pre-commit-hooks.yaml` as `kyberforge-skill-size-check` — it has no external asset dependency, so it needed no relocation, only exposure to external consumers. File scope (`SKILL.md` + agent files) and enforcement model (rules land directly in `styles/Kyberforge`, blocking immediately, no trial tier) stay unchanged; governance.md/CONTROLS.md were evaluated and excluded as rule sources (nothing prose-pattern-matchable to mine). House convention: banned phrasing that must be mentioned rather than used goes in backticks or a fenced code block — Vale skips code spans and fences, so no suppression is needed; inline `<!-- vale Rule = NO -->` (HTML-comment form; the MDX `{/* */}` form does not work in plain Markdown) is the fallback only where backticking is impossible.
### LESSONS.md ### LESSONS.md
Long-loop feedback log for patterns observed across sessions. Three or more entries on the same pattern graduate to the relevant standing file (e.g. a coding convention, a governance rule). Updated by the session-handoff skill or directly by the human. Lives at the repo root. Long-loop feedback log for patterns observed across sessions. Three or more entries on the same pattern graduate to the relevant standing file (e.g. a coding convention, a governance rule). Updated by the session-handoff skill or directly by the human. Lives at the repo root.

View File

@@ -142,6 +142,8 @@ During write-skill refactor, an "open thread" note (about a deferred research st
Three separate times in one PR (#85), a check reported success because it had silently not run. (1) Vale's `text.frontmatter.description` scope stops matching once the value is a multi-line YAML block scalar — the style most skills here use — so a repo-wide sweep returned 0 alerts across 49 files and was read as a clean repo. (2) Five of six rules were `level: warning`, but Vale's exit code keys on `error` alone and pre-commit hides output from passing hooks, so those rules were invisible and blocked nothing for two review rounds while the ADR described them as "enforcing immediately." (3) `.vale.ini`'s globs matched no file outside `plugins/`, so Vale printed "0 files" and exited 0, which both audit skills read as "no findings" and used to skip their own judgment passes. Each time the green result was worse than no check at all, because it was cited as positive evidence of cleanliness. Fix: for any new check, prove it fails before trusting that it passes — run it against a deliberately-bad fixture, confirm the failure, then run the real corpus. Where a check can scan zero inputs, assert on the input count, not just the exit code. **[graduated → core/instructions/testing.md]** (4th instance below, kept for audit trail). Three separate times in one PR (#85), a check reported success because it had silently not run. (1) Vale's `text.frontmatter.description` scope stops matching once the value is a multi-line YAML block scalar — the style most skills here use — so a repo-wide sweep returned 0 alerts across 49 files and was read as a clean repo. (2) Five of six rules were `level: warning`, but Vale's exit code keys on `error` alone and pre-commit hides output from passing hooks, so those rules were invisible and blocked nothing for two review rounds while the ADR described them as "enforcing immediately." (3) `.vale.ini`'s globs matched no file outside `plugins/`, so Vale printed "0 files" and exited 0, which both audit skills read as "no findings" and used to skip their own judgment passes. Each time the green result was worse than no check at all, because it was cited as positive evidence of cleanliness. Fix: for any new check, prove it fails before trusting that it passes — run it against a deliberately-bad fixture, confirm the failure, then run the real corpus. Where a check can scan zero inputs, assert on the input count, not just the exit code. **[graduated → core/instructions/testing.md]** (4th instance below, kept for audit trail).
**5th instance (2026-08-09, PR #85 round 6):** `tests/test-vale-hooks-consumer.sh` asserted `grep -c "VagueWording" >= 2` across the *combined* output of both shipped Vale hooks, and the SKILL.md fixture alone raised two alerts — so one working hook satisfied the threshold and the agent hook could be disabled entirely (glob retargeted to match nothing) while the suite still reported `3 passed` under the message "both hooks flatten and flag". The `Skipped` guard did not catch it: the hook still *matched* the file, Vale simply linted nothing, reported `0 errors in 1 file`, and exited 0, which pre-commit renders as `Passed`. The general shape: **an assertion that aggregates over N subjects proves nothing about any individual subject** — a total is satisfiable by a proper subset. Fix: attribute each signal to its source before asserting (alerts are now filed by path, with a distinct trigger token per fixture so one hook's alert cannot be credited to another), and assert per subject. Corollary technique, now standing practice for any check whose failure mode is silence: run the mutation sweep in *reverse* as well — neuter each assertion in turn and confirm exactly one test case fails. Applied to `check-vale-style-sync.sh` it exposed two assertions bound to no failing case at all, one of them masked by a stronger check that ran first.
**4th instance (2026-08-09, ADR-0014):** splitting the single root `.vale.ini` into two skill-scoped copies (skill-audit: `SKILL.md` only; agent-audit: agent files only) meant a single retargeted pre-commit hook pointed at agent-audit's copy alone would have silently scanned 0 `SKILL.md` files and exited 0 — caught only because the full corpus was dry-run against both the old and new config and the outputs diffed before the old config was deleted, not because any test asserted on file counts. Standing practice going forward: when a Vale (or any linter) config that serves multiple file-glob scopes is split or moved, dry-run the full corpus through both the old and new config and diff the outputs before removing the superseded source — a hook silently scanning 0 files looks identical to a clean pass. **4th instance (2026-08-09, ADR-0014):** splitting the single root `.vale.ini` into two skill-scoped copies (skill-audit: `SKILL.md` only; agent-audit: agent files only) meant a single retargeted pre-commit hook pointed at agent-audit's copy alone would have silently scanned 0 `SKILL.md` files and exited 0 — caught only because the full corpus was dry-run against both the old and new config and the outputs diffed before the old config was deleted, not because any test asserted on file counts. Standing practice going forward: when a Vale (or any linter) config that serves multiple file-glob scopes is split or moved, dry-run the full corpus through both the old and new config and diff the outputs before removing the superseded source — a hook silently scanning 0 files looks identical to a clean pass.
## 2026-08-08 — One signal, two consumers, no named distinction ## 2026-08-08 — One signal, two consumers, no named distinction
@@ -159,3 +161,7 @@ The root `.pre-commit-hooks.yaml` shipped Vale hooks whose `entry:` carried a `-
## 2026-08-09 — Deleting a token from a shared artifact breaks whatever parses it, silently ## 2026-08-09 — Deleting a token from a shared artifact breaks whatever parses it, silently
Dropping the `--config` argument from `.pre-commit-hooks.yaml` was the right fix, but `scripts/check-release-needed.sh` derived its release-relevant path list by scanning those same `entry:` lines for `--config` and taking the target's `dirname` — that parse was the only thing giving the bundled `.vale.ini` and its sibling `styles/` tree release coverage. With the token gone the loop simply never fired: no error, no failing test, no warning, just a path list that shrank from six entries to four and lost both `assets/vale/` trees. Consequence: a change to a Vale *rule* could land on `main` without demanding a release tag, leaving external consumers pinned to an old `rev:` with stale rules — the exact drift the gate exists to prevent. It surfaced only because the agent making the change reported it as a suspected side effect of its own edit, and was confirmed by diffing the derived path list before and after. Fix: before removing a token from an artifact more than one script reads, grep for everything that *parses* the artifact, not just everything that consumes its documented purpose. The smell to watch for is a loop that builds a list, where an empty or short list is indistinguishable from a correct one — assert on the expected members, so a derivation whose input vanished fails loudly instead of quietly covering less. Dropping the `--config` argument from `.pre-commit-hooks.yaml` was the right fix, but `scripts/check-release-needed.sh` derived its release-relevant path list by scanning those same `entry:` lines for `--config` and taking the target's `dirname` — that parse was the only thing giving the bundled `.vale.ini` and its sibling `styles/` tree release coverage. With the token gone the loop simply never fired: no error, no failing test, no warning, just a path list that shrank from six entries to four and lost both `assets/vale/` trees. Consequence: a change to a Vale *rule* could land on `main` without demanding a release tag, leaving external consumers pinned to an old `rev:` with stale rules — the exact drift the gate exists to prevent. It surfaced only because the agent making the change reported it as a suspected side effect of its own edit, and was confirmed by diffing the derived path list before and after. Fix: before removing a token from an artifact more than one script reads, grep for everything that *parses* the artifact, not just everything that consumes its documented purpose. The smell to watch for is a loop that builds a list, where an empty or short list is indistinguishable from a correct one — assert on the expected members, so a derivation whose input vanished fails loudly instead of quietly covering less.
## 2026-08-09 — A documented impossibility is a claim, not a constraint
`vale-wrap.sh` flattens multi-line YAML `description:` scalars so Vale's `text.frontmatter.description` scope keeps matching. Its last-resort branch rewrote ASCII `'` to U+2019, justified at the emission site and in review as "the single combination no YAML scalar can carry verbatim" — an accepted-by-design residual, documented and test-covered, which is exactly why nobody retested it. The claim was false: a `|-` literal block with one indented content line carries `'`, `"`, `\` and `: ` verbatim, keeps the scope alive, and the wrapper's own header docstring already said literal blocks were unaffected. The cost of the unexamined claim was a silent underlint on 12 of 54 in-scope files — any rule whose token contained an apostrophe simply never fired, and the covering test (case 20) pinned only "the scope stays alive", so it passed either way. Fix: when a residual is accepted because something is "impossible", write down the specific claim in a falsifiable form and test *that*, not the workaround built on top of it. The tell here was that the residual and its justification were documented in the same breath by the same author — documentation records a belief, and a belief adjacent to a workaround is the one most worth attacking. Related: an assertion written to cover an accepted residual tends to assert the residual's *presence* rather than the behaviour it costs; case 20b asserted the scope survived flattening, never that a rule matching the rewritten characters still fired.

View File

@@ -96,10 +96,11 @@ every rule to `level: error` is what actually implements this decision.
"cherry-picked rules" is one rule, not two. "cherry-picked rules" is one rule, not two.
- A new pre-commit hook, `skill-size-check` (`scripts/skill-size-check.sh`), enforces the - A new pre-commit hook, `skill-size-check` (`scripts/skill-size-check.sh`), enforces the
500-line/5,000-token `SKILL.md` ceiling, sibling to `skill-frontmatter`. Both halves of that 500-line/5,000-token `SKILL.md` ceiling, sibling to `skill-frontmatter`. Both halves of that
ceiling are blocking gates, not just the line count: `MAX_LINES=500`, and `MAX_WORDS=2900` as a ceiling are blocking gates, not just the line count: `MAX_LINES=500`, and `MAX_WORDS=2770` as a
word-count proxy for the 5,000-token limit (calibrated from this repo's measured ~1.6-1.7 tokens word-count proxy for the 5,000-token limit (calibrated to the densest prose this repo measured,
per word — `wc -w` is not BPE tokenization). Either one exceeded fails the hook. Both are 1.81 tokens per word, so a worst-case `SKILL.md` at the ceiling still lands under 5,000 tokens —
inclusive: a file at exactly 500 lines or exactly 2,900 words passes, and only one past a ceiling `wc -w` is not BPE tokenization). Either one exceeded fails the hook. Both are
inclusive: a file at exactly 500 lines or exactly 2,770 words passes, and only one past a ceiling
fails. `skill-audit/scripts/validate.sh` enforces the same pair on the same inclusive terms, so fails. `skill-audit/scripts/validate.sh` enforces the same pair on the same inclusive terms, so
the audit and the commit hook cannot disagree about whether a given `SKILL.md` is over size. the audit and the commit hook cannot disagree about whether a given `SKILL.md` is over size.
- `styles/KyberforgeTrial/` and `.vale.trial.ini` were deliberately not created — noted here so a - `styles/KyberforgeTrial/` and `.vale.trial.ini` were deliberately not created — noted here so a

View File

@@ -168,3 +168,21 @@ that makes the two disagree already touches `$HOOKS_MANIFEST`, itself a release-
unreadable tagged tree (shallow clone, truncated fetch) fails closed rather than silently degrading unreadable tagged tree (shallow clone, truncated fetch) fails closed rather than silently degrading
to worktree-only derivation; a manifest simply absent at the tag — legitimate, it was added since — to worktree-only derivation; a manifest simply absent at the tag — legitimate, it was added since —
does not. does not.
**Update — the flattener rewrites no characters.** This ADR never recorded it as a decision, but
`vale-wrap.sh`'s flattener carried a lossy last-resort branch: when a description needed quoting
*and* held an ASCII apostrophe *and* held a double quote or backslash, it substituted U+2019 (`’`)
for every `'` before writing the scratch copy, on the stated rationale that no verbatim YAML scalar
could carry that combination. The rationale was wrong. A `|-` literal block with a single indented
content line carries `'`, `"`, `\` and `: ` byte for byte — a block scalar's body has no escape
syntax at all — and vale's `text.frontmatter.description` scope still matches and fires rules on it
(verified against vale 3.15.2; it is the same property that makes the `|` blocks in the wrapper's
header safe to leave unflattened). The branch fired on 12 of the 54 in-scope files in this repo,
silently disabling every rule whose token contains an apostrophe on each of them. The flattener now
emits that literal block instead, so its output is verbatim in all four forms and no Vale rule can
be silently disabled by the prefilter. The `|-` form is two physical lines where the three inline
forms are one, so the blank-line pad that preserves later line numbers drops by one — reachable
only when the original span is already two or more lines, so the pad count stays non-negative.
`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.

View File

@@ -8,5 +8,5 @@
"keywords": [], "keywords": [],
"license": "MIT", "license": "MIT",
"name": "kyberforge", "name": "kyberforge",
"version": "1.2.7" "version": "1.2.8"
} }

View File

@@ -13,5 +13,5 @@
"skills": [ "skills": [
"skills/" "skills/"
], ],
"version": "1.2.7" "version": "1.2.8"
} }

View File

@@ -9,8 +9,10 @@ set -euo pipefail
# the same way. A `|`/`|-`/`|+` literal block scalar is NOT affected: its parsed # the same way. A `|`/`|-`/`|+` literal block scalar is NOT affected: its parsed
# value keeps exactly the line breaks the source has, and vale matches it fine # value keeps exactly the line breaks the source has, and vale matches it fine
# (verified against vale 3.15.2), so literal blocks are deliberately left alone. # (verified against vale 3.15.2), so literal blocks are deliberately left alone.
# This script flattens an affected description to one physical line in a scratch # This script flattens an affected description to a one-line scalar in a scratch
# copy (padding with blank lines so every other line number is unchanged), then # copy — or, for the rare value no inline scalar can spell out verbatim, to a
# `|-` literal block with a single content line, which vale matches just as well
# (padding with blank lines so every other line number is unchanged), then
# runs the real `vale` binary against the copies. Drop-in replacement for calling # runs the real `vale` binary against the copies. Drop-in replacement for calling
# `vale` directly: same args, same exit code, bar the two documented divergences # `vale` directly: same args, same exit code, bar the two documented divergences
# below. # below.
@@ -61,6 +63,20 @@ vale_args=()
path_args=() path_args=()
pending_flag="" pending_flag=""
config_given=false config_given=false
# `--output` takes either one of vale's built-in style names or a template file
# path. Only the file form needs absolutizing, and the built-in names have to be
# excluded by name *before* the existence test below: a file or directory
# literally called `line` in the caller's cwd would otherwise rewrite the
# built-in into `$cwd/line`, flipping vale into template mode (`E100 [template]
# Runtime error`) where bare vale just uses the built-in. `--path` has no such
# names — it is always a path — so the check is keyed on the flag too.
is_builtin_output() {
case "$2" in
line|JSON|CLI) [[ "$1" == "--output" ]] ;;
*) false ;;
esac
}
for arg in "$@"; do for arg in "$@"; do
if [[ -n "$pending_flag" ]]; then if [[ -n "$pending_flag" ]]; then
# Value of a separated two-argv flag. It is never a lint target, however # Value of a separated two-argv flag. It is never a lint target, however
@@ -76,11 +92,12 @@ for arg in "$@"; do
fi fi
;; ;;
--output|--path) --output|--path)
# `--output` is either a built-in style name (`line`, `JSON`) or a # See `is_builtin_output` above for why the built-in `--output` names
# template file; only the file form needs resolving. `--path` is # are excluded first. Anything that names nothing is passed through and
# likewise a path. Anything that names nothing is passed through and
# left for vale to interpret. # left for vale to interpret.
if [[ "$arg" != /* && -e "$arg" ]]; then if is_builtin_output "$pending_flag" "$arg"; then
vale_args+=("$arg")
elif [[ "$arg" != /* && -e "$arg" ]]; then
vale_args+=("$cwd/$arg") vale_args+=("$cwd/$arg")
else else
vale_args+=("$arg") vale_args+=("$arg")
@@ -114,7 +131,9 @@ for arg in "$@"; do
# other path-valued flags. # other path-valued flags.
--output=*|--path=*) --output=*|--path=*)
flag_val="${arg#*=}" flag_val="${arg#*=}"
if [[ "$flag_val" != /* && -n "$flag_val" && -e "$flag_val" ]]; then if is_builtin_output "${arg%%=*}" "$flag_val"; then
vale_args+=("$arg")
elif [[ "$flag_val" != /* && -n "$flag_val" && -e "$flag_val" ]]; then
vale_args+=("${arg%%=*}=$cwd/$flag_val") vale_args+=("${arg%%=*}=$cwd/$flag_val")
else else
vale_args+=("$arg") vale_args+=("$arg")
@@ -286,13 +305,14 @@ def continuation_lines(rest):
def emit(value): def emit(value):
"""Render `value` as a one-line YAML scalar whose source text spells the """Render `value` as a YAML scalar whose source text spells the value out
value out verbatim. Vale locates the description by matching the parsed verbatim. Vale locates the description by matching the parsed value back
value back against the source, so a scalar carrying any escape — `''` in a against the source, so a scalar carrying any escape — `''` in a
single-quoted scalar, `\\"` or `\\\\` in a double-quoted one — makes the single-quoted scalar, `\\"` or `\\\\` in a double-quoted one — makes the
whole `text.frontmatter.description` scope vanish, the same failure this whole `text.frontmatter.description` scope vanish, the same failure this
script exists to work around. Verbatim forms only, therefore, tried in script exists to work around. Verbatim forms only, therefore, tried in
descending order of fidelity.""" descending order of fidelity. The first three occupy one physical line; the
`|-` fallback occupies two, which the caller accounts for when padding."""
if (value if (value
and value[0] not in PLAIN_UNSAFE_FIRST and value[0] not in PLAIN_UNSAFE_FIRST
and ': ' not in value and ': ' not in value
@@ -304,11 +324,14 @@ def emit(value):
if '"' not in value and '\\' not in value: if '"' not in value and '\\' not in value:
return '"' + value + '"' # double-quoted: only `"`/`\` would return '"' + value + '"' # double-quoted: only `"`/`\` would
# Last resort: the value needs quoting AND holds an apostrophe AND a double # Last resort: the value needs quoting AND holds an apostrophe AND a double
# quote or backslash, so no verbatim YAML scalar can carry it. Substituting # quote or backslash, so no *inline* scalar can carry it verbatim. A `|-`
# U+2019 for the apostrophe keeps the scope alive, at the cost of any style # literal block can — a block scalar's body has no escape syntax at all, so
# rule whose token contains a literal ASCII apostrophe. Scratch-copy only — # `'`, `"`, `\` and `: ` all survive byte for byte, and vale still matches
# never written back to the real file. # the description scope against it (the header above says the same of the
return "'" + value.replace("'", '’') + "'" # `|` blocks this script deliberately leaves alone; verified against vale
# 3.15.2). One content line, indented two spaces, `-`-chomped so the parsed
# value is exactly `value` with no trailing newline.
return '|-\n ' + value
fm_match = re.match(r'^(---\n)(.*?\n)(---\n)', content, re.DOTALL) fm_match = re.match(r'^(---\n)(.*?\n)(---\n)', content, re.DOTALL)
@@ -392,11 +415,21 @@ if header_m:
newline = fm.find('\n', value_end) newline = fm.find('\n', value_end)
span_end = len(fm) if newline == -1 else newline + 1 span_end = len(fm) if newline == -1 else newline + 1
trailer = fm[value_end:span_end].rstrip('\n') trailer = fm[value_end:span_end].rstrip('\n')
# One line replaces the span, so the blank-line pad is one short of the scalar = emit(flat)
# newline count it displaced — every later line number is unchanged. # A trailing comment carried across from the original line stays on the
pad = '\n' * (fm[head_start:span_end].count('\n') - 1) # `description:` line itself: after a block scalar's `|-` header it is
new_fm = (fm[:head_start] + 'description: ' + emit(flat) + trailer # still a comment, but inside the block body it would become part of the
+ '\n' + pad + fm[span_end:]) # value.
head, newline_sep, block_body = scalar.partition('\n')
# The replacement displaces the whole span, so the blank-line pad makes
# up the difference between the lines it displaced and the lines it
# occupies — every later line number is unchanged. That is one line for
# the three inline forms and two for the `|-` block; the span itself is
# at least two lines here (`value_lines >= 2` is a precondition), so the
# pad count never goes negative.
pad = '\n' * (fm[head_start:span_end].count('\n') - 1 - scalar.count('\n'))
new_fm = (fm[:head_start] + 'description: ' + head + trailer
+ newline_sep + block_body + '\n' + pad + fm[span_end:])
content = (fm_match.group(1) + new_fm + fm_match.group(3) content = (fm_match.group(1) + new_fm + fm_match.group(3)
+ content[fm_match.end():]) + content[fm_match.end():])

View File

@@ -37,7 +37,7 @@ bash scripts/validate-provenance.sh <skill-dir>
scripts/vale-wrap.sh <skill-dir>/SKILL.md scripts/vale-wrap.sh <skill-dir>/SKILL.md
``` ```
Note any structural FAILs — they will appear in the report as a `### Structure` dimension. If the script cannot execute (python3 unavailable, Bash denied, or permission error), perform structural checks manually: name format, name matches directory, description length ≤1024 chars, SKILL.md ≤500 lines, no unfilled `FILL IN:` placeholders, scripts executable and free of interactive prompts. Note any structural FAILs — they will appear in the report as a `### Structure` dimension. If the script cannot execute (python3 unavailable, Bash denied, or permission error), perform structural checks manually: name format, name matches directory, description length ≤1024 chars, SKILL.md ≤500 lines and ≤2770 words (the word count is a proxy for the ~5,000-token ceiling, and blocks a commit exactly like the line count does), no unfilled `FILL IN:` placeholders, scripts executable and free of interactive prompts.
Note any Provenance FAILs and INFO findings from `validate-provenance.sh` — they surface in the report as a `### Provenance` dimension (separate from `### Structure`). The script embeds full FAIL/INFO format with Why and Fix per finding; surface them verbatim. Note any Provenance FAILs and INFO findings from `validate-provenance.sh` — they surface in the report as a `### Provenance` dimension (separate from `### Structure`). The script embeds full FAIL/INFO format with Why and Fix per finding; surface them verbatim.

View File

@@ -9,8 +9,10 @@ set -euo pipefail
# the same way. A `|`/`|-`/`|+` literal block scalar is NOT affected: its parsed # the same way. A `|`/`|-`/`|+` literal block scalar is NOT affected: its parsed
# value keeps exactly the line breaks the source has, and vale matches it fine # value keeps exactly the line breaks the source has, and vale matches it fine
# (verified against vale 3.15.2), so literal blocks are deliberately left alone. # (verified against vale 3.15.2), so literal blocks are deliberately left alone.
# This script flattens an affected description to one physical line in a scratch # This script flattens an affected description to a one-line scalar in a scratch
# copy (padding with blank lines so every other line number is unchanged), then # copy — or, for the rare value no inline scalar can spell out verbatim, to a
# `|-` literal block with a single content line, which vale matches just as well
# (padding with blank lines so every other line number is unchanged), then
# runs the real `vale` binary against the copies. Drop-in replacement for calling # runs the real `vale` binary against the copies. Drop-in replacement for calling
# `vale` directly: same args, same exit code, bar the two documented divergences # `vale` directly: same args, same exit code, bar the two documented divergences
# below. # below.
@@ -61,6 +63,20 @@ vale_args=()
path_args=() path_args=()
pending_flag="" pending_flag=""
config_given=false config_given=false
# `--output` takes either one of vale's built-in style names or a template file
# path. Only the file form needs absolutizing, and the built-in names have to be
# excluded by name *before* the existence test below: a file or directory
# literally called `line` in the caller's cwd would otherwise rewrite the
# built-in into `$cwd/line`, flipping vale into template mode (`E100 [template]
# Runtime error`) where bare vale just uses the built-in. `--path` has no such
# names — it is always a path — so the check is keyed on the flag too.
is_builtin_output() {
case "$2" in
line|JSON|CLI) [[ "$1" == "--output" ]] ;;
*) false ;;
esac
}
for arg in "$@"; do for arg in "$@"; do
if [[ -n "$pending_flag" ]]; then if [[ -n "$pending_flag" ]]; then
# Value of a separated two-argv flag. It is never a lint target, however # Value of a separated two-argv flag. It is never a lint target, however
@@ -76,11 +92,12 @@ for arg in "$@"; do
fi fi
;; ;;
--output|--path) --output|--path)
# `--output` is either a built-in style name (`line`, `JSON`) or a # See `is_builtin_output` above for why the built-in `--output` names
# template file; only the file form needs resolving. `--path` is # are excluded first. Anything that names nothing is passed through and
# likewise a path. Anything that names nothing is passed through and
# left for vale to interpret. # left for vale to interpret.
if [[ "$arg" != /* && -e "$arg" ]]; then if is_builtin_output "$pending_flag" "$arg"; then
vale_args+=("$arg")
elif [[ "$arg" != /* && -e "$arg" ]]; then
vale_args+=("$cwd/$arg") vale_args+=("$cwd/$arg")
else else
vale_args+=("$arg") vale_args+=("$arg")
@@ -114,7 +131,9 @@ for arg in "$@"; do
# other path-valued flags. # other path-valued flags.
--output=*|--path=*) --output=*|--path=*)
flag_val="${arg#*=}" flag_val="${arg#*=}"
if [[ "$flag_val" != /* && -n "$flag_val" && -e "$flag_val" ]]; then if is_builtin_output "${arg%%=*}" "$flag_val"; then
vale_args+=("$arg")
elif [[ "$flag_val" != /* && -n "$flag_val" && -e "$flag_val" ]]; then
vale_args+=("${arg%%=*}=$cwd/$flag_val") vale_args+=("${arg%%=*}=$cwd/$flag_val")
else else
vale_args+=("$arg") vale_args+=("$arg")
@@ -286,13 +305,14 @@ def continuation_lines(rest):
def emit(value): def emit(value):
"""Render `value` as a one-line YAML scalar whose source text spells the """Render `value` as a YAML scalar whose source text spells the value out
value out verbatim. Vale locates the description by matching the parsed verbatim. Vale locates the description by matching the parsed value back
value back against the source, so a scalar carrying any escape — `''` in a against the source, so a scalar carrying any escape — `''` in a
single-quoted scalar, `\\"` or `\\\\` in a double-quoted one — makes the single-quoted scalar, `\\"` or `\\\\` in a double-quoted one — makes the
whole `text.frontmatter.description` scope vanish, the same failure this whole `text.frontmatter.description` scope vanish, the same failure this
script exists to work around. Verbatim forms only, therefore, tried in script exists to work around. Verbatim forms only, therefore, tried in
descending order of fidelity.""" descending order of fidelity. The first three occupy one physical line; the
`|-` fallback occupies two, which the caller accounts for when padding."""
if (value if (value
and value[0] not in PLAIN_UNSAFE_FIRST and value[0] not in PLAIN_UNSAFE_FIRST
and ': ' not in value and ': ' not in value
@@ -304,11 +324,14 @@ def emit(value):
if '"' not in value and '\\' not in value: if '"' not in value and '\\' not in value:
return '"' + value + '"' # double-quoted: only `"`/`\` would return '"' + value + '"' # double-quoted: only `"`/`\` would
# Last resort: the value needs quoting AND holds an apostrophe AND a double # Last resort: the value needs quoting AND holds an apostrophe AND a double
# quote or backslash, so no verbatim YAML scalar can carry it. Substituting # quote or backslash, so no *inline* scalar can carry it verbatim. A `|-`
# U+2019 for the apostrophe keeps the scope alive, at the cost of any style # literal block can — a block scalar's body has no escape syntax at all, so
# rule whose token contains a literal ASCII apostrophe. Scratch-copy only — # `'`, `"`, `\` and `: ` all survive byte for byte, and vale still matches
# never written back to the real file. # the description scope against it (the header above says the same of the
return "'" + value.replace("'", '’') + "'" # `|` blocks this script deliberately leaves alone; verified against vale
# 3.15.2). One content line, indented two spaces, `-`-chomped so the parsed
# value is exactly `value` with no trailing newline.
return '|-\n ' + value
fm_match = re.match(r'^(---\n)(.*?\n)(---\n)', content, re.DOTALL) fm_match = re.match(r'^(---\n)(.*?\n)(---\n)', content, re.DOTALL)
@@ -392,11 +415,21 @@ if header_m:
newline = fm.find('\n', value_end) newline = fm.find('\n', value_end)
span_end = len(fm) if newline == -1 else newline + 1 span_end = len(fm) if newline == -1 else newline + 1
trailer = fm[value_end:span_end].rstrip('\n') trailer = fm[value_end:span_end].rstrip('\n')
# One line replaces the span, so the blank-line pad is one short of the scalar = emit(flat)
# newline count it displaced — every later line number is unchanged. # A trailing comment carried across from the original line stays on the
pad = '\n' * (fm[head_start:span_end].count('\n') - 1) # `description:` line itself: after a block scalar's `|-` header it is
new_fm = (fm[:head_start] + 'description: ' + emit(flat) + trailer # still a comment, but inside the block body it would become part of the
+ '\n' + pad + fm[span_end:]) # value.
head, newline_sep, block_body = scalar.partition('\n')
# The replacement displaces the whole span, so the blank-line pad makes
# up the difference between the lines it displaced and the lines it
# occupies — every later line number is unchanged. That is one line for
# the three inline forms and two for the `|-` block; the span itself is
# at least two lines here (`value_lines >= 2` is a precondition), so the
# pad count never goes negative.
pad = '\n' * (fm[head_start:span_end].count('\n') - 1 - scalar.count('\n'))
new_fm = (fm[:head_start] + 'description: ' + head + trailer
+ newline_sep + block_body + '\n' + pad + fm[span_end:])
content = (fm_match.group(1) + new_fm + fm_match.group(3) content = (fm_match.group(1) + new_fm + fm_match.group(3)
+ content[fm_match.end():]) + content[fm_match.end():])

View File

@@ -140,7 +140,11 @@ else:
# no single source to share. Keep the two in sync by hand: if they drift, this # no single source to share. Keep the two in sync by hand: if they drift, this
# audit will report a skill ready to ship that the commit hook then rejects. # audit will report a skill ready to ship that the commit hook then rejects.
MAX_LINES = 500 MAX_LINES = 500
MAX_WORDS = 2900 # word-count proxy for the ~5,000-token ceiling # Word-count proxy for the ~5,000-token ceiling, calibrated to the densest
# prose in the corpus (7.22 chars/word): 2770 words is ~20,000 characters,
# ~5,000 tokens at 4 characters per token. See skill-size-check.sh's header
# for the full measurement.
MAX_WORDS = 2770
line_count = len(content.splitlines()) line_count = len(content.splitlines())
if line_count <= MAX_LINES: if line_count <= MAX_LINES:

View File

@@ -13,5 +13,5 @@
], ],
"license": "MIT", "license": "MIT",
"name": "lint", "name": "lint",
"version": "1.1.4" "version": "1.1.5"
} }

View File

@@ -8,7 +8,14 @@ source_keys:
Vale supports inline markup comments to disable checks for a section of content. Syntax varies by format: Vale supports inline markup comments to disable checks for a section of content. Syntax varies by format:
Markdown/MDX: Markdown — HTML comments; the MDX `{/* */}` form suppresses nothing in a plain `.md` file:
```markdown
<!-- vale off -->
This text will be ignored.
<!-- vale on -->
```
MDX:
```mdx ```mdx
{/* vale off */} {/* vale off */}
This text will be ignored. This text will be ignored.
@@ -24,7 +31,15 @@ This text will be ignored.
## Disabling a Specific Rule for Specific Matches ## Disabling a Specific Rule for Specific Matches
Rather than disabling all checks, target one rule and specific known-exception strings, then re-enable: Rather than disabling all checks, target one rule and specific known-exception strings, then re-enable. Same per-format comment syntax as above — Markdown:
```markdown
<!-- vale Style.Redundancy["ACT test","OTHER"] = NO -->
This is some text ACT test
<!-- vale Style.Redundancy["ACT test","OTHER"] = YES -->
```
MDX:
```mdx ```mdx
{/* vale Style.Redundancy["ACT test","OTHER"] = NO */} {/* vale Style.Redundancy["ACT test","OTHER"] = NO */}

View File

@@ -18,5 +18,5 @@
"skills": [ "skills": [
"skills/" "skills/"
], ],
"version": "1.1.4" "version": "1.1.5"
} }

View File

@@ -21,6 +21,27 @@ if [[ "${PRE_COMMIT_REMOTE_BRANCH:-}" != "$TARGET_BRANCH" ]]; then
exit 0 exit 0
fi 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)" REPO_ROOT="$(git rev-parse --show-toplevel)"
cd "$REPO_ROOT" cd "$REPO_ROOT"
@@ -31,8 +52,10 @@ if [[ ! -f "$HOOKS_MANIFEST" ]]; then
fi fi
# Only vX.Y.Z release tags count as a baseline — an incidental checkpoint or # Only vX.Y.Z release tags count as a baseline — an incidental checkpoint or
# experiment tag reachable from HEAD must not shift the diff baseline. # experiment tag reachable from the pushed ref must not shift the diff baseline.
LAST_TAG="$(git describe --tags --abbrev=0 --match 'v[0-9]*.[0-9]*.[0-9]*' 2>/dev/null || true)" # 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.
LAST_TAG="$(git describe --tags --abbrev=0 --match 'v[0-9]*.[0-9]*.[0-9]*' "$PUSHED_REF" 2>/dev/null || true)"
if [[ -z "$LAST_TAG" ]]; then 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 "FAIL: no release tag exists yet, but .pre-commit-hooks.yaml already exposes hooks to external consumers." >&2
@@ -68,16 +91,93 @@ add_release_path() {
RELEASE_PATHS+=("$candidate") 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: # $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 # "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 # with git plumbing. Reading entry lines from stdin keeps one derivation for
# both the tagged manifest and the current one. # both the tagged manifest and the current one.
collect_release_paths() { collect_release_paths() {
local scope="$1" entry bundle_root local scope="$1" line hook_id entry bundle_root where
local -a tokens local -a tokens
while IFS= read -r entry; do 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" read -ra tokens <<< "$entry"
[[ ${#tokens[@]} -eq 0 ]] && continue [[ ${#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]}" add_release_path "${tokens[0]}"
bundle_root="$(dirname "$(dirname "${tokens[0]}")")" bundle_root="$(dirname "$(dirname "${tokens[0]}")")"
[[ "$bundle_root" == "." ]] && continue [[ "$bundle_root" == "." ]] && continue
@@ -91,7 +191,7 @@ collect_release_paths() {
} }
# The worktree alone is not enough: a path is release-relevant if it was part of # 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 at HEAD, so both trees have to be # 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 # 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 # 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 — # altogether, and the deletion — which breaks every consumer at the next rev —
@@ -102,7 +202,7 @@ collect_release_paths() {
# surface they cannot reach without a new tag. The union never over-fires on its # 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 # own, either — any manifest edit that makes the two disagree already changes
# $HOOKS_MANIFEST, which is itself a release-relevant path. # $HOOKS_MANIFEST, which is itself a release-relevant path.
collect_release_paths worktree < <(sed -n 's/^[[:space:]]*entry:[[:space:]]*//p' "$HOOKS_MANIFEST") collect_release_paths worktree < <(manifest_entries < "$HOOKS_MANIFEST")
# A missing manifest at the tag is legitimate (the manifest was added since) but # 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, # is indistinguishable from an unreadable tagged tree by its exit status alone,
@@ -110,20 +210,21 @@ collect_release_paths worktree < <(sed -n 's/^[[:space:]]*entry:[[:space:]]*//p'
# shallow clone, a truncated fetch — fails closed exactly like a `git diff` # shallow clone, a truncated fetch — fails closed exactly like a `git diff`
# failure does, rather than silently degrading to worktree-only derivation. # 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 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" | sed -n 's/^[[:space:]]*entry:[[:space:]]*//p') 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 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 "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 echo " Fix: ensure full tag history is available (e.g. git fetch --unshallow) and retry." >&2
exit 1 exit 1
fi fi
# No -e/existence filtering: a path deleted since $LAST_TAG is exactly the case # No -e/existence filtering on the pathspec: a path deleted since $LAST_TAG is
# that must be caught (external consumers pinning the old tag would hit a # exactly the case that must be caught (external consumers pinning the old tag
# missing file), and `git diff` reports deletions fine without it existing at # would hit a missing file), and `git diff` reports deletions fine without it
# HEAD. A git failure (e.g. a shallow clone missing $LAST_TAG's history) must # existing at the pushed ref. A git failure (e.g. a shallow clone missing
# fail closed, not be swallowed into an empty, falsely-clean diff. # $LAST_TAG's history) must fail closed, not be swallowed into an empty,
if ! CHANGED="$(git diff --name-only "$LAST_TAG"..HEAD -- "${RELEASE_PATHS[@]}")"; then # falsely-clean diff.
echo "FAIL: could not diff $LAST_TAG..HEAD to check for release-relevant changes (see git error above)." >&2 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 echo " Fix: ensure full tag history is available (e.g. git fetch --unshallow) and retry." >&2
exit 1 exit 1
fi fi

View File

@@ -10,6 +10,11 @@ set -euo pipefail
# only one of the two. Run from repo root or pass REPO_ROOT as arg. # only one of the two. Run from repo root or pass REPO_ROOT as arg.
REPO_ROOT="${1:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" REPO_ROOT="${1:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
# Absolutized because the glob probe below `cd`s into a scratch tree, where a
# relative --config path would stop resolving.
if [[ -d "$REPO_ROOT" ]]; then
REPO_ROOT="$(cd "$REPO_ROOT" && pwd)"
fi
FAIL=0 FAIL=0
err() { echo " FAIL: $1" >&2; FAIL=$((FAIL + 1)); } err() { echo " FAIL: $1" >&2; FAIL=$((FAIL + 1)); }
@@ -41,7 +46,126 @@ if ! diff -rq "$SKILL_AUDIT/assets/vale/styles/Kyberforge" "$AGENT_AUDIT/assets/
err "assets/vale/styles/Kyberforge differs between skill-audit and agent-audit" err "assets/vale/styles/Kyberforge differs between skill-audit and agent-audit"
fi fi
# --- .vale.ini coverage ------------------------------------------------------
# The two .vale.ini files are deliberately NOT identical — agent-audit's carries
# an extra [**/*.agent.md] section and the KyberforgeCopilot style — so they
# cannot be diffed like the styles above. Nothing else in the repo read them at
# all, and that is what let a one-character glob typo silently disable the
# prefilter for a whole file type: the hook still MATCHES the file via its
# `files:` regex, so pre-commit reports neither `Skipped` nor an error; vale
# lints zero files, prints `0 errors ... in 1 file` and exits 0, and the hook
# shows `Passed`. So check the parts that must hold in both, not equality.
SKILL_INI="$SKILL_AUDIT/assets/vale/.vale.ini"
AGENT_INI="$AGENT_AUDIT/assets/vale/.vale.ini"
for ini in "$SKILL_INI" "$AGENT_INI"; do
rel_ini="${ini#"$REPO_ROOT"/}"
if [[ ! -f "$ini" ]]; then
err "$rel_ini is missing — without it vale falls back to an upward config search and lints with whatever it finds"
continue
fi
# StylesPath is resolved relative to the .vale.ini, which is the only reason
# the bundled styles are found from a consuming repo's clone prefix.
if ! grep -Eq '^[[:space:]]*StylesPath[[:space:]]*=[[:space:]]*styles[[:space:]]*$' "$ini"; then
err "$rel_ini has no 'StylesPath = styles' — the bundled styles/ directory would not be found"
fi
# Matches `Kyberforge` as a whole name, so `KyberforgeCopilot` alone does not
# satisfy it. Avoids \b, which is a GNU grep extension.
if ! grep -Eq '^[[:space:]]*BasedOnStyles[[:space:]]*=.*Kyberforge([[:space:],]|$)' "$ini"; then
err "$rel_ini has no section whose BasedOnStyles names Kyberforge — every rule the audit prefilters on lives in that style"
fi
done
# Prints the `files:` regex of every hook, in either manifest, whose entry is
# $1's vale-wrap.sh. Records are delimited by their `- id:` line, so the check
# does not depend on `entry:` preceding `files:` within a record.
hook_file_regexes() {
local skill="$1" manifest raw
for manifest in "$REPO_ROOT/.pre-commit-hooks.yaml" "$REPO_ROOT/.pre-commit-config.yaml"; do
[[ -f "$manifest" ]] || continue
awk -v skill="$skill" '
function flush() {
if (entry ~ skill "/scripts/vale-wrap.sh" && files != "") print files
entry = ""; files = ""
}
/^[ \t]*-[ \t]*id:/ { flush() }
/^[ \t]*entry:/ { entry = $0 }
/^[ \t]*files:/ { files = $0; sub(/^[ \t]*files:[ \t]*/, "", files) }
END { flush() }
' "$manifest"
done | while IFS= read -r raw; do
# Strip the surrounding YAML quotes; the regex itself never carries them.
raw="${raw%\'}"; raw="${raw#\'}"
raw="${raw%\"}"; raw="${raw#\"}"
printf '%s\n' "$raw"
done
}
# Asks vale — the thing that actually applies these globs — whether a config
# covers a path, rather than reimplementing doublestar matching. The probe file
# carries a description with a token Kyberforge.VagueWording flags, so a config
# whose glob matches but whose BasedOnStyles lost Kyberforge fails too: it would
# lint the file and report nothing.
vale_flags_path() {
local cfg="$1" rel="$2" tmp out
tmp="$(mktemp -d)"
mkdir -p "$tmp/$(dirname "$rel")"
{
echo "---"
echo "name: probe"
echo "description: Use when the caller wants a probe that helps with things."
echo "---"
echo ""
echo "Body."
} > "$tmp/$rel"
out="$(cd "$tmp" && vale --config "$cfg" "$rel" 2>&1)" || true
rm -rf "$tmp"
printf '%s\n' "$out" | grep -qF "Kyberforge.VagueWording"
}
VALE_AVAILABLE=true
if ! command -v vale >/dev/null 2>&1; then
VALE_AVAILABLE=false
echo " WARNING: vale is not installed — .vale.ini glob coverage was NOT verified. Install it (https://vale.sh/docs/vale-cli/installation/) before trusting a clean run." >&2
fi
# One representative path per file shape the prefilter is supposed to cover. Each
# is cross-checked against the shipped hooks' `files:` regexes first, so a path
# that goes stale because a hook was rescoped fails loudly here instead of
# quietly probing a shape nothing lints any more.
while IFS='|' read -r skill rel; do
[[ -n "$skill" ]] || continue
dir="$REPO_ROOT/plugins/kyberforge/skills/$skill"
ini="$dir/assets/vale/.vale.ini"
[[ -f "$ini" ]] || continue
regexes="$(hook_file_regexes "$skill")"
if [[ -n "$regexes" ]]; then
in_scope=false
while IFS= read -r re; do
[[ -n "$re" ]] || continue
if printf '%s\n' "$rel" | grep -Eq "$re"; then
in_scope=true
fi
done <<EOF_RE
$regexes
EOF_RE
if [[ "$in_scope" == false ]]; then
err "$rel matches no 'files:' regex of any $skill hook — the probe path is stale, or the hook was rescoped away from a shape it still needs to lint"
fi
fi
if [[ "$VALE_AVAILABLE" == true ]] && ! vale_flags_path "$ini" "$rel"; then
err "$skill/assets/vale/.vale.ini raises no Kyberforge alert on $rel — its glob sections do not cover a path its own pre-commit hook is scoped to, so the hook passes that shape without linting it"
fi
done <<'EOF_PROBE'
skill-audit|plugins/demo/skills/demo/SKILL.md
agent-audit|plugins/demo/agents/demo.md
agent-audit|copilot/demo.agent.md
EOF_PROBE
if [[ $FAIL -gt 0 ]]; then if [[ $FAIL -gt 0 ]]; then
echo "Vale style sync check failed: $FAIL error(s). agent-audit's copy is canonical — run scripts/sync-vale-styles.sh to regenerate skill-audit's copy, then commit both." >&2 echo "Vale style sync check failed: $FAIL error(s). For a drifted wrapper or style, agent-audit's copy is canonical — run scripts/sync-vale-styles.sh to regenerate skill-audit's copy, then commit both. A .vale.ini finding is not drift and sync-vale-styles.sh will not fix it: edit that file's own StylesPath, BasedOnStyles or glob sections." >&2
exit 1 exit 1
fi fi

View File

@@ -20,13 +20,15 @@ set -euo pipefail
# ~4-characters-per-token English approximation that is 1.49 / 1.70 / 1.69 / # ~4-characters-per-token English approximation that is 1.49 / 1.70 / 1.69 /
# 1.81 tokens per word. # 1.81 tokens per word.
# #
# MAX_WORDS=2900 is therefore calibrated to the corpus MEDIAN, not to its worst # MAX_WORDS=2770 is therefore calibrated to the corpus WORST case rather than
# case, and carries no margin: 2900 x 1.70 = ~4,900 tokens for a median-density # its median: 2770 words at the densest observed 7.22 chars/word is ~20,000
# file, but 2900 x 1.81 = ~5,240 tokens for the densest file in the corpus. So # characters, or ~5,000 tokens at the 4-characters-per-token approximation. So
# what this gate actually guarantees is "under 5,000 tokens for a file of # what this gate guarantees is "under 5,000 tokens even for the densest prose
# typical prose density"; a prose-dense SKILL.md can sit at exactly MAX_WORDS # the corpus has produced" — the earlier median-calibrated MAX_WORDS=2900 let
# and still be a few hundred tokens over the agentskills.io ceiling. Holding # such a file sit at exactly the ceiling and still spend ~5,240 tokens. A
# the worst observed ratio under 5,000 tokens would need MAX_WORDS ~2770. # median-density file at 2770 words spends ~4,700 tokens, so typical prose
# gives up ~130 words of headroom to close that gap. The largest SKILL.md in
# the repo is 2,489 words, so no current file is affected.
# #
# It is a one-sided proxy in the useful direction — nothing under the word # It is a one-sided proxy in the useful direction — nothing under the word
# ceiling is wildly over the token ceiling — but it is not exact BPE # ceiling is wildly over the token ceiling — but it is not exact BPE
@@ -34,7 +36,7 @@ set -euo pipefail
# any of these numbers as still current. # any of these numbers as still current.
MAX_LINES=500 MAX_LINES=500
MAX_WORDS=2900 MAX_WORDS=2770
FAIL=0 FAIL=0
for f in "$@"; do for f in "$@"; do

View File

@@ -35,14 +35,24 @@ fi
run_bats run_bats
mapfile -t SCRIPTS < <( # Collected with a `while read` loop rather than `mapfile` — macOS ships
# /bin/bash 3.2, which has no `mapfile`. Process substitution (not a pipe)
# keeps the loop in this shell so the appends survive. `sort` is still fed
# newline-delimited output, exactly as before.
SCRIPTS=()
while IFS= read -r script; do
SCRIPTS+=("$script")
done < <(
find "$SEARCH_ROOT" -name "test-*.sh" \ find "$SEARCH_ROOT" -name "test-*.sh" \
-not -path "*/.git/*" \ -not -path "*/.git/*" \
-not -path "*/.claude/worktrees/*" \ -not -path "*/.claude/worktrees/*" \
| sort | sort
) )
for script in "${SCRIPTS[@]}"; do # bash before 4.4 treats "${arr[@]}" on an empty array as unbound under
# `set -u`, so every array expansion here uses the ${arr[@]+"${arr[@]}"} guard,
# including the SKIPPED/FAILED loops already fenced by a count check.
for script in ${SCRIPTS[@]+"${SCRIPTS[@]}"}; do
rel="${script#"$SEARCH_ROOT/"}" rel="${script#"$SEARCH_ROOT/"}"
echo "=== $rel ===" echo "=== $rel ==="
rc=0 rc=0
@@ -60,13 +70,13 @@ done
echo "=== Summary: $PASSED passed, ${#SKIPPED[@]} skipped, ${#FAILED[@]} failed ===" echo "=== Summary: $PASSED passed, ${#SKIPPED[@]} skipped, ${#FAILED[@]} failed ==="
if [[ ${#SKIPPED[@]} -gt 0 ]]; then if [[ ${#SKIPPED[@]} -gt 0 ]]; then
echo "Skipped scripts:" echo "Skipped scripts:"
for s in "${SKIPPED[@]}"; do for s in ${SKIPPED[@]+"${SKIPPED[@]}"}; do
echo " $s" echo " $s"
done done
fi fi
if [[ ${#FAILED[@]} -gt 0 ]]; then if [[ ${#FAILED[@]} -gt 0 ]]; then
echo "Failed scripts:" echo "Failed scripts:"
for s in "${FAILED[@]}"; do for s in ${FAILED[@]+"${FAILED[@]}"}; do
echo " $s" echo " $s"
done done
exit 1 exit 1

View File

@@ -52,9 +52,46 @@ make_tagged_fixture() {
echo "$dir" 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() { run_check() {
local dir="$1" branch="$2" local dir="$1" branch="$2"
(cd "$dir" && PRE_COMMIT_REMOTE_BRANCH="$branch" bash "$SCRIPT" 2>&1) 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=() CLEANUP_DIRS=()
@@ -268,6 +305,120 @@ else
fail "reported only the manifest change and hid which shipped paths the retirement removed" fail "reported only the manifest change and hid which shipped paths the retirement removed"
fi 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 echo "$OUT15" | grep -q "fake-size-check" \
&& echo "$OUT15" | grep -q "bash scripts/skill-size-check.sh" \
&& echo "$OUT15" | grep -q "ADR-0014"; 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 echo "$OUT16" | grep -q "fake-size-check" && echo "$OUT16" | grep -q "ADR-0014"; 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 echo "$OUT17" | grep -q "skill-size-check.sh"; 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
echo "" echo ""
echo "Results: $PASS passed, $FAIL failed" echo "Results: $PASS passed, $FAIL failed"
[[ $FAIL -eq 0 ]] [[ $FAIL -eq 0 ]]

View File

@@ -9,41 +9,72 @@ FAIL=0
pass() { echo " PASS: $1"; PASS=$((PASS + 1)); } pass() { echo " PASS: $1"; PASS=$((PASS + 1)); }
fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); } fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); }
# One trap over a registry, rather than rebuilding the trap line per fixture:
# the guard is there because bash 3.2 treats "${arr[@]}" on an empty array as
# unbound under `set -u`.
FIXTURES=()
cleanup() { [[ ${#FIXTURES[@]} -eq 0 ]] || rm -rf "${FIXTURES[@]}"; }
trap cleanup EXIT
# Helper: make a fixture repo with skill-audit/agent-audit's Vale copies, in sync by default. # Helper: make a fixture repo with skill-audit/agent-audit's Vale copies, in sync by default.
# The wrapper is a stub — the script only diffs it — but the Vale assets and both
# pre-commit manifests are the repo's real ones, because the .vale.ini checks ask
# vale to apply those globs for real and cross-check them against the shipped
# hooks' `files:` regexes. A synthetic style or manifest would prove nothing, and
# copying the real ones keeps agent-audit's intentional KyberforgeCopilot
# divergence in the fixture instead of a sanitized stand-in for it.
make_fixture() { make_fixture() {
local dir local dir
dir="$(mktemp -d)" dir="$(mktemp -d)"
local skill_audit="$dir/plugins/kyberforge/skills/skill-audit" local skill_audit="$dir/plugins/kyberforge/skills/skill-audit"
local agent_audit="$dir/plugins/kyberforge/skills/agent-audit" local agent_audit="$dir/plugins/kyberforge/skills/agent-audit"
mkdir -p "$skill_audit/scripts" "$skill_audit/assets/vale/styles/Kyberforge" mkdir -p "$skill_audit/scripts" "$agent_audit/scripts"
mkdir -p "$agent_audit/scripts" "$agent_audit/assets/vale/styles/Kyberforge"
echo '#!/usr/bin/env bash' > "$skill_audit/scripts/vale-wrap.sh" echo '#!/usr/bin/env bash' > "$skill_audit/scripts/vale-wrap.sh"
echo 'echo wrap' >> "$skill_audit/scripts/vale-wrap.sh" echo 'echo wrap' >> "$skill_audit/scripts/vale-wrap.sh"
cp "$skill_audit/scripts/vale-wrap.sh" "$agent_audit/scripts/vale-wrap.sh" cp "$skill_audit/scripts/vale-wrap.sh" "$agent_audit/scripts/vale-wrap.sh"
echo 'extends: existence' > "$skill_audit/assets/vale/styles/Kyberforge/Rule.yml" cp -R "$REPO_ROOT/plugins/kyberforge/skills/skill-audit/assets" "$skill_audit/"
cp "$skill_audit/assets/vale/styles/Kyberforge/Rule.yml" "$agent_audit/assets/vale/styles/Kyberforge/Rule.yml" cp -R "$REPO_ROOT/plugins/kyberforge/skills/agent-audit/assets" "$agent_audit/"
cp "$REPO_ROOT/.pre-commit-hooks.yaml" "$REPO_ROOT/.pre-commit-config.yaml" "$dir/"
echo "$dir" echo "$dir"
} }
# Helper: rewrite a glob section header in one copy's .vale.ini, leaving every
# other line — StylesPath, BasedOnStyles — intact. This is the shape of the
# typo the check exists to catch: the hook still matches the file via its
# `files:` regex, vale lints nothing, and pre-commit reports `Passed`.
break_glob() {
local ini="$1" old="$2" new="$3"
python3 - "$ini" "$old" "$new" <<'PYTHON'
import sys
path, old, new = sys.argv[1], sys.argv[2], sys.argv[3]
with open(path, encoding='utf-8') as fh:
content = fh.read()
assert old in content, f"{old} not found in {path}"
with open(path, 'w', encoding='utf-8') as fh:
fh.write(content.replace(old, new))
PYTHON
}
# --- 1. Exits 0 when the two copies are in sync --- # --- 1. Exits 0 when the two copies are in sync ---
echo "" echo ""
echo "--- exits 0 when skill-audit and agent-audit copies are in sync ---" echo "--- exits 0 when skill-audit and agent-audit copies are in sync ---"
FIXTURE="$(make_fixture)" FIXTURE="$(make_fixture)"
trap 'rm -rf "$FIXTURE"' EXIT FIXTURES+=("$FIXTURE")
if bash "$SCRIPT" "$FIXTURE" > /dev/null 2>&1; then if bash "$SCRIPT" "$FIXTURE" > /dev/null 2>&1; then
pass "exits 0 when copies are in sync" pass "exits 0 when copies are in sync"
else else
fail "exited non-zero against in-sync copies" fail "exited non-zero against in-sync copies"
bash "$SCRIPT" "$FIXTURE" 2>&1 | sed 's/^/ /' || true
fi fi
# --- 2. Exits 1 when vale-wrap.sh differs between the two copies --- # --- 2. Exits 1 when vale-wrap.sh differs between the two copies ---
echo "" echo ""
echo "--- exits 1 when vale-wrap.sh differs ---" echo "--- exits 1 when vale-wrap.sh differs ---"
FIXTURE2="$(make_fixture)" FIXTURE2="$(make_fixture)"
trap 'rm -rf "$FIXTURE" "$FIXTURE2"' EXIT FIXTURES+=("$FIXTURE2")
echo 'echo different' >> "$FIXTURE2/plugins/kyberforge/skills/skill-audit/scripts/vale-wrap.sh" echo 'echo different' >> "$FIXTURE2/plugins/kyberforge/skills/skill-audit/scripts/vale-wrap.sh"
if bash "$SCRIPT" "$FIXTURE2" > /dev/null 2>&1; then if bash "$SCRIPT" "$FIXTURE2" > /dev/null 2>&1; then
fail "exited 0 when vale-wrap.sh copies differ — expected exit 1" fail "exited 0 when vale-wrap.sh copies differ — expected exit 1"
@@ -55,8 +86,8 @@ fi
echo "" echo ""
echo "--- exits 1 when a Kyberforge style rule differs ---" echo "--- exits 1 when a Kyberforge style rule differs ---"
FIXTURE3="$(make_fixture)" FIXTURE3="$(make_fixture)"
trap 'rm -rf "$FIXTURE" "$FIXTURE2" "$FIXTURE3"' EXIT FIXTURES+=("$FIXTURE3")
echo 'level: error' >> "$FIXTURE3/plugins/kyberforge/skills/agent-audit/assets/vale/styles/Kyberforge/Rule.yml" echo ' - divergent token' >> "$FIXTURE3/plugins/kyberforge/skills/agent-audit/assets/vale/styles/Kyberforge/VagueWording.yml"
if bash "$SCRIPT" "$FIXTURE3" > /dev/null 2>&1; then if bash "$SCRIPT" "$FIXTURE3" > /dev/null 2>&1; then
fail "exited 0 when a style rule differs — expected exit 1" fail "exited 0 when a style rule differs — expected exit 1"
else else
@@ -67,8 +98,14 @@ fi
echo "" echo ""
echo "--- exits 1 when a rule file is missing from one copy ---" echo "--- exits 1 when a rule file is missing from one copy ---"
FIXTURE4="$(make_fixture)" FIXTURE4="$(make_fixture)"
trap 'rm -rf "$FIXTURE" "$FIXTURE2" "$FIXTURE3" "$FIXTURE4"' EXIT FIXTURES+=("$FIXTURE4")
echo 'extends: existence' > "$FIXTURE4/plugins/kyberforge/skills/agent-audit/assets/vale/styles/Kyberforge/Extra.yml" cat > "$FIXTURE4/plugins/kyberforge/skills/agent-audit/assets/vale/styles/Kyberforge/Extra.yml" <<'EOF'
extends: existence
message: "Extra: '%s'"
level: error
tokens:
- divergent token
EOF
if bash "$SCRIPT" "$FIXTURE4" > /dev/null 2>&1; then if bash "$SCRIPT" "$FIXTURE4" > /dev/null 2>&1; then
fail "exited 0 when a rule file exists in only one copy — expected exit 1" fail "exited 0 when a rule file exists in only one copy — expected exit 1"
else else
@@ -79,7 +116,7 @@ fi
echo "" echo ""
echo "--- exits 0 when kyberforge skills are absent (no-op) ---" echo "--- exits 0 when kyberforge skills are absent (no-op) ---"
FIXTURE5="$(mktemp -d)" FIXTURE5="$(mktemp -d)"
trap 'rm -rf "$FIXTURE" "$FIXTURE2" "$FIXTURE3" "$FIXTURE4" "$FIXTURE5"' EXIT FIXTURES+=("$FIXTURE5")
if bash "$SCRIPT" "$FIXTURE5" > /dev/null 2>&1; then if bash "$SCRIPT" "$FIXTURE5" > /dev/null 2>&1; then
pass "exits 0 as a no-op when skill-audit/agent-audit don't exist" pass "exits 0 as a no-op when skill-audit/agent-audit don't exist"
else else
@@ -93,7 +130,7 @@ echo ""
echo "--- exits 1 when only one of the two copies is present ---" echo "--- exits 1 when only one of the two copies is present ---"
FIXTURE6="$(make_fixture)" FIXTURE6="$(make_fixture)"
FIXTURE7="$(make_fixture)" FIXTURE7="$(make_fixture)"
trap 'rm -rf "$FIXTURE" "$FIXTURE2" "$FIXTURE3" "$FIXTURE4" "$FIXTURE5" "$FIXTURE6" "$FIXTURE7"' EXIT FIXTURES+=("$FIXTURE6" "$FIXTURE7")
rm -rf "$FIXTURE6/plugins/kyberforge/skills/skill-audit" rm -rf "$FIXTURE6/plugins/kyberforge/skills/skill-audit"
rm -rf "$FIXTURE7/plugins/kyberforge/skills/agent-audit" rm -rf "$FIXTURE7/plugins/kyberforge/skills/agent-audit"
if bash "$SCRIPT" "$FIXTURE6" > /dev/null 2>&1; then if bash "$SCRIPT" "$FIXTURE6" > /dev/null 2>&1; then
@@ -107,6 +144,172 @@ else
pass "exits non-zero when agent-audit's canonical copy is missing but skill-audit's is present" pass "exits non-zero when agent-audit's canonical copy is missing but skill-audit's is present"
fi fi
# --- 7. Exits 1 when a .vale.ini is missing entirely ---
# Without it vale falls back to an upward config search and lints the file with
# whatever config it happens to find, which is not a failure anyone sees.
echo ""
echo "--- exits 1 when a .vale.ini is missing ---"
FIXTURE8="$(make_fixture)"
FIXTURES+=("$FIXTURE8")
rm -f "$FIXTURE8/plugins/kyberforge/skills/skill-audit/assets/vale/.vale.ini"
if bash "$SCRIPT" "$FIXTURE8" > /dev/null 2>&1; then
fail "exited 0 when skill-audit's .vale.ini is missing — expected exit 1"
else
pass "exits non-zero when a .vale.ini is missing"
fi
# --- 8. Exits 1 when the shared StylesPath line is dropped from either copy ---
# StylesPath resolves relative to the .vale.ini, which is the only reason the
# bundled styles are found from a consuming repo's clone prefix.
echo ""
echo "--- exits 1 when StylesPath is missing from either .vale.ini ---"
FIXTURE9="$(make_fixture)"
FIXTURE10="$(make_fixture)"
FIXTURES+=("$FIXTURE9" "$FIXTURE10")
break_glob "$FIXTURE9/plugins/kyberforge/skills/skill-audit/assets/vale/.vale.ini" \
'StylesPath = styles' 'StylesPath = elsewhere'
break_glob "$FIXTURE10/plugins/kyberforge/skills/agent-audit/assets/vale/.vale.ini" \
'StylesPath = styles' 'StylesPath = elsewhere'
if bash "$SCRIPT" "$FIXTURE9" > /dev/null 2>&1; then
fail "exited 0 when skill-audit's .vale.ini lost StylesPath — expected exit 1"
else
pass "exits non-zero when skill-audit's .vale.ini lost StylesPath"
fi
if bash "$SCRIPT" "$FIXTURE10" > /dev/null 2>&1; then
fail "exited 0 when agent-audit's .vale.ini lost StylesPath — expected exit 1"
else
pass "exits non-zero when agent-audit's .vale.ini lost StylesPath"
fi
# --- 9. Exits 1 when no section's BasedOnStyles names Kyberforge ---
# Every rule the prefilter gates on lives in that style, so a section that keeps
# its glob but loses the style lints the file and reports nothing.
echo ""
echo "--- exits 1 when BasedOnStyles no longer names Kyberforge ---"
FIXTURE11="$(make_fixture)"
FIXTURES+=("$FIXTURE11")
break_glob "$FIXTURE11/plugins/kyberforge/skills/agent-audit/assets/vale/.vale.ini" \
'BasedOnStyles = Kyberforge' 'BasedOnStyles = KyberforgeCopilot'
if bash "$SCRIPT" "$FIXTURE11" > /dev/null 2>&1; then
fail "exited 0 when agent-audit's .vale.ini stopped naming Kyberforge — expected exit 1"
else
pass "exits non-zero when a .vale.ini no longer names the Kyberforge style"
fi
# --- 10. Exits 1 when a glob section stops matching the shape its hook lints ---
# One case per glob section, because each covers a file shape the others don't:
# agent-audit's [**/*.agent.md] is the only section covering a Copilot agent file
# outside an agents/ directory, so breaking it alone is invisible to the others.
echo ""
echo "--- exits 1 when a .vale.ini glob no longer matches its hook's file shape ---"
FIXTURE12="$(make_fixture)"
FIXTURE13="$(make_fixture)"
FIXTURE14="$(make_fixture)"
FIXTURES+=("$FIXTURE12" "$FIXTURE13" "$FIXTURE14")
break_glob "$FIXTURE12/plugins/kyberforge/skills/skill-audit/assets/vale/.vale.ini" \
'[**/SKILL.md]' '[**/NOMATCH.md]'
break_glob "$FIXTURE13/plugins/kyberforge/skills/agent-audit/assets/vale/.vale.ini" \
'[**/agents/*.md]' '[**/NOMATCH-agents/*.md]'
break_glob "$FIXTURE14/plugins/kyberforge/skills/agent-audit/assets/vale/.vale.ini" \
'[**/*.agent.md]' '[**/*.NOMATCH.md]'
if bash "$SCRIPT" "$FIXTURE12" > /dev/null 2>&1; then
fail "exited 0 when skill-audit's SKILL.md glob matched nothing — expected exit 1"
else
pass "exits non-zero when skill-audit's SKILL.md glob matches nothing"
fi
if bash "$SCRIPT" "$FIXTURE13" > /dev/null 2>&1; then
fail "exited 0 when agent-audit's agents/*.md glob matched nothing — expected exit 1"
else
pass "exits non-zero when agent-audit's agents/*.md glob matches nothing"
fi
if bash "$SCRIPT" "$FIXTURE14" > /dev/null 2>&1; then
fail "exited 0 when agent-audit's *.agent.md glob matched nothing — expected exit 1"
else
pass "exits non-zero when agent-audit's *.agent.md glob matches nothing"
fi
# --- 11. Exits 1 when a probe path falls out of every hook's `files:` regex ---
# The probe paths are hardcoded, so they can silently stop representing anything
# the hooks lint. Rescoping the shipped agent hook away from the `.agent.md`
# shape has to fail here rather than leave a probe testing a shape no hook
# matches any more.
echo ""
echo "--- exits 1 when a probe path matches no hook's files: regex ---"
FIXTURE16="$(make_fixture)"
FIXTURES+=("$FIXTURE16")
break_glob "$FIXTURE16/.pre-commit-hooks.yaml" \
"files: '(^|/)agents/[^/]+\\.md\$|\\.agent\\.md\$'" "files: '(^|/)agents/[^/]+\\.md\$'"
if bash "$SCRIPT" "$FIXTURE16" > /dev/null 2>&1; then
fail "exited 0 when the agent hook was rescoped away from .agent.md — expected exit 1"
else
pass "exits non-zero when a probe path is in no hook's scope any more"
fi
# --- 12. The text-level assertions hold on a machine without vale ---
# They are the fallback when the glob probe cannot run. With vale on PATH the
# probe fails on these same mutations, so it would mask them: only masking vale
# proves a clean run here means the text assertions themselves ran.
echo ""
echo "--- the StylesPath / BasedOnStyles assertions still gate with vale masked off PATH ---"
VALE_DIR="$(dirname "$(command -v vale 2>/dev/null || echo /nonexistent/vale)")"
PATH_NO_VALE="$(printf '%s' "$PATH" | tr ':' '\n' | grep -vxF "$VALE_DIR" | paste -sd: -)"
if (PATH="$PATH_NO_VALE"; command -v vale >/dev/null 2>&1); then
fail "could not mask vale off PATH — the vale-absent fallback was not exercised"
else
FIXTURE17="$(make_fixture)"
FIXTURE18="$(make_fixture)"
FIXTURE19="$(make_fixture)"
FIXTURES+=("$FIXTURE17" "$FIXTURE18" "$FIXTURE19")
break_glob "$FIXTURE18/plugins/kyberforge/skills/skill-audit/assets/vale/.vale.ini" \
'StylesPath = styles' 'StylesPath = elsewhere'
break_glob "$FIXTURE19/plugins/kyberforge/skills/agent-audit/assets/vale/.vale.ini" \
'BasedOnStyles = Kyberforge' 'BasedOnStyles = KyberforgeCopilot'
if PATH="$PATH_NO_VALE" bash "$SCRIPT" "$FIXTURE17" > /dev/null 2>&1; then
pass "exits 0 on in-sync copies with vale unavailable"
else
fail "exited non-zero on in-sync copies with vale unavailable — the missing binary must warn, not fail"
fi
if PATH="$PATH_NO_VALE" bash "$SCRIPT" "$FIXTURE18" > /dev/null 2>&1; then
fail "exited 0 on a dropped StylesPath with vale unavailable — expected exit 1"
else
pass "exits non-zero on a dropped StylesPath with vale unavailable"
fi
if PATH="$PATH_NO_VALE" bash "$SCRIPT" "$FIXTURE19" > /dev/null 2>&1; then
fail "exited 0 on a BasedOnStyles that dropped Kyberforge with vale unavailable — expected exit 1"
else
pass "exits non-zero on a BasedOnStyles that dropped Kyberforge with vale unavailable"
fi
# A clean run without vale must say so — silence would read as verified.
if PATH="$PATH_NO_VALE" bash "$SCRIPT" "$FIXTURE17" 2>&1 | grep -q "vale is not installed"; then
pass "warns that glob coverage was not verified when vale is unavailable"
else
fail "exited clean without vale and said nothing — an unverified run looks identical to a verified one"
fi
fi
# --- 13. The intentional agent-audit-only divergence is NOT flagged ---
# The two .vale.ini files are deliberately different: agent-audit ships an extra
# [**/*.agent.md] section and the KyberforgeCopilot style. A check that diffed
# them would fail the repo as it stands, so assert the divergence is really in
# the fixture before asserting the check tolerates it — otherwise this case would
# still pass if the fixture had quietly stopped carrying it.
echo ""
echo "--- exits 0 despite agent-audit's KyberforgeCopilot divergence ---"
FIXTURE15="$(make_fixture)"
FIXTURES+=("$FIXTURE15")
AGENT_INI15="$FIXTURE15/plugins/kyberforge/skills/agent-audit/assets/vale/.vale.ini"
SKILL_INI15="$FIXTURE15/plugins/kyberforge/skills/skill-audit/assets/vale/.vale.ini"
if ! grep -q "KyberforgeCopilot" "$AGENT_INI15" \
|| grep -q "KyberforgeCopilot" "$SKILL_INI15" \
|| [[ ! -d "$FIXTURE15/plugins/kyberforge/skills/agent-audit/assets/vale/styles/KyberforgeCopilot" ]]; then
fail "the fixture no longer carries the agent-audit-only KyberforgeCopilot divergence, so tolerating it proves nothing"
elif bash "$SCRIPT" "$FIXTURE15" > /dev/null 2>&1; then
pass "exits 0 with agent-audit's extra KyberforgeCopilot section and style present"
else
fail "flagged the intentional agent-audit-only KyberforgeCopilot divergence — expected exit 0"
bash "$SCRIPT" "$FIXTURE15" 2>&1 | sed 's/^/ /' || true
fi
echo "" echo ""
echo "Results: $PASS passed, $FAIL failed" echo "Results: $PASS passed, $FAIL failed"
[[ $FAIL -eq 0 ]] [[ $FAIL -eq 0 ]]

View File

@@ -1,12 +1,14 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Regression test for scripts/skill-size-check.sh: enforces agentskills.io's # Regression test for scripts/skill-size-check.sh: enforces agentskills.io's
# 500-line/5,000-token SKILL.md size ceiling. The token half is enforced via a # 500-line/5,000-token SKILL.md size ceiling. The token half is enforced via a
# word-count proxy (MAX_WORDS, currently 2900) — 5,000 is the token ceiling, # word-count proxy (MAX_WORDS, currently 2770) — 5,000 is the token ceiling,
# 2,900 is the word budget the script derives from it. # 2,770 is the word budget the script derives from it at the corpus's densest
# measured prose.
set -euo pipefail set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SCRIPT="$REPO_ROOT/scripts/skill-size-check.sh" SCRIPT="$REPO_ROOT/scripts/skill-size-check.sh"
VALIDATE="$REPO_ROOT/plugins/kyberforge/skills/skill-audit/scripts/validate.sh"
PASS=0 PASS=0
FAIL=0 FAIL=0
@@ -67,6 +69,29 @@ fi
MAX_WORDS="$(grep -oE '^MAX_WORDS=[0-9]+' "$SCRIPT" | cut -d= -f2)" MAX_WORDS="$(grep -oE '^MAX_WORDS=[0-9]+' "$SCRIPT" | cut -d= -f2)"
MAX_LINES="$(grep -oE '^MAX_LINES=[0-9]+' "$SCRIPT" | cut -d= -f2)" MAX_LINES="$(grep -oE '^MAX_LINES=[0-9]+' "$SCRIPT" | cut -d= -f2)"
# The audit (skill-audit/scripts/validate.sh) duplicates both ceilings, because
# a cache-installed plugin's scripts cannot read files outside the plugin
# directory. Nothing but this assertion stops the copies drifting, and drift
# means a SKILL.md passes its own audit and is then rejected by the commit hook.
echo ""
echo "--- the hook and skill-audit's validate.sh agree on both ceilings ---"
if [[ ! -f "$VALIDATE" ]]; then
fail "skill-audit validate.sh not found at $VALIDATE"
else
V_MAX_WORDS="$(grep -oE '^MAX_WORDS = [0-9]+' "$VALIDATE" | grep -oE '[0-9]+')"
V_MAX_LINES="$(grep -oE '^MAX_LINES = [0-9]+' "$VALIDATE" | grep -oE '[0-9]+')"
if [[ "$V_MAX_WORDS" == "$MAX_WORDS" ]]; then
pass "both enforce MAX_WORDS=$MAX_WORDS"
else
fail "MAX_WORDS drift: hook says $MAX_WORDS, validate.sh says ${V_MAX_WORDS:-<unset>}"
fi
if [[ "$V_MAX_LINES" == "$MAX_LINES" ]]; then
pass "both enforce MAX_LINES=$MAX_LINES"
else
fail "MAX_LINES drift: hook says $MAX_LINES, validate.sh says ${V_MAX_LINES:-<unset>}"
fi
fi
# make_line_fixture builds a file with an exact total line count (frontmatter # make_line_fixture builds a file with an exact total line count (frontmatter
# included), independent of word count, for the line-boundary tests. # included), independent of word count, for the line-boundary tests.
make_line_fixture() { make_line_fixture() {

View File

@@ -64,13 +64,18 @@ repos:
- id: kyberforge-skill-size-check - id: kyberforge-skill-size-check
EOF 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() { write_fixtures() {
local body="$1" local skill_body="$1"
local agent_body="${2:-$1}"
cat > "$CONSUMER/skills/demo/SKILL.md" <<EOF cat > "$CONSUMER/skills/demo/SKILL.md" <<EOF
--- ---
name: demo name: demo
description: > description: >
Use when the caller wants a demonstration skill $body across two Use when the caller wants a demonstration skill $skill_body across two
physical lines of one folded block scalar. physical lines of one folded block scalar.
--- ---
@@ -80,7 +85,7 @@ EOF
--- ---
name: demo name: demo
description: > description: >
Use when the caller wants a demonstration agent $body across two Use when the caller wants a demonstration agent $agent_body across two
physical lines of one folded block scalar. physical lines of one folded block scalar.
--- ---
@@ -89,27 +94,56 @@ EOF
git -C "$CONSUMER" add -A git -C "$CONSUMER" add -A
} }
run_hooks() { # Vale prints each linted path as its own header line with that file's alerts
(cd "$CONSUMER" && pre-commit run --all-files 2>&1) || true # 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. Both hooks resolve their config and actually gate on a bad file --- # --- 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 ""
echo "--- both Vale hooks run and fail a bad file in an external consumer repo ---" echo "--- each Vale hook flags its own fixture in an external consumer repo ---"
write_fixtures "that helps with and utilize things" write_fixtures "that helps with things" "that will utilize things"
OUT_BAD="$(run_hooks)" while IFS='|' read -r HOOK_ID FIXTURE TOKEN; do
if echo "$OUT_BAD" | grep -q "does not exist"; then [[ -n "$HOOK_ID" ]] || continue
fail "hooks hard-errored on a path resolved against the consumer repo (E100) — the bug this test guards against" LOG="$WORK/$HOOK_ID.log"
echo "$OUT_BAD" | sed 's/^/ /' set +e
elif echo "$OUT_BAD" | grep -q "Skipped"; then (cd "$CONSUMER" && pre-commit run "$HOOK_ID" --all-files > "$LOG" 2>&1)
fail "a hook matched no files, so it proved nothing" RC_HOOK=$?
echo "$OUT_BAD" | sed 's/^/ /' set -e
elif [[ "$(echo "$OUT_BAD" | grep -c "VagueWording")" -ge 2 ]]; then if grep -q "does not exist" "$LOG"; then
pass "both hooks flatten and flag the folded description in a consumer repo" 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 else
fail "hooks did not flag both fixtures" fail "$HOOK_ID failed, but no alert quoting '$TOKEN' was filed under $FIXTURE"
echo "$OUT_BAD" | sed 's/^/ /' sed 's/^/ /' "$LOG"
fi fi
done <<'EOF'
kyberforge-vale-audit-skill|skills/demo/SKILL.md|helps with
kyberforge-vale-audit-agent|agents/demo.md|utilize
EOF
# --- 2. Clean files pass — the hooks gate, they don't just always fail --- # --- 2. Clean files pass — the hooks gate, they don't just always fail ---
echo "" echo ""

View File

@@ -443,8 +443,11 @@ fi
# is declared and never reset to empty: it cannot be empty at any expansion # is declared and never reset to empty: it cannot be empty at any expansion
# site, so the construct is not a hazard there and demanding the guarded form # site, so the construct is not a hazard there and demanding the guarded form
# would be a wrong test. The file list covers every script this repo ships or # would be a wrong test. The file list covers every script this repo ships or
# runs that a macOS user reaches: the wrapper itself plus the two pre-commit # runs that a macOS user reaches: the wrapper itself, the two pre-commit hook
# hook scripts. # scripts, and the test runner AGENTS.md tells contributors to run by hand.
# `mapfile` is checked alongside, because it is bash 4.0+ and the expansion scan
# cannot see it — run-tests.sh carried one until it was replaced with a
# `while read` loop, and nothing would have caught its return.
echo "" echo ""
echo "--- no unguarded array expansion remains in the macOS-facing scripts ---" echo "--- no unguarded array expansion remains in the macOS-facing scripts ---"
unguarded_expansions() { unguarded_expansions() {
@@ -470,11 +473,19 @@ HAZARDS16=""
for BASH32_SCRIPT in \ for BASH32_SCRIPT in \
"$SCRIPT" \ "$SCRIPT" \
"$REPO_ROOT/scripts/skill-size-check.sh" \ "$REPO_ROOT/scripts/skill-size-check.sh" \
"$REPO_ROOT/scripts/check-release-needed.sh"; do "$REPO_ROOT/scripts/check-release-needed.sh" \
"$REPO_ROOT/tests/run-tests.sh"; do
FOUND16="$(unguarded_expansions "$BASH32_SCRIPT")" FOUND16="$(unguarded_expansions "$BASH32_SCRIPT")"
if [[ -n "$FOUND16" ]]; then if [[ -n "$FOUND16" ]]; then
HAZARDS16+="$FOUND16 " HAZARDS16+="$FOUND16 "
fi fi
# `mapfile`/`readarray` are bash 4.0+ builtins with no 3.2 fallback. Whole-line
# comments are blanked first so prose naming the builtin is not a hit.
FOUND16B="$(awk '{ if ($0 ~ /^[[:space:]]*#/) print ""; else print }' "$BASH32_SCRIPT" \
| grep -nE '(^|[^[:alnum:]_])(mapfile|readarray)[[:space:]]' || true)"
if [[ -n "$FOUND16B" ]]; then
HAZARDS16+="${BASH32_SCRIPT##*/}:$FOUND16B "
fi
done done
if [[ -n "$HAZARDS16" ]]; then if [[ -n "$HAZARDS16" ]]; then
fail "unguarded array expansion(s) abort on bash < 4.4 under set -u: $(echo "$HAZARDS16" | tr '\n' ' ')" fail "unguarded array expansion(s) abort on bash < 4.4 under set -u: $(echo "$HAZARDS16" | tr '\n' ' ')"
@@ -588,10 +599,15 @@ make_form_fixture() {
} }
# Alert text with the `line:col` prefix and ANSI colouring stripped, sorted. # Alert text with the `line:col` prefix and ANSI colouring stripped, sorted.
# The `|| true` matters under this file's `set -o pipefail`: a report with no
# alerts at all makes grep exit 1, which would abort the whole run inside the
# command substitutions below — silently, before the empty-baseline guard could
# print anything. Returning empty output instead is what makes that guard
# reachable.
alert_text() { alert_text() {
echo "$1" \ echo "$1" \
| sed -E 's/\x1b\[[0-9;]*m//g' \ | sed -E 's/\x1b\[[0-9;]*m//g' \
| grep -oE '(error|warning|suggestion)[[:space:]]+.*' \ | { grep -oE '(error|warning|suggestion)[[:space:]]+.*' || true; } \
| sed -E 's/[[:space:]]+/ /g' \ | sed -E 's/[[:space:]]+/ /g' \
| sort | sort
} }
@@ -601,8 +617,11 @@ echo "--- every multi-line description form reports what its single-line form re
FIXTURE19_SINGLE="$(make_form_fixture single)" FIXTURE19_SINGLE="$(make_form_fixture single)"
BASELINE19="$(alert_text "$(run_wrap "$FIXTURE19_SINGLE" --config "$VALE_CONFIG" "$REL_SKILL19")")" BASELINE19="$(alert_text "$(run_wrap "$FIXTURE19_SINGLE" --config "$VALE_CONFIG" "$REL_SKILL19")")"
if [[ -z "$BASELINE19" ]]; then if [[ -z "$BASELINE19" ]]; then
fail "the single-line baseline reported nothing — the comparison below would be vacuous" # The loop below has to be skipped, not merely reported on: an empty baseline
fi # compares equal to five empty results, so it would print five vacuous PASSes
# alongside this one FAIL. The FAIL alone still fails the run at the end.
fail "the single-line baseline reported nothing — the comparisons below would be vacuous, so they are skipped"
else
for FORM19 in folded plain dquote squote keyonly; do for FORM19 in folded plain dquote squote keyonly; do
DIR19="$(make_form_fixture "$FORM19")" DIR19="$(make_form_fixture "$FORM19")"
BARE19="$(cd "$DIR19" && vale --config "$VALE_CONFIG" "$REL_SKILL19" 2>&1 || true)" BARE19="$(cd "$DIR19" && vale --config "$VALE_CONFIG" "$REL_SKILL19" 2>&1 || true)"
@@ -615,15 +634,18 @@ for FORM19 in folded plain dquote squote keyonly; do
fail "a $FORM19 multi-line description diverged from its single-line form: got [$GOT19]" fail "a $FORM19 multi-line description diverged from its single-line form: got [$GOT19]"
fi fi
done done
fi
# --- 20. A style token containing an ASCII apostrophe matches inside a # --- 20. A style token containing an ASCII apostrophe matches inside a
# flattened description. The flattener used to substitute U+2019 for every `'` # flattened description. The flattener used to substitute U+2019 for every `'`
# before writing the scratch copy, so no rule whose token carried an apostrophe # before writing the scratch copy, so no rule whose token carried an apostrophe
# could ever fire on a flattened description — a silent, rule-shaped blind spot. # could ever fire on a flattened description — a silent, rule-shaped blind spot.
# Both branches that can hold an apostrophe verbatim are exercised: a value that # All three branches that can hold an apostrophe are exercised: a value that is
# is safe unquoted, and one that must be quoted (it contains `: `) and so has to # safe unquoted; one that must be quoted (it contains `: `) and so lands in a
# land in a double-quoted scalar, since a single-quoted one would need the `''` # double-quoted scalar, since a single-quoted one would need the `''` escape
# escape that kills the scope outright. # that kills the scope outright; and one that also holds a double quote, which
# no inline scalar can spell verbatim and which therefore lands in a `|-`
# literal block.
echo "" echo ""
echo "--- a style token containing an apostrophe matches in a flattened description ---" echo "--- a style token containing an apostrophe matches in a flattened description ---"
APOS_STYLE="$(mktemp -d)" APOS_STYLE="$(mktemp -d)"
@@ -638,6 +660,17 @@ ignorecase: true
tokens: tokens:
- "user's task" - "user's task"
EOF EOF
# A body-scoped companion rule, used by case 20b to read back the line number of
# a line *after* the frontmatter — the only way to catch the blank-line pad
# being off in either direction.
cat > "$APOS_STYLE/styles/Apostrophe/Body.yml" <<'EOF'
extends: existence
message: "body token: '%s'"
level: error
scope: text
tokens:
- flattening marker phrase
EOF
cat > "$APOS_STYLE/.vale.ini" <<'EOF' cat > "$APOS_STYLE/.vale.ini" <<'EOF'
StylesPath = styles StylesPath = styles
@@ -668,7 +701,23 @@ Body.
EOF EOF
)" )"
new_fixture "$FIXTURE20_QUOTED" new_fixture "$FIXTURE20_QUOTED"
for CASE20 in "unquoted:$FIXTURE20_PLAIN" "double-quoted:$FIXTURE20_QUOTED"; do # Needs quoting (`: `), holds an apostrophe AND a double quote — the one
# combination no inline scalar can carry, so this is the `|-` literal-block
# branch. The VagueWording tokens are there for case 20b, which reuses it.
FIXTURE20_BLOCK="$(make_raw_fixture <<'EOF'
---
name: zzzskill
description: >
Triggers on: the user's task and "audit this" phrasing, which helps
with and utilize things across a second physical line.
---
Body carrying a flattening marker phrase for the line-number check.
EOF
)"
new_fixture "$FIXTURE20_BLOCK"
for CASE20 in "unquoted:$FIXTURE20_PLAIN" "double-quoted:$FIXTURE20_QUOTED" \
"literal-block:$FIXTURE20_BLOCK"; do
if run_wrap "${CASE20#*:}" --config "$APOS_STYLE/.vale.ini" "$REL_SKILL19" \ if run_wrap "${CASE20#*:}" --config "$APOS_STYLE/.vale.ini" "$REL_SKILL19" \
| grep -q "Apostrophe.Token"; then | grep -q "Apostrophe.Token"; then
pass "an apostrophe-bearing token matches in a flattened ${CASE20%%:*} description" pass "an apostrophe-bearing token matches in a flattened ${CASE20%%:*} description"
@@ -677,29 +726,31 @@ for CASE20 in "unquoted:$FIXTURE20_PLAIN" "double-quoted:$FIXTURE20_QUOTED"; do
fi fi
done done
# --- 20b. The one combination no verbatim YAML scalar can carry — needs # --- 20b. The `|-` literal-block branch that case 20 just proved lossless must
# quoting, holds an apostrophe, and holds a double quote — falls back to the # also keep the rest of the scope working and keep the line accounting right.
# lossy U+2019 substitution. Apostrophe-bearing tokens are lost there by # The block is 2 physical lines where every inline form is 1, so the blank-line
# design, but the scope must stay alive so every other rule still fires. # pad that preserves later line numbers has to drop by one. The second
# assertion pins that arithmetic against the body line's true number: case 3's
# `<= original line count` bound would not, since a pad that is one line short
# shifts every later line *up*, staying inside the bound while still lying.
echo "" echo ""
echo "--- the unrepresentable combination keeps the description scope alive ---" echo "--- the |- literal-block fallback lints normally and preserves line numbers ---"
FIXTURE20C="$(make_raw_fixture <<'EOF' OUT20B=$(run_wrap "$FIXTURE20_BLOCK" --config "$VALE_CONFIG" "$REL_SKILL19")
--- if echo "$OUT20B" | grep -q "VagueWording"; then
name: zzzskill
description: >
Triggers on: the user's "audit this" phrasing, which helps with
and utilize things across a second physical line.
---
Body.
EOF
)"
new_fixture "$FIXTURE20C"
if run_wrap "$FIXTURE20C" --config "$VALE_CONFIG" "$REL_SKILL19" | grep -q "VagueWording"; then
pass "a description needing quotes with both an apostrophe and a double quote is still linted" pass "a description needing quotes with both an apostrophe and a double quote is still linted"
else else
fail "a description needing quotes with both an apostrophe and a double quote produced no alerts" fail "a description needing quotes with both an apostrophe and a double quote produced no alerts"
fi fi
WANT20B_LINE="$(grep -n 'flattening marker phrase' "$FIXTURE20_BLOCK/$REL_SKILL19" | cut -d: -f1)"
# `--output line` prints `file:line:col:Rule:message`, so the line number reads
# back without any wrapping or colour to strip.
GOT20B_LINE="$(run_wrap "$FIXTURE20_BLOCK" --config "$APOS_STYLE/.vale.ini" --output line "$REL_SKILL19" \
| grep 'Apostrophe.Body' | head -1 | cut -d: -f2)"
if [[ "$GOT20B_LINE" == "$WANT20B_LINE" ]]; then
pass "a body line after a |- flattened description keeps its original line number ($WANT20B_LINE)"
else
fail "the |- block's blank-line pad shifted the body: vale reported line $GOT20B_LINE, the file has it at $WANT20B_LINE"
fi
# --- 21. A symlinked file inside a directory argument is mirrored and linted. # --- 21. A symlinked file inside a directory argument is mirrored and linted.
# Vale follows symlinks (both a symlinked file and a file under a symlinked # Vale follows symlinks (both a symlinked file and a file under a symlinked
@@ -766,6 +817,31 @@ else
fail "a typo'd path exited $RC23 but the message does not name it: $OUT23" fail "a typo'd path exited $RC23 but the message does not name it: $OUT23"
fi fi
# --- 24. `--output`'s built-in style names must not be path-absolutized. The
# wrapper rewrites path-valued flag values to absolute form so they still
# resolve after the `cd` into the scratch mirror, deciding with an `-e`
# existence test — but `line`, `JSON` and `CLI` are style names, not paths. With
# a file or directory of that name sitting in the caller's cwd the test hit, the
# built-in became `$cwd/line`, and vale flipped into template mode and died with
# `E100 [template] Runtime error` where bare vale prints a normal report.
echo ""
echo "--- a built-in --output style name survives a same-named entry in the cwd ---"
FIXTURE24="$(make_fixture 2)"
new_fixture "$FIXTURE24"
mkdir -p "$FIXTURE24/line"
: > "$FIXTURE24/JSON"
for FORM24 in "--output line" "--output=line" "--output JSON" "--output=JSON"; do
# shellcheck disable=SC2086 # deliberate word splitting of the argv fixture
OUT24="$(run_wrap "$FIXTURE24" --config "$VALE_CONFIG" $FORM24 "$REL_SKILL19")"
if echo "$OUT24" | grep -q "E100"; then
fail "'$FORM24' was rewritten to a cwd path and vale flipped into template mode — the bug this test guards against"
elif echo "$OUT24" | grep -q "VagueWording"; then
pass "'$FORM24' is passed through as a built-in style name"
else
fail "'$FORM24' produced no alert: $OUT24"
fi
done
echo "" echo ""
echo "Results: $PASS passed, $FAIL failed" echo "Results: $PASS passed, $FAIL failed"
[[ $FAIL -eq 0 ]] [[ $FAIL -eq 0 ]]