5 Commits

Author SHA1 Message Date
cc5f366450 chore(plugins): patch-bump kyberforge and lint for shipped changes
kyberforge 1.2.5 -> 1.2.6 for the self-locating vale-wrap.sh.
lint 1.1.3 -> 1.1.4 for the corrected Vale exit-code semantics: a
consumer cached at 1.1.3 holds docs that lead to building a gate which
passes everything.

Both provider manifests bumped in parity per ADR-0006. Marketplace
entries carry no per-plugin version, so neither file changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MCQ648fLSFXPHGZdQ8gn58
2026-08-09 13:06:53 +00:00
e9234f6d8a docs(lint): correct the Vale exit-code and glob-scoping claims
cli-reference.md said vale exits non-zero for any alert at or above
MinAlertLevel. The exit code keys on error-level alerts alone;
MinAlertLevel filters display only. LESSONS.md records this exact
misconception as costing two review rounds, and this research doc is
the cited provenance source for the skills that state it correctly.

CONTEXT.md claimed a SKILL.md outside plugins/ matches no glob section.
[**/SKILL.md] matches any path ending in SKILL.md — the sentence is a
stale leftover from the path-scoped globs at cbc33d9, and contradicted
its own paragraph two sentences earlier. The NOT-RUN 0-files guard it
justifies is correct and is unchanged; only the rationale was wrong.
CONTEXT.md also cited the local files: regex as the scoping mechanism,
where the shipped manifest deliberately stays layout-agnostic.

ADR-0014 records the entry[0]-only prefixing constraint as the reason
the self-locating design is required, and that no entry may grow a
repo-internal path argument.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MCQ648fLSFXPHGZdQ8gn58
2026-08-09 13:06:52 +00:00
348dd9f665 fix(lint): restore release-gate coverage of bundled Vale assets
The gate derived release-relevant paths from the dirname of each
entry's --config target. Dropping --config from .pre-commit-hooks.yaml
left that loop dead, silently removing both assets/vale/ trees from
coverage — a Vale rule change could land on main without demanding a
release tag, leaving consumers pinned to an old rev: with stale rules.

Coverage now derives from tokens[0] instead: double-dirname for the ..
normalization, guarded on the tree existing and on the bundle root not
resolving to "." so skill-size-check.sh cannot invent a bogus path.

The --config branch is removed rather than kept as dead code. Since
pre-commit rewrites only entry[0], no argument in any entry can ever
name a file this repo ships, so that shape is broken by design.

Known gap: deleting a hook's entire assets/ tree is not flagged, as the
candidate path stops existing. Deletions within a surviving tree are.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MCQ648fLSFXPHGZdQ8gn58
2026-08-09 13:06:37 +00:00
714e8a0c78 fix(lint): fail the style-sync check when one copy is missing
The guard used `||`, so exactly one of the two audit skill directories
missing also exited 0, where the intended silent no-op is both absent.
A renamed skill-audit reported green instead of flagging that a
canonical style copy had lost its counterpart.

One-present now exits 1 naming the missing side and the remedy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MCQ648fLSFXPHGZdQ8gn58
2026-08-09 13:06:36 +00:00
8c570e9659 fix(lint): make the Vale prefilter work for external consumers
pre-commit prefixes only entry[0] with the hook-repo clone path
(cmd = (prefix.path(cmd[0]), *cmd[1:])), so the --config argument in
.pre-commit-hooks.yaml resolved against the *consuming* repo's root
and hard-failed every external run with E100. Two of the three hooks
ADR-0014 promises were unusable.

vale-wrap.sh now self-locates its config from BASH_SOURCE when no
--config is supplied; an explicit --config still wins in all three
argv forms and stays cwd-relative. Both manifests drop the argument
and are kept byte-identical: the local repo: local config resolved
--config correctly only because the consuming repo *was* this repo,
and that divergence is why three review rounds missed the defect.

Also in the wrapper:
- replace GNU-only `realpath -m` with a portable abspath helper; -m is
  load-bearing (dest does not exist yet), so BSD realpath aborted the
  script under set -e on macOS
- walk directory arguments instead of passing them through unflattened,
  which reported a clean 0-error run for files that fail when named
  explicitly
- read/write with errors='surrogateescape' so one non-UTF-8 .md under a
  directory argument cannot abort the hook

New test-vale-hooks-consumer.sh builds the hook repo from the working
tree and points a file:// consumer at it, covering the manifest as a
hook repo for the first time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MCQ648fLSFXPHGZdQ8gn58
2026-08-09 13:06:23 +00:00
19 changed files with 580 additions and 139 deletions

View File

@@ -129,7 +129,7 @@ repos:
stages: ['pre-commit'] stages: ['pre-commit']
name: Vale audit prefilter (SKILL.md) name: Vale audit prefilter (SKILL.md)
description: Run Vale against SKILL.md files as a deterministic prefilter for skill-audit, via skill-audit's own bundled copy description: Run Vale against SKILL.md files as a deterministic prefilter for skill-audit, via skill-audit's own bundled copy
entry: plugins/kyberforge/skills/skill-audit/scripts/vale-wrap.sh --config plugins/kyberforge/skills/skill-audit/assets/vale/.vale.ini entry: plugins/kyberforge/skills/skill-audit/scripts/vale-wrap.sh
language: script language: script
files: '^plugins/[^/]+/skills/[^/]+/SKILL\.md$' files: '^plugins/[^/]+/skills/[^/]+/SKILL\.md$'
pass_filenames: true pass_filenames: true
@@ -138,7 +138,7 @@ repos:
stages: ['pre-commit'] stages: ['pre-commit']
name: Vale audit prefilter (agent files) name: Vale audit prefilter (agent files)
description: Run Vale against agent markdown files as a deterministic prefilter for agent-audit, via agent-audit's own bundled copy description: Run Vale against agent markdown files as a deterministic prefilter for agent-audit, via agent-audit's own bundled copy
entry: plugins/kyberforge/skills/agent-audit/scripts/vale-wrap.sh --config plugins/kyberforge/skills/agent-audit/assets/vale/.vale.ini entry: plugins/kyberforge/skills/agent-audit/scripts/vale-wrap.sh
language: script language: script
files: '^plugins/[^/]+/agents/[^/]+\.md$' files: '^plugins/[^/]+/agents/[^/]+\.md$'
pass_filenames: true pass_filenames: true

View File

@@ -1,14 +1,14 @@
- id: kyberforge-vale-audit-skill - id: kyberforge-vale-audit-skill
name: Kyberforge Vale prose audit (SKILL.md) name: Kyberforge Vale prose audit (SKILL.md)
description: Deterministic prose-pattern prefilter for kyberforge's skill-audit, via its own bundled Vale config/styles description: Deterministic prose-pattern prefilter for kyberforge's skill-audit, via its own bundled Vale config/styles
entry: plugins/kyberforge/skills/skill-audit/scripts/vale-wrap.sh --config plugins/kyberforge/skills/skill-audit/assets/vale/.vale.ini entry: plugins/kyberforge/skills/skill-audit/scripts/vale-wrap.sh
language: script language: script
files: '(^|/)SKILL\.md$' files: '(^|/)SKILL\.md$'
- id: kyberforge-vale-audit-agent - id: kyberforge-vale-audit-agent
name: Kyberforge Vale prose audit (agent files) name: Kyberforge Vale prose audit (agent files)
description: Deterministic prose-pattern prefilter for kyberforge's agent-audit, via its own bundled Vale config/styles description: Deterministic prose-pattern prefilter for kyberforge's agent-audit, via its own bundled Vale config/styles
entry: plugins/kyberforge/skills/agent-audit/scripts/vale-wrap.sh --config plugins/kyberforge/skills/agent-audit/assets/vale/.vale.ini entry: plugins/kyberforge/skills/agent-audit/scripts/vale-wrap.sh
language: script language: script
files: '(^|/)agents/[^/]+\.md$|\.agent\.md$' files: '(^|/)agents/[^/]+\.md$|\.agent\.md$'

View File

@@ -72,7 +72,7 @@ A standalone, repo-agnostic plugin (`plugins/lint/`) for configuring and running
### Vale audit prefilter (skill-audit / agent-audit) ### Vale audit prefilter (skill-audit / agent-audit)
Wiring Vale as a deterministic prefilter for `skill-audit`/`agent-audit`'s Description dimension (ADR motivation: issue #84) is repo-specific, not part of the generic `lint` plugin, so it doesn't live in `plugins/lint/` — but per ADR-0014 it also doesn't live at the repo root anymore. Two copies live inside `plugins/kyberforge/`, one per skill, since a plugin's cache-install only copies each skill's own files (no cross-skill sharing): `plugins/kyberforge/skills/agent-audit/assets/vale/` is canonical (`.vale.ini` plus a custom `Kyberforge` style covering description-opener banning ("This skill/agent..."), vague-capability wording ("helps with", "utilize", ...), and generic "see references/ for details" padding — and a `KyberforgeCopilot` style scoped only to `.agent.md` files for the Copilot-only "Use proactively has no effect" check), and `plugins/kyberforge/skills/skill-audit/assets/vale/` is a smaller duplicate (`Kyberforge` only, scoped to `SKILL.md`) kept in sync by `scripts/check-vale-style-sync.sh` (pre-push). A root-level `.pre-commit-hooks.yaml` exposes both copies (plus `skill-size-check`) so any external repo can enforce the same rules via `repo: <this-repo-url>, rev: <tag>` in its own `.pre-commit-config.yaml` — pre-commit clones the pinned rev into its own cache, independent of whether Claude Code or the `kyberforge` plugin is installed at all, and the same mechanism covers CI (`pre-commit run --all-files`). This repo's own `vale-audit-prefilter-skill`/`-agent` pre-commit hooks consume the identical plugin-bundled copies via `repo: local` (not a third root copy, and not a pinned self-reference — a pinned self-reference would lint working-tree edits against the last tagged release rather than the change being made). Every rule is `level: error` and every alert is a FAIL — no ignorable tier, same as shellcheck, the test suite, and conventional-pre-commit. Graded severities do not work here: Vale's exit code keys on `error` alerts alone, so `warning`/`suggestion` rules exit 0 and pre-commit swallows the output of a passing hook, leaving them invisible and blocking nothing. `MinAlertLevel` and `--minAlertLevel` are correspondingly absent from `.vale.ini` and the hook, being no-ops under this model. Vale covers the pattern-matchable sub-checks named in issue #84 (imperative opener, vague filler, `Use proactively`, generic reference-pointer padding) plus, per ADR-0013, one body-wide prose-pattern check ("There is/are" sentence openers) — everything else about body discipline (defaults-vs-menus, why-rationale, non-pattern-matchable judgment calls), near-miss exclusion strength, and control calibration stays LLM judgment. Wiring Vale as a deterministic prefilter for `skill-audit`/`agent-audit`'s Description dimension (ADR motivation: issue #84) is repo-specific, not part of the generic `lint` plugin, so it doesn't live in `plugins/lint/` — but per ADR-0014 it also doesn't live at the repo root anymore. Two copies live inside `plugins/kyberforge/`, one per skill, since a plugin's cache-install only copies each skill's own files (no cross-skill sharing): `plugins/kyberforge/skills/agent-audit/assets/vale/` is canonical (`.vale.ini` plus a custom `Kyberforge` style covering description-opener banning ("This skill/agent..."), vague-capability wording ("helps with", "utilize", ...), and generic "see references/ for details" padding — and a `KyberforgeCopilot` style scoped only to `.agent.md` files for the Copilot-only "Use proactively has no effect" check), and `plugins/kyberforge/skills/skill-audit/assets/vale/` is a smaller duplicate (`Kyberforge` only, scoped to `SKILL.md`) kept in sync by `scripts/check-vale-style-sync.sh` (pre-push). A root-level `.pre-commit-hooks.yaml` exposes both copies (plus `skill-size-check`) so any external repo can enforce the same rules via `repo: <this-repo-url>, rev: <tag>` in its own `.pre-commit-config.yaml` — pre-commit clones the pinned rev into its own cache, independent of whether Claude Code or the `kyberforge` plugin is installed at all, and the same mechanism covers CI (`pre-commit run --all-files`). This repo's own `vale-audit-prefilter-skill`/`-agent` pre-commit hooks consume the identical plugin-bundled copies via `repo: local` (not a third root copy, and not a pinned self-reference — a pinned self-reference would lint working-tree edits against the last tagged release rather than the change being made). Every rule is `level: error` and every alert is a FAIL — no ignorable tier, same as shellcheck, the test suite, and conventional-pre-commit. Graded severities do not work here: Vale's exit code keys on `error` alerts alone, so `warning`/`suggestion` rules exit 0 and pre-commit swallows the output of a passing hook, leaving them invisible and blocking nothing. `MinAlertLevel` and `--minAlertLevel` are correspondingly absent from `.vale.ini` and the hook, being no-ops under this model. Vale covers the pattern-matchable sub-checks named in issue #84 (imperative opener, vague filler, `Use proactively`, generic reference-pointer padding) plus, per ADR-0013, one body-wide prose-pattern check ("There is/are" sentence openers) — everything else about body discipline (defaults-vs-menus, why-rationale, non-pattern-matchable judgment calls), near-miss exclusion strength, and control calibration stays 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 once the description is a YAML block scalar (`>`/`|`) spanning 2+ physical lines, which is how most skills/agents in this repo write it. The wrapper flattens the description 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`; single-line descriptions pass through untouched; a relative `--config` path resolves against the caller's cwd, matching bare `vale`, not against the repo root. `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 — `^plugins/[^/]+/skills/[^/]+/SKILL\.md$` for `-skill`, `^plugins/[^/]+/agents/[^/]+\.md$` for `-agent` (split into two hooks precisely because one combined hook pointed at only one copy would silently 0-file-skip the other file type) — and from the audit skills passing one explicit file per invocation. A skill outside `plugins/` (e.g. project-scope `.claude/skills/foo/SKILL.md`) matches no glob section, so Vale reports 0 files and exits 0 — which the audits read as clean. 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 once the description is a YAML block scalar (`>`/`|`) spanning 2+ physical lines, which is how most skills/agents in this repo write it. The wrapper flattens the description 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`; single-line descriptions pass through untouched. 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 still passes `--config assets/vale/.vale.ini` and is unaffected. `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 500-line/5,000-token `SKILL.md` ceiling — failing only above 500 lines, matching `skill-audit/scripts/validate.sh`'s `<= 500` pass — 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 500-line/5,000-token `SKILL.md` ceiling — failing only above 500 lines, matching `skill-audit/scripts/validate.sh`'s `<= 500` pass — 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.

View File

@@ -55,11 +55,27 @@ define separate `-skill`/`-agent` hook IDs, each with a `files:` regex matching
target copy's glob covers. (Confirmed empirically before deleting the root files: retargeting a target copy's glob covers. (Confirmed empirically before deleting the root files: retargeting a
single hook at agent-audit's copy silently scanned 0 SKILL.md files.) single hook at agent-audit's copy silently scanned 0 SKILL.md files.)
**The hook `entry:` is the wrapper alone; the wrapper self-locates its config.** pre-commit
prefixes only `entry[0]` with the hook-repo clone path (`cmd = (prefix.path(cmd[0]), *cmd[1:])`);
every later argument is handed to the process untouched and so resolves against the *consuming*
repo's root. A `--config plugins/kyberforge/skills/…/assets/vale/.vale.ini` in
`.pre-commit-hooks.yaml` therefore named a path no consumer has, and every external run died with
`E100 [--config] Runtime error`. The external-consumer contract this ADR exists to establish
cannot be expressed as a `--config` argument at all — the config path has to be derived inside
the process, from the script's own location. `vale-wrap.sh` accordingly defaults to its sibling
`assets/vale/.vale.ini`, resolved from `${BASH_SOURCE[0]}`, whenever no `--config` is supplied;
an explicit `--config` from any other caller still wins and still resolves against the caller's
cwd, so both audit skills' Step 1 (`--config assets/vale/.vale.ini`) is unaffected. Both
manifests now carry the identical argument-free `entry:`. Keeping them identical is part of the
decision: the local `repo: local` hook resolved its `--config` correctly only because the
consuming repo *was* this repo, and that one difference is why three review rounds exercised a
code path no external consumer ever takes.
**Vale's `StylesPath` resolves relative to the `.vale.ini` file's own location**, confirmed **Vale's `StylesPath` resolves relative to the `.vale.ini` file's own location**, confirmed
against `docs.vale.sh/keys/stylespath` — so `--config <path-into-plugin>/.vale.ini` correctly against `docs.vale.sh/keys/stylespath` — so a config path into the plugin finds that ini's
finds that ini's sibling `styles/` regardless of the caller's cwd, with no extra path-juggling sibling `styles/` regardless of the caller's cwd, whether it arrives as an explicit `--config` or
needed beyond what `vale-wrap.sh` already does for its cwd-relative `--config`/file-argument as the wrapper's self-located default. No extra path-juggling is needed beyond `vale-wrap.sh`'s
handling. cwd-relative `--config`/path-argument handling and that fallback.
**A sync-check catches drift between the two copies.** `scripts/check-vale-style-sync.sh` diffs **A sync-check catches drift between the two copies.** `scripts/check-vale-style-sync.sh` diffs
`scripts/vale-wrap.sh` and `assets/vale/styles/Kyberforge/` between skill-audit and agent-audit `scripts/vale-wrap.sh` and `assets/vale/styles/Kyberforge/` between skill-audit and agent-audit
@@ -91,8 +107,18 @@ doesn't wonder if it was overlooked.
- Root `.vale.ini`, `styles/`, `scripts/vale-wrap.sh` are deleted. Two copies remain: - Root `.vale.ini`, `styles/`, `scripts/vale-wrap.sh` are deleted. Two copies remain:
`plugins/kyberforge/skills/agent-audit/assets/vale/` (canonical, superset) and `plugins/kyberforge/skills/agent-audit/assets/vale/` (canonical, superset) and
`plugins/kyberforge/skills/skill-audit/assets/vale/` (subset, `Kyberforge` only). `plugins/kyberforge/skills/skill-audit/assets/vale/` (subset, `Kyberforge` only).
- `plugins/kyberforge`'s `plugin.json` and `.claude-plugin/plugin.json` both patch-bump to - `plugins/kyberforge`'s `plugin.json` and `.claude-plugin/plugin.json` both patch-bump for every
`1.2.5` for the shipped content change (per ADR-0006's version-parity invariant). shipped content change (per ADR-0006's version-parity invariant): `1.2.5` for the relocation
itself, `1.2.6` for the self-locating `vale-wrap.sh` that followed.
- **`.pre-commit-hooks.yaml` entries are a bare script path and nothing else — a constraint, not a
house style, and it binds every future hook here, not just the Vale two.** Since pre-commit
rewrites only `entry[0]` into the hook-repo clone, no argument token in any entry can reference
a file this repo ships: a relative path resolves against the *consuming* repo and hard-fails,
and the absolute path is unknowable at author time. A hook that needs one of its own bundled
files must have the script self-locate it from `$0`/`${BASH_SOURCE[0]}`, exactly as
`vale-wrap.sh` now does for `.vale.ini`. Anything else rediscovers this as another `E100`.
`.pre-commit-config.yaml` stays byte-identical to the shipped manifest on those `entry:` lines
so the local gate keeps exercising the same resolution path a consumer does.
- `tests/test-vale-wrap.sh` now exercises skill-audit's copy specifically — its fixtures are all - `tests/test-vale-wrap.sh` now exercises skill-audit's copy specifically — its fixtures are all
`SKILL.md`-shaped, and only skill-audit's `.vale.ini` has the matching glob section. `SKILL.md`-shaped, and only skill-audit's `.vale.ini` has the matching glob section.
- The first `vX.Y.Z` tag is cut once this change and its tests pass, giving external - The first `vX.Y.Z` tag is cut once this change and its tests pass, giving external
@@ -117,3 +143,14 @@ doesn't wonder if it was overlooked.
as a separate piece of infrastructure, not fixed here. `RELEASE_PATHS` is derived from as a separate piece of infrastructure, not fixed here. `RELEASE_PATHS` is derived from
`.pre-commit-hooks.yaml`'s own `entry:` lines rather than hand-maintained, so at least the set of `.pre-commit-hooks.yaml`'s own `entry:` lines rather than hand-maintained, so at least the set of
paths it checks can't drift from the manifest on its own. paths it checks can't drift from the manifest on its own.
- **Dropping `--config` moved the release gate's path derivation too.** `check-release-needed.sh`
used to reach each hook's bundled assets through the `dirname` of its `--config` target. With
no `--config` token left, that loop went dead and silently dropped both `assets/vale/` trees
from release coverage — a Vale *rule* change could then land on `main` without demanding a tag,
leaving consumers pinned to an old `rev:` running stale rules while the gate stayed green. The
script now derives the bundle's `assets/` tree from `tokens[0]` instead (double-`dirname`,
guarded on the candidate existing and on not resolving to `.`), which is the only derivation
compatible with the argument-free `entry:` contract above.
- **Accepted residual in the release gate:** deleting a hook's *entire* `assets/` tree is not
flagged — the derived candidate path stops existing, so the guard drops it before it reaches
the pathspec. Deleting individual files inside a surviving tree is flagged, and tested.

View File

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

View File

@@ -13,5 +13,5 @@
"skills": [ "skills": [
"skills/" "skills/"
], ],
"version": "1.2.5" "version": "1.2.6"
} }

View File

@@ -9,26 +9,35 @@ set -euo pipefail
# the real `vale` binary against the copies. Drop-in replacement for calling # the real `vale` binary against the copies. Drop-in replacement for calling
# `vale` directly: same args, same exit code. # `vale` directly: same args, same exit code.
# #
# "Same args" means relative paths — `--config` values and file arguments alike # "Same args" means relative paths — `--config` values and path arguments alike
# — resolve against the caller's current directory, exactly as bare `vale` # — resolve against the caller's current directory, exactly as bare `vale`
# resolves them. (An earlier version resolved them against the repo root, an # resolves them. (An earlier version resolved them against the repo root, an
# invented convention that hard-errored on `--config ../../.vale.ini` from a # invented convention that hard-errored on `--config ../../.vale.ini` from a
# subdirectory and, worse, silently dropped file arguments that didn't happen to # subdirectory and, worse, silently dropped file arguments that didn't happen to
# resolve from the repo root — skipping the flattening this script exists for.) # resolve from the repo root — skipping the flattening this script exists for.)
# #
# Vale prints each file path exactly as it was handed to it, so the scratch tree # The one addition to bare `vale`'s argument handling: with no `--config` at
# mirrors the caller's absolute cwd: a relative file argument is passed through # all, this script's own sibling `assets/vale/.vale.ini` is used instead of
# vale's upward search. pre-commit prefixes only `entry[0]` with the hook-repo
# clone path, so a `--config` in `.pre-commit-hooks.yaml` would resolve against
# the *consuming* repo and hard-fail (E100) for every external consumer. The
# manifest therefore passes the script alone, and an explicit `--config` from
# any other caller still wins.
#
# Vale prints each path exactly as it was handed to it, so the scratch tree
# mirrors the caller's absolute cwd: a relative path argument is passed through
# verbatim and resolves to its flattened copy, keeping the report byte-identical # verbatim and resolves to its flattened copy, keeping the report byte-identical
# to bare `vale`'s. An absolute file argument inside the cwd is relativized to # to bare `vale`'s. An absolute path inside the cwd is relativized to keep that
# keep that property. Only an absolute path outside the cwd is rewritten to its # property. Only an absolute path outside the cwd is rewritten to its scratch
# scratch copy and so reports a scratch path — unavoidable, since a file can # copy and so reports a scratch path — unavoidable, since a file can only be
# only be read from where it actually is. # read from where it actually is.
cwd="$(pwd -P)" cwd="$(pwd -P)"
vale_args=() vale_args=()
file_args=() path_args=()
config_next=false config_next=false
config_given=false
for arg in "$@"; do for arg in "$@"; do
if [[ "$config_next" == true ]]; then if [[ "$config_next" == true ]]; then
config_next=false config_next=false
@@ -43,19 +52,24 @@ for arg in "$@"; do
--config) --config)
vale_args+=("$arg") vale_args+=("$arg")
config_next=true config_next=true
config_given=true
continue continue
;; ;;
--config=/*) --config=/*)
vale_args+=("$arg") vale_args+=("$arg")
config_given=true
continue continue
;; ;;
--config=*) --config=*)
vale_args+=("--config=$cwd/${arg#--config=}") vale_args+=("--config=$cwd/${arg#--config=}")
config_given=true
continue continue
;; ;;
esac esac
# `-f` resolves relative paths against the caller's cwd, same as vale does. # `-f`/`-d` resolve relative paths against the caller's cwd, same as vale does.
if [[ "$arg" != -* && -f "$arg" ]]; then # A path that doesn't exist is left for vale to report on, exactly as bare
# vale would.
if [[ "$arg" != -* && ( -f "$arg" || -d "$arg" ) ]]; then
# An absolute path inside the caller's cwd is relativized so the report cites # An absolute path inside the caller's cwd is relativized so the report cites
# a path that resolves against the real tree. Left absolute, it would be # a path that resolves against the real tree. Left absolute, it would be
# rewritten to its scratch copy and printed as `/tmp/tmp.XXXX/...` — a real # rewritten to its scratch copy and printed as `/tmp/tmp.XXXX/...` — a real
@@ -63,54 +77,41 @@ for arg in "$@"; do
# quoting it. Absolute paths outside the cwd have no relative form and keep # quoting it. Absolute paths outside the cwd have no relative form and keep
# the scratch-path behaviour documented above. # the scratch-path behaviour documented above.
if [[ "$arg" == "$cwd"/* ]]; then if [[ "$arg" == "$cwd"/* ]]; then
file_args+=("${arg#"$cwd"/}") path_args+=("${arg#"$cwd"/}")
else else
file_args+=("$arg") path_args+=("$arg")
fi fi
else else
vale_args+=("$arg") vale_args+=("$arg")
fi fi
done done
if [[ ${#file_args[@]} -eq 0 ]]; then if [[ "$config_given" == false ]]; then
vale_args+=(--config "$(cd "$(dirname "${BASH_SOURCE[0]}")/../assets/vale" && pwd)/.vale.ini")
fi
if [[ ${#path_args[@]} -eq 0 ]]; then
# Nothing to flatten. Hand off directly, with stdin closed so vale doesn't # Nothing to flatten. Hand off directly, with stdin closed so vale doesn't
# block waiting on a pipe that will never carry content. # block waiting on a pipe that will never carry content.
exec vale "${vale_args[@]}" < /dev/null exec vale "${vale_args[@]}" < /dev/null
fi fi
tmpdir="$(realpath -m "$(mktemp -d)")" # `realpath -m` would be the obvious normalizer, but `-m` (canonicalize-missing)
trap 'rm -rf "$tmpdir"' EXIT # is a GNU extension the BSD realpath on macOS doesn't have — and every dest
# below is a path that doesn't exist yet. python3 is already a hard dependency.
abspath() {
python3 -c 'import os, sys; print(os.path.abspath(sys.argv[1]))' "$1"
}
# Mirror of the caller's cwd inside the scratch tree; relative file arguments flatten() {
# are resolved from here. python3 - "$1" "$2" <<'PYTHON'
mirror="$tmpdir$cwd"
mkdir -p "$mirror"
argv_files=()
for arg in "${file_args[@]}"; do
if [[ "$arg" == /* ]]; then
dest="$tmpdir$arg"
else
dest="$mirror/$arg"
fi
dest="$(realpath -m "$dest")"
# A file argument with enough leading `..` to climb past the mirror root would
# write outside the scratch dir. The real filesystem clamps such a path at
# `/`; the mirror can't, so refuse rather than scribble outside the sandbox.
case "$dest" in
"$tmpdir"/*) ;;
*)
echo "vale-wrap.sh: refusing to lint '$arg': its scratch copy would land outside $tmpdir" >&2
exit 2
;;
esac
mkdir -p "$(dirname "$dest")"
python3 - "$arg" "$dest" <<'PYTHON'
import re import re
import sys import sys
src, dest = sys.argv[1], sys.argv[2] src, dest = sys.argv[1], sys.argv[2]
with open(src) as fh: # surrogateescape keeps a non-UTF-8 file (reachable via a directory argument)
# a byte-for-byte round trip instead of aborting the whole run on a decode error.
with open(src, encoding='utf-8', errors='surrogateescape') as fh:
content = fh.read() content = fh.read()
fm_match = re.match(r'^(---\n)(.*?\n)(---\n)', content, re.DOTALL) fm_match = re.match(r'^(---\n)(.*?\n)(---\n)', content, re.DOTALL)
@@ -163,15 +164,60 @@ if fm_match:
new_fm = fm[:start] + f'description: {flat_q}\n{pad}' + fm[end:] new_fm = fm[:start] + f'description: {flat_q}\n{pad}' + fm[end:]
content = fm_match.group(1) + new_fm + fm_match.group(3) + content[fm_match.end():] content = fm_match.group(1) + new_fm + fm_match.group(3) + content[fm_match.end():]
with open(dest, 'w') as fh: with open(dest, 'w', encoding='utf-8', errors='surrogateescape') as fh:
fh.write(content) fh.write(content)
PYTHON PYTHON
}
tmpdir="$(cd "$(mktemp -d)" && pwd -P)"
trap 'rm -rf "$tmpdir"' EXIT
# Mirror of the caller's cwd inside the scratch tree; relative path arguments
# are resolved from here.
mirror="$tmpdir$cwd"
mkdir -p "$mirror"
argv_paths=()
for arg in "${path_args[@]}"; do
if [[ "$arg" == /* ]]; then if [[ "$arg" == /* ]]; then
argv_files+=("$dest") dest="$tmpdir$arg"
else else
argv_files+=("$arg") dest="$mirror/$arg"
fi
dest="$(abspath "$dest")"
# A path argument with enough leading `..` to climb past the mirror root would
# write outside the scratch dir. The real filesystem clamps such a path at
# `/`; the mirror can't, so refuse rather than scribble outside the sandbox.
case "$dest" in
"$tmpdir"/*) ;;
*)
echo "vale-wrap.sh: refusing to lint '$arg': its scratch copy would land outside $tmpdir" >&2
exit 2
;;
esac
mkdir -p "$(dirname "$dest")"
if [[ -d "$arg" ]]; then
# A directory is mirrored whole — vale applies its own format filtering to
# the tree, so any file dropped here would be silently unlinted — and then
# every markdown file in the copy is flattened in place. `.git` is pruned:
# vale never lints it and copying it can dwarf the rest of the tree.
mkdir -p "$dest"
while IFS= read -r -d '' rel; do
mkdir -p "$dest/$(dirname "$rel")"
cp "$arg/$rel" "$dest/$rel"
done < <(cd "$arg" && find . -name .git -prune -o -type f -print0)
while IFS= read -r -d '' md; do
flatten "$md" "$md"
done < <(find "$dest" -type f -name '*.md' -print0)
else
flatten "$arg" "$dest"
fi
if [[ "$arg" == /* ]]; then
argv_paths+=("$dest")
else
argv_paths+=("$arg")
fi fi
done done
cd "$mirror" cd "$mirror"
vale "${vale_args[@]}" "${argv_files[@]}" vale "${vale_args[@]}" "${argv_paths[@]}"

View File

@@ -9,26 +9,35 @@ set -euo pipefail
# the real `vale` binary against the copies. Drop-in replacement for calling # the real `vale` binary against the copies. Drop-in replacement for calling
# `vale` directly: same args, same exit code. # `vale` directly: same args, same exit code.
# #
# "Same args" means relative paths — `--config` values and file arguments alike # "Same args" means relative paths — `--config` values and path arguments alike
# — resolve against the caller's current directory, exactly as bare `vale` # — resolve against the caller's current directory, exactly as bare `vale`
# resolves them. (An earlier version resolved them against the repo root, an # resolves them. (An earlier version resolved them against the repo root, an
# invented convention that hard-errored on `--config ../../.vale.ini` from a # invented convention that hard-errored on `--config ../../.vale.ini` from a
# subdirectory and, worse, silently dropped file arguments that didn't happen to # subdirectory and, worse, silently dropped file arguments that didn't happen to
# resolve from the repo root — skipping the flattening this script exists for.) # resolve from the repo root — skipping the flattening this script exists for.)
# #
# Vale prints each file path exactly as it was handed to it, so the scratch tree # The one addition to bare `vale`'s argument handling: with no `--config` at
# mirrors the caller's absolute cwd: a relative file argument is passed through # all, this script's own sibling `assets/vale/.vale.ini` is used instead of
# vale's upward search. pre-commit prefixes only `entry[0]` with the hook-repo
# clone path, so a `--config` in `.pre-commit-hooks.yaml` would resolve against
# the *consuming* repo and hard-fail (E100) for every external consumer. The
# manifest therefore passes the script alone, and an explicit `--config` from
# any other caller still wins.
#
# Vale prints each path exactly as it was handed to it, so the scratch tree
# mirrors the caller's absolute cwd: a relative path argument is passed through
# verbatim and resolves to its flattened copy, keeping the report byte-identical # verbatim and resolves to its flattened copy, keeping the report byte-identical
# to bare `vale`'s. An absolute file argument inside the cwd is relativized to # to bare `vale`'s. An absolute path inside the cwd is relativized to keep that
# keep that property. Only an absolute path outside the cwd is rewritten to its # property. Only an absolute path outside the cwd is rewritten to its scratch
# scratch copy and so reports a scratch path — unavoidable, since a file can # copy and so reports a scratch path — unavoidable, since a file can only be
# only be read from where it actually is. # read from where it actually is.
cwd="$(pwd -P)" cwd="$(pwd -P)"
vale_args=() vale_args=()
file_args=() path_args=()
config_next=false config_next=false
config_given=false
for arg in "$@"; do for arg in "$@"; do
if [[ "$config_next" == true ]]; then if [[ "$config_next" == true ]]; then
config_next=false config_next=false
@@ -43,19 +52,24 @@ for arg in "$@"; do
--config) --config)
vale_args+=("$arg") vale_args+=("$arg")
config_next=true config_next=true
config_given=true
continue continue
;; ;;
--config=/*) --config=/*)
vale_args+=("$arg") vale_args+=("$arg")
config_given=true
continue continue
;; ;;
--config=*) --config=*)
vale_args+=("--config=$cwd/${arg#--config=}") vale_args+=("--config=$cwd/${arg#--config=}")
config_given=true
continue continue
;; ;;
esac esac
# `-f` resolves relative paths against the caller's cwd, same as vale does. # `-f`/`-d` resolve relative paths against the caller's cwd, same as vale does.
if [[ "$arg" != -* && -f "$arg" ]]; then # A path that doesn't exist is left for vale to report on, exactly as bare
# vale would.
if [[ "$arg" != -* && ( -f "$arg" || -d "$arg" ) ]]; then
# An absolute path inside the caller's cwd is relativized so the report cites # An absolute path inside the caller's cwd is relativized so the report cites
# a path that resolves against the real tree. Left absolute, it would be # a path that resolves against the real tree. Left absolute, it would be
# rewritten to its scratch copy and printed as `/tmp/tmp.XXXX/...` — a real # rewritten to its scratch copy and printed as `/tmp/tmp.XXXX/...` — a real
@@ -63,54 +77,41 @@ for arg in "$@"; do
# quoting it. Absolute paths outside the cwd have no relative form and keep # quoting it. Absolute paths outside the cwd have no relative form and keep
# the scratch-path behaviour documented above. # the scratch-path behaviour documented above.
if [[ "$arg" == "$cwd"/* ]]; then if [[ "$arg" == "$cwd"/* ]]; then
file_args+=("${arg#"$cwd"/}") path_args+=("${arg#"$cwd"/}")
else else
file_args+=("$arg") path_args+=("$arg")
fi fi
else else
vale_args+=("$arg") vale_args+=("$arg")
fi fi
done done
if [[ ${#file_args[@]} -eq 0 ]]; then if [[ "$config_given" == false ]]; then
vale_args+=(--config "$(cd "$(dirname "${BASH_SOURCE[0]}")/../assets/vale" && pwd)/.vale.ini")
fi
if [[ ${#path_args[@]} -eq 0 ]]; then
# Nothing to flatten. Hand off directly, with stdin closed so vale doesn't # Nothing to flatten. Hand off directly, with stdin closed so vale doesn't
# block waiting on a pipe that will never carry content. # block waiting on a pipe that will never carry content.
exec vale "${vale_args[@]}" < /dev/null exec vale "${vale_args[@]}" < /dev/null
fi fi
tmpdir="$(realpath -m "$(mktemp -d)")" # `realpath -m` would be the obvious normalizer, but `-m` (canonicalize-missing)
trap 'rm -rf "$tmpdir"' EXIT # is a GNU extension the BSD realpath on macOS doesn't have — and every dest
# below is a path that doesn't exist yet. python3 is already a hard dependency.
abspath() {
python3 -c 'import os, sys; print(os.path.abspath(sys.argv[1]))' "$1"
}
# Mirror of the caller's cwd inside the scratch tree; relative file arguments flatten() {
# are resolved from here. python3 - "$1" "$2" <<'PYTHON'
mirror="$tmpdir$cwd"
mkdir -p "$mirror"
argv_files=()
for arg in "${file_args[@]}"; do
if [[ "$arg" == /* ]]; then
dest="$tmpdir$arg"
else
dest="$mirror/$arg"
fi
dest="$(realpath -m "$dest")"
# A file argument with enough leading `..` to climb past the mirror root would
# write outside the scratch dir. The real filesystem clamps such a path at
# `/`; the mirror can't, so refuse rather than scribble outside the sandbox.
case "$dest" in
"$tmpdir"/*) ;;
*)
echo "vale-wrap.sh: refusing to lint '$arg': its scratch copy would land outside $tmpdir" >&2
exit 2
;;
esac
mkdir -p "$(dirname "$dest")"
python3 - "$arg" "$dest" <<'PYTHON'
import re import re
import sys import sys
src, dest = sys.argv[1], sys.argv[2] src, dest = sys.argv[1], sys.argv[2]
with open(src) as fh: # surrogateescape keeps a non-UTF-8 file (reachable via a directory argument)
# a byte-for-byte round trip instead of aborting the whole run on a decode error.
with open(src, encoding='utf-8', errors='surrogateescape') as fh:
content = fh.read() content = fh.read()
fm_match = re.match(r'^(---\n)(.*?\n)(---\n)', content, re.DOTALL) fm_match = re.match(r'^(---\n)(.*?\n)(---\n)', content, re.DOTALL)
@@ -163,15 +164,60 @@ if fm_match:
new_fm = fm[:start] + f'description: {flat_q}\n{pad}' + fm[end:] new_fm = fm[:start] + f'description: {flat_q}\n{pad}' + fm[end:]
content = fm_match.group(1) + new_fm + fm_match.group(3) + content[fm_match.end():] content = fm_match.group(1) + new_fm + fm_match.group(3) + content[fm_match.end():]
with open(dest, 'w') as fh: with open(dest, 'w', encoding='utf-8', errors='surrogateescape') as fh:
fh.write(content) fh.write(content)
PYTHON PYTHON
}
tmpdir="$(cd "$(mktemp -d)" && pwd -P)"
trap 'rm -rf "$tmpdir"' EXIT
# Mirror of the caller's cwd inside the scratch tree; relative path arguments
# are resolved from here.
mirror="$tmpdir$cwd"
mkdir -p "$mirror"
argv_paths=()
for arg in "${path_args[@]}"; do
if [[ "$arg" == /* ]]; then if [[ "$arg" == /* ]]; then
argv_files+=("$dest") dest="$tmpdir$arg"
else else
argv_files+=("$arg") dest="$mirror/$arg"
fi
dest="$(abspath "$dest")"
# A path argument with enough leading `..` to climb past the mirror root would
# write outside the scratch dir. The real filesystem clamps such a path at
# `/`; the mirror can't, so refuse rather than scribble outside the sandbox.
case "$dest" in
"$tmpdir"/*) ;;
*)
echo "vale-wrap.sh: refusing to lint '$arg': its scratch copy would land outside $tmpdir" >&2
exit 2
;;
esac
mkdir -p "$(dirname "$dest")"
if [[ -d "$arg" ]]; then
# A directory is mirrored whole — vale applies its own format filtering to
# the tree, so any file dropped here would be silently unlinted — and then
# every markdown file in the copy is flattened in place. `.git` is pruned:
# vale never lints it and copying it can dwarf the rest of the tree.
mkdir -p "$dest"
while IFS= read -r -d '' rel; do
mkdir -p "$dest/$(dirname "$rel")"
cp "$arg/$rel" "$dest/$rel"
done < <(cd "$arg" && find . -name .git -prune -o -type f -print0)
while IFS= read -r -d '' md; do
flatten "$md" "$md"
done < <(find "$dest" -type f -name '*.md' -print0)
else
flatten "$arg" "$dest"
fi
if [[ "$arg" == /* ]]; then
argv_paths+=("$dest")
else
argv_paths+=("$arg")
fi fi
done done
cd "$mirror" cd "$mirror"
vale "${vale_args[@]}" "${argv_files[@]}" vale "${vale_args[@]}" "${argv_paths[@]}"

View File

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

View File

@@ -19,11 +19,11 @@ Lints the given file(s)/glob against the styles configured in `.vale.ini`.
| `vale sync` | Downloads and installs packages/styles declared in `.vale.ini`. Run after install and whenever `Packages` changes. | | `vale sync` | Downloads and installs packages/styles declared in `.vale.ini`. Run after install and whenever `Packages` changes. |
| `vale ls-config` | Prints the currently active, fully-resolved configuration as JSON. Useful for debugging what settings actually apply to a file. | | `vale ls-config` | Prints the currently active, fully-resolved configuration as JSON. Useful for debugging what settings actually apply to a file. |
| `--output=<style>` | Sets the output format/template: `line`, `JSON`, `CLI` (default), or a custom template. | | `--output=<style>` | Sets the output format/template: `line`, `JSON`, `CLI` (default), or a custom template. |
| `--no-exit` | Suppresses the non-zero exit code Vale normally returns when alerts are found — useful in CI pipelines that shouldn't hard-fail on lint output. | | `--no-exit` | Suppresses the non-zero exit code Vale normally returns when `error`-level alerts are found — useful in CI pipelines that shouldn't hard-fail on lint output. |
| `--ignore-syntax` | Treats input as plain, unformatted text, skipping syntax-aware parsing (Markdown/HTML/etc). | | `--ignore-syntax` | Treats input as plain, unformatted text, skipping syntax-aware parsing (Markdown/HTML/etc). |
| `--minAlertLevel=<level>` | Overrides `MinAlertLevel` from the config for this run (`suggestion`, `warning`, `error`). | | `--minAlertLevel=<level>` | Overrides `MinAlertLevel` from the config for this run (`suggestion`, `warning`, `error`). Filters what is displayed; does not affect the exit code. |
| `--version` | Prints the Vale binary version. | | `--version` | Prints the Vale binary version. |
## Exit Codes ## Exit Codes
By default, `vale` exits non-zero when it finds any alert at or above `MinAlertLevel` — this is what makes it usable as a CI gate. Pass `--no-exit` to always exit `0` regardless of findings. By default, `vale` exits non-zero only when it finds at least one `error`-level alert — this is what makes it usable as a CI gate. `warning` and `suggestion` alerts are printed but still exit `0`, so a rule that must gate CI or a commit hook has to be `level: error`. `MinAlertLevel` and `--minAlertLevel` filter which alerts are displayed and never affect the exit code. Pass `--no-exit` to always exit `0` regardless of findings.

View File

@@ -53,4 +53,6 @@ If a file's syntax-aware parsing produces noisy/incorrect results (e.g. an unsup
## CI Failing Unexpectedly ## CI Failing Unexpectedly
If a CI job fails solely because Vale returns a non-zero exit code on found alerts (not because the content is actually wrong for that pipeline stage), add `--no-exit` rather than suppressing the rule itself — this preserves the lint output while not gating the build on it. If a CI job fails solely because Vale returns a non-zero exit code on `error`-level alerts (not because the content is actually wrong for that pipeline stage), add `--no-exit` rather than suppressing the rule itself — this preserves the lint output while not gating the build on it. Raising `MinAlertLevel` is not an alternative: it only filters which alerts print, so an `error`-level alert still exits non-zero.
The mirror-image failure is a Vale gate that never fails. Only `error`-level alerts drive the exit code, so a `warning`- or `suggestion`-level rule prints its alert and still exits `0` — invisible in any CI stage that hides passing output. If a rule must block, give it `level: error`.

View File

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

View File

@@ -34,19 +34,29 @@ fi
# instead of hand-maintaining a parallel list — the manifest is the single # instead of hand-maintaining a parallel list — the manifest is the single
# source of truth for what external consumers actually pull at a pinned rev, # source of truth for what external consumers actually pull at a pinned rev,
# so a hook added/removed/renamed there can't silently drift out of sync here. # so a hook added/removed/renamed there can't silently drift out of sync here.
# Each entry is "<script> [--config <path>] [...]"; the script itself and the # Everything is derived from tokens[0], the hook's script: pre-commit prefixes
# directory holding any --config target (vale-wrap.sh needs its .vale.ini's # only entry[0] with the hook-repo clone path, so any later token that looks
# sibling styles/ tree, not just the ini file) are release-relevant. # like a path resolves against the *consuming* repo and can never name a file
# this repo ships. A hook's bundled data therefore has to be self-located
# relative to the script — vale-wrap.sh reads its own
# <script-dir>/../assets/vale/.vale.ini plus the sibling styles/ tree — which
# makes <script-dir>/../assets release-relevant alongside the script itself.
# The ../ is normalised by stripping a path component rather than with
# `realpath -m`, which is a GNU-only extension. Two guards keep the derivation
# from inventing paths: a bundle root of "." is skipped, because a script in a
# top-level directory (scripts/skill-size-check.sh) would derive the repo's own
# shared assets/, which no hook owns and whose churn must not demand a release;
# and the directory is added only when it exists, since a hook that bundles
# nothing must not contribute a pathspec matching nothing.
RELEASE_PATHS=("$HOOKS_MANIFEST") RELEASE_PATHS=("$HOOKS_MANIFEST")
while IFS= read -r entry; do while IFS= read -r entry; do
read -ra tokens <<< "$entry" read -ra tokens <<< "$entry"
[[ ${#tokens[@]} -eq 0 ]] && continue [[ ${#tokens[@]} -eq 0 ]] && continue
RELEASE_PATHS+=("${tokens[0]}") RELEASE_PATHS+=("${tokens[0]}")
for ((i = 1; i < ${#tokens[@]}; i++)); do bundle_root="$(dirname "$(dirname "${tokens[0]}")")"
if [[ "${tokens[i]}" == "--config" && -n "${tokens[i + 1]:-}" ]]; then if [[ "$bundle_root" != "." && -d "$bundle_root/assets" ]]; then
RELEASE_PATHS+=("$(dirname "${tokens[i + 1]}")") RELEASE_PATHS+=("$bundle_root/assets")
fi fi
done
done < <(sed -n 's/^[[:space:]]*entry:[[:space:]]*//p' "$HOOKS_MANIFEST") done < <(sed -n 's/^[[:space:]]*entry:[[:space:]]*//p' "$HOOKS_MANIFEST")
# 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

View File

@@ -17,10 +17,22 @@ err() { echo " FAIL: $1" >&2; FAIL=$((FAIL + 1)); }
SKILL_AUDIT="$REPO_ROOT/plugins/kyberforge/skills/skill-audit" SKILL_AUDIT="$REPO_ROOT/plugins/kyberforge/skills/skill-audit"
AGENT_AUDIT="$REPO_ROOT/plugins/kyberforge/skills/agent-audit" AGENT_AUDIT="$REPO_ROOT/plugins/kyberforge/skills/agent-audit"
if [[ ! -d "$SKILL_AUDIT" || ! -d "$AGENT_AUDIT" ]]; then if [[ ! -d "$SKILL_AUDIT" && ! -d "$AGENT_AUDIT" ]]; then
exit 0 exit 0
fi fi
# Exactly one present is drift, not absence: the missing copy can't be in sync
# with the surviving one, and treating it as a no-op is how a deleted or
# renamed copy would slip through silently.
if [[ ! -d "$SKILL_AUDIT" ]]; then
echo "Vale style sync check failed: $AGENT_AUDIT exists but $SKILL_AUDIT does not — run scripts/sync-vale-styles.sh to regenerate skill-audit's copy." >&2
exit 1
fi
if [[ ! -d "$AGENT_AUDIT" ]]; then
echo "Vale style sync check failed: $SKILL_AUDIT exists but $AGENT_AUDIT does not — agent-audit holds the canonical copy, so restore it before syncing." >&2
exit 1
fi
if ! diff -q "$SKILL_AUDIT/scripts/vale-wrap.sh" "$AGENT_AUDIT/scripts/vale-wrap.sh" >/dev/null 2>&1; then if ! diff -q "$SKILL_AUDIT/scripts/vale-wrap.sh" "$AGENT_AUDIT/scripts/vale-wrap.sh" >/dev/null 2>&1; then
err "scripts/vale-wrap.sh differs between skill-audit and agent-audit" err "scripts/vale-wrap.sh differs between skill-audit and agent-audit"
fi fi

View File

@@ -2,6 +2,9 @@
# Run all test-*.sh files in the repo (including plugins) and the bats suite. # Run all test-*.sh files in the repo (including plugins) and the bats suite.
# Usage: bash tests/run-tests.sh [--bats-only] # Usage: bash tests/run-tests.sh [--bats-only]
# #
# A script exiting 77 (the automake convention) is reported as SKIPPED, not
# passed — a suite that can't run for lack of a binary must not read as green.
#
# TEST_DIR — override root to search for test-*.sh (default: REPO_ROOT); used by tests. # TEST_DIR — override root to search for test-*.sh (default: REPO_ROOT); used by tests.
set -euo pipefail set -euo pipefail
@@ -13,7 +16,9 @@ BATS_ONLY=false
SEARCH_ROOT="${TEST_DIR:-$REPO_ROOT}" SEARCH_ROOT="${TEST_DIR:-$REPO_ROOT}"
FAILED=() FAILED=()
SKIPPED=()
PASSED=0 PASSED=0
SKIP_EXIT=77
run_bats() { run_bats() {
if [[ -x "$BATS" ]]; then if [[ -x "$BATS" ]]; then
@@ -40,15 +45,25 @@ mapfile -t SCRIPTS < <(
for script in "${SCRIPTS[@]}"; do for script in "${SCRIPTS[@]}"; do
rel="${script#"$SEARCH_ROOT/"}" rel="${script#"$SEARCH_ROOT/"}"
echo "=== $rel ===" echo "=== $rel ==="
if bash "$script"; then rc=0
bash "$script" || rc=$?
if [[ $rc -eq 0 ]]; then
PASSED=$((PASSED + 1)) PASSED=$((PASSED + 1))
elif [[ $rc -eq $SKIP_EXIT ]]; then
SKIPPED+=("$rel")
else else
FAILED+=("$rel") FAILED+=("$rel")
fi fi
echo "" echo ""
done done
echo "=== Summary: $PASSED passed, ${#FAILED[@]} failed ===" echo "=== Summary: $PASSED passed, ${#SKIPPED[@]} skipped, ${#FAILED[@]} failed ==="
if [[ ${#SKIPPED[@]} -gt 0 ]]; then
echo "Skipped scripts:"
for s in "${SKIPPED[@]}"; do
echo " $s"
done
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[@]}"; do

View File

@@ -9,32 +9,45 @@ 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)); }
# Helper: write a minimal .pre-commit-hooks.yaml exposing one script entry and # Both entry shapes the real .pre-commit-hooks.yaml ships: a bare script with no
# one --config-bearing entry, so RELEASE_PATHS (derived from the manifest, not # bundled data, and a bare script whose sibling assets/ tree it self-locates at
# hand-maintained) has both shapes to parse. # runtime. Neither carries arguments — pre-commit only rewrites entry[0] to the
# hook-repo clone path, so an argument path would resolve against the consuming
# repo. RELEASE_PATHS is derived from the manifest rather than hand-maintained,
# so it has to cope with both.
HOOK_DIR="plugins/demo/skills/demo-audit"
write_manifest() { write_manifest() {
local dir="$1" local dir="$1"
mkdir -p "$dir/vale" cat > "$dir/.pre-commit-hooks.yaml" <<EOF
cat > "$dir/.pre-commit-hooks.yaml" <<'EOF'
- id: fake-size-check - id: fake-size-check
entry: scripts/skill-size-check.sh entry: scripts/skill-size-check.sh
language: script language: script
- id: fake-vale-check - id: fake-vale-check
entry: scripts/vale-wrap.sh --config vale/.vale.ini entry: $HOOK_DIR/scripts/vale-wrap.sh
language: script language: script
EOF EOF
} }
# Helper: a fixture repo with a manifest and its two referenced release-relevant # Helper: writes the files both manifest entries expose — the two hook scripts
# paths, committed and tagged v1.0.0. # plus the bundled Vale config and style rule the second one self-locates.
write_release_paths() {
local dir="$1"
mkdir -p "$dir/scripts" "$dir/$HOOK_DIR/scripts" "$dir/$HOOK_DIR/assets/vale/styles/Kyberforge"
echo "v1" > "$dir/scripts/skill-size-check.sh"
echo "v1" > "$dir/$HOOK_DIR/scripts/vale-wrap.sh"
echo "cfg" > "$dir/$HOOK_DIR/assets/vale/.vale.ini"
echo "rule: v1" > "$dir/$HOOK_DIR/assets/vale/styles/Kyberforge/DemoRule.yml"
}
# Helper: a fixture repo with a manifest and every release-relevant path it
# exposes, committed and tagged v1.0.0.
make_tagged_fixture() { make_tagged_fixture() {
local dir local dir
dir="$(mktemp -d)" dir="$(mktemp -d)"
(cd "$dir" && git init -q && git config user.email t@t.t && git config user.name t) (cd "$dir" && git init -q && git config user.email t@t.t && git config user.name t)
write_manifest "$dir" write_manifest "$dir"
mkdir -p "$dir/scripts" write_release_paths "$dir"
echo "v1" > "$dir/scripts/skill-size-check.sh"
echo "cfg" > "$dir/vale/.vale.ini"
(cd "$dir" && git add -A && git commit -q -m "initial" && git tag v1.0.0) (cd "$dir" && git add -A && git commit -q -m "initial" && git tag v1.0.0)
echo "$dir" echo "$dir"
} }
@@ -65,9 +78,7 @@ echo "--- exits 1 when targeting main and no tag exists ---"
FIXTURE2="$(mktemp -d)"; track "$FIXTURE2" FIXTURE2="$(mktemp -d)"; track "$FIXTURE2"
(cd "$FIXTURE2" && git init -q && git config user.email t@t.t && git config user.name t) (cd "$FIXTURE2" && git init -q && git config user.email t@t.t && git config user.name t)
write_manifest "$FIXTURE2" write_manifest "$FIXTURE2"
mkdir -p "$FIXTURE2/scripts" write_release_paths "$FIXTURE2"
echo "v1" > "$FIXTURE2/scripts/skill-size-check.sh"
echo "cfg" > "$FIXTURE2/vale/.vale.ini"
(cd "$FIXTURE2" && git add -A && git commit -q -m "initial") (cd "$FIXTURE2" && git add -A && git commit -q -m "initial")
if run_check "$FIXTURE2" "refs/heads/main" > /dev/null; then if run_check "$FIXTURE2" "refs/heads/main" > /dev/null; then
fail "exited 0 when targeting main with no tag — expected exit 1" fail "exited 0 when targeting main with no tag — expected exit 1"
@@ -116,10 +127,10 @@ fi
echo "" echo ""
echo "--- exits 1 when a release-relevant path was deleted since the tag, not just modified ---" echo "--- exits 1 when a release-relevant path was deleted since the tag, not just modified ---"
FIXTURE6="$(make_tagged_fixture)"; track "$FIXTURE6" FIXTURE6="$(make_tagged_fixture)"; track "$FIXTURE6"
rm -rf "$FIXTURE6/vale" rm -f "$FIXTURE6/$HOOK_DIR/assets/vale/.vale.ini"
(cd "$FIXTURE6" && git add -A && git commit -q -m "delete the vale config dir") (cd "$FIXTURE6" && git add -A && git commit -q -m "delete the bundled vale config")
OUT6=$(run_check "$FIXTURE6" "refs/heads/main" || true) OUT6=$(run_check "$FIXTURE6" "refs/heads/main" || true)
if echo "$OUT6" | grep -q "vale/.vale.ini"; then if echo "$OUT6" | grep -q "assets/vale/.vale.ini"; then
pass "flags a deleted release-relevant path instead of silently dropping it from the diff" pass "flags a deleted release-relevant path instead of silently dropping it from the diff"
else else
fail "did not flag deletion of a release-relevant path since the tag" fail "did not flag deletion of a release-relevant path since the tag"
@@ -166,6 +177,40 @@ else
fail "flagged a file that no .pre-commit-hooks.yaml entry actually exposes" fail "flagged a file that no .pre-commit-hooks.yaml entry actually exposes"
fi fi
# --- 10. A change confined to a hook's bundled styles/ tree is release-relevant ---
# The manifest entry names only the wrapper script; the Vale rules it enforces
# live in the sibling assets/ tree it self-locates at runtime. If that tree is
# not covered, editing a rule and landing it on main demands no new tag, and a
# consumer pinned to the old rev keeps the stale rules forever.
echo ""
echo "--- exits 1 when only a bundled Vale style rule changed since the tag ---"
FIXTURE10="$(make_tagged_fixture)"; track "$FIXTURE10"
echo "rule: v2" > "$FIXTURE10/$HOOK_DIR/assets/vale/styles/Kyberforge/DemoRule.yml"
(cd "$FIXTURE10" && git add -A && git commit -q -m "tighten a vale rule")
OUT10=$(run_check "$FIXTURE10" "refs/heads/main" || true)
if echo "$OUT10" | grep -q "assets/vale/styles/Kyberforge/DemoRule.yml"; then
pass "flags a change confined to a hook's bundled assets/vale/styles/ tree"
else
fail "a bundled Vale style rule changed since the tag without demanding a release"
fi
# --- 11. The assets/ derivation must not invent a path for a bundle-less hook ---
# scripts/skill-size-check.sh has no sibling assets/ tree, so its derived
# candidate normalises to a bare top-level assets/ — a directory this repo does
# not ship. Adding it unconditionally would make any unrelated repo-root
# assets/ file falsely demand a release.
echo ""
echo "--- exits 0 when a top-level assets/ file changed but no hook bundles one ---"
FIXTURE11="$(make_tagged_fixture)"; track "$FIXTURE11"
mkdir -p "$FIXTURE11/assets"
echo "unrelated" > "$FIXTURE11/assets/logo.txt"
(cd "$FIXTURE11" && git add -A && git commit -q -m "add an unrelated top-level assets file")
if run_check "$FIXTURE11" "refs/heads/main" > /dev/null; then
pass "exits 0 for a top-level assets/ file that no manifest entry bundles"
else
fail "invented a bogus assets/ path for a hook script with no bundled tree"
fi
echo "" echo ""
echo "Results: $PASS passed, $FAIL failed" echo "Results: $PASS passed, $FAIL failed"
[[ $FAIL -eq 0 ]] [[ $FAIL -eq 0 ]]

View File

@@ -86,6 +86,27 @@ else
fail "exited non-zero when skill-audit/agent-audit are simply absent" fail "exited non-zero when skill-audit/agent-audit are simply absent"
fi fi
# --- 6. Exits 1 when only one of the two copies is present ---
# The no-op guard used `||`, so a single missing copy also exited 0 — a deleted
# or renamed copy passed the sync check silently.
echo ""
echo "--- exits 1 when only one of the two copies is present ---"
FIXTURE6="$(make_fixture)"
FIXTURE7="$(make_fixture)"
trap 'rm -rf "$FIXTURE" "$FIXTURE2" "$FIXTURE3" "$FIXTURE4" "$FIXTURE5" "$FIXTURE6" "$FIXTURE7"' EXIT
rm -rf "$FIXTURE6/plugins/kyberforge/skills/skill-audit"
rm -rf "$FIXTURE7/plugins/kyberforge/skills/agent-audit"
if bash "$SCRIPT" "$FIXTURE6" > /dev/null 2>&1; then
fail "exited 0 when only agent-audit is present — expected exit 1"
else
pass "exits non-zero when skill-audit's copy is missing but agent-audit's is present"
fi
if bash "$SCRIPT" "$FIXTURE7" > /dev/null 2>&1; then
fail "exited 0 when only skill-audit is present — expected exit 1"
else
pass "exits non-zero when agent-audit's canonical copy is missing but skill-audit's is present"
fi
echo "" echo ""
echo "Results: $PASS passed, $FAIL failed" echo "Results: $PASS passed, $FAIL failed"
[[ $FAIL -eq 0 ]] [[ $FAIL -eq 0 ]]

127
tests/test-vale-hooks-consumer.sh Executable file
View File

@@ -0,0 +1,127 @@
#!/usr/bin/env bash
# Integration test for .pre-commit-hooks.yaml as an EXTERNAL hook repo — the
# contract ADR-0014 exists to provide, and the one thing running pre-commit
# inside this repo can never exercise: `repo: local` makes pre-commit's clone
# prefix equal to the consuming repo's root, so a hook entry that only works
# because those two coincide passes here and hard-fails everywhere else.
# (It did: every argument after entry[0] resolves against the CONSUMING repo,
# so a `--config plugins/.../.vale.ini` argument gave external consumers
# `E100 [--config] Runtime error ... does not exist`, exit 2, on both Vale hooks.)
#
# The hook repo is built from the WORKING TREE, not from HEAD, so an uncommitted
# change to the manifest or the wrapper is what gets tested.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PASS=0
FAIL=0
pass() { echo " PASS: $1"; PASS=$((PASS + 1)); }
fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); }
for bin in pre-commit vale git; do
if ! command -v "$bin" &>/dev/null; then
echo "SKIP: $bin is not installed — cannot stand up a consumer repo"
exit 77
fi
done
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
HOOK_REPO="$WORK/hookrepo"
CONSUMER="$WORK/consumer"
export PRE_COMMIT_HOME="$WORK/pc-home"
mkdir -p "$HOOK_REPO/plugins/kyberforge/skills" "$HOOK_REPO/scripts"
cp "$REPO_ROOT/.pre-commit-hooks.yaml" "$HOOK_REPO/"
cp "$REPO_ROOT/scripts/skill-size-check.sh" "$HOOK_REPO/scripts/"
for skill in skill-audit agent-audit; do
mkdir -p "$HOOK_REPO/plugins/kyberforge/skills/$skill"
cp -R "$REPO_ROOT/plugins/kyberforge/skills/$skill/scripts" \
"$REPO_ROOT/plugins/kyberforge/skills/$skill/assets" \
"$HOOK_REPO/plugins/kyberforge/skills/$skill/"
done
git -C "$HOOK_REPO" init -q
git -C "$HOOK_REPO" add -A
git -C "$HOOK_REPO" -c user.email=test@example.invalid -c user.name=test commit -qm "hook repo"
HOOK_REV="$(git -C "$HOOK_REPO" rev-parse HEAD)"
# The two Vale hooks scope by filename, so the consumer needs one file of each
# shape: a hook with nothing to match reports `Skipped` and proves nothing.
mkdir -p "$CONSUMER/skills/demo" "$CONSUMER/agents"
git -C "$CONSUMER" init -q
cat > "$CONSUMER/.pre-commit-config.yaml" <<EOF
repos:
- repo: file://$HOOK_REPO
rev: $HOOK_REV
hooks:
- id: kyberforge-vale-audit-skill
- id: kyberforge-vale-audit-agent
EOF
write_fixtures() {
local body="$1"
cat > "$CONSUMER/skills/demo/SKILL.md" <<EOF
---
name: demo
description: >
Use when the caller wants a demonstration skill $body across two
physical lines of one folded block scalar.
---
Body.
EOF
cat > "$CONSUMER/agents/demo.md" <<EOF
---
name: demo
description: >
Use when the caller wants a demonstration agent $body across two
physical lines of one folded block scalar.
---
Body.
EOF
git -C "$CONSUMER" add -A
}
run_hooks() {
(cd "$CONSUMER" && pre-commit run --all-files 2>&1) || true
}
# --- 1. Both hooks resolve their config and actually gate on a bad file ---
echo ""
echo "--- both Vale hooks run and fail a bad file in an external consumer repo ---"
write_fixtures "that helps with and utilize things"
OUT_BAD="$(run_hooks)"
if echo "$OUT_BAD" | grep -q "does not exist"; then
fail "hooks hard-errored on a path resolved against the consumer repo (E100) — the bug this test guards against"
echo "$OUT_BAD" | sed 's/^/ /'
elif echo "$OUT_BAD" | grep -q "Skipped"; then
fail "a hook matched no files, so it proved nothing"
echo "$OUT_BAD" | sed 's/^/ /'
elif [[ "$(echo "$OUT_BAD" | grep -c "VagueWording")" -ge 2 ]]; then
pass "both hooks flatten and flag the folded description in a consumer repo"
else
fail "hooks did not flag both fixtures"
echo "$OUT_BAD" | sed 's/^/ /'
fi
# --- 2. Clean files pass — the hooks gate, they don't just always fail ---
echo ""
echo "--- both Vale hooks pass clean files in an external consumer repo ---"
write_fixtures "of the packaged hook contract"
set +e
(cd "$CONSUMER" && pre-commit run --all-files > "$WORK/clean.log" 2>&1)
RC_CLEAN=$?
set -e
if [[ $RC_CLEAN -eq 0 ]]; then
pass "both hooks exit 0 on clean files"
else
fail "hooks failed on clean files (rc=$RC_CLEAN)"
sed 's/^/ /' "$WORK/clean.log"
fi
echo ""
echo "Results: $PASS passed, $FAIL failed"
[[ $FAIL -eq 0 ]]

View File

@@ -19,8 +19,8 @@ pass() { echo " PASS: $1"; PASS=$((PASS + 1)); }
fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); } fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); }
if ! command -v vale &>/dev/null; then if ! command -v vale &>/dev/null; then
echo "vale is not installed — skipping (matches skill-audit/agent-audit's own fallback behavior)" echo "SKIP: vale is not installed — skipping (matches skill-audit/agent-audit's own fallback behavior)"
exit 0 exit 77
fi fi
make_fixture() { make_fixture() {
@@ -345,6 +345,86 @@ else
fail "wrapper altered output for a literal (|) block scalar description — should be left untouched" fail "wrapper altered output for a literal (|) block scalar description — should be left untouched"
fi fi
# --- 12. With no --config at all, the wrapper falls back to its own sibling
# assets/vale/.vale.ini. `.pre-commit-hooks.yaml` relies on this: pre-commit
# prefixes only entry[0] with the hook-repo clone path, so a --config argument
# there resolves against the consuming repo and hard-errors (E100) for every
# external consumer.
echo ""
echo "--- defaults --config to the wrapper's own sibling assets/vale/.vale.ini ---"
FIXTURE12="$(make_fixture 2)"
trap 'rm -rf "$FIXTURE1" "$FIXTURE2" "$FIXTURE3" "$FIXTURE4" "$FIXTURE5" "$FIXTURE6" "$FIXTURE7" "$FIXTURE8" "$FIXTURE10" "$FIXTURE11" "$FIXTURE12"' EXIT
OUT12=$(run_wrap "$FIXTURE12" plugins/testplugin/skills/zzzskill/SKILL.md)
if echo "$OUT12" | grep -q "VagueWording"; then
pass "a --config-less invocation uses the wrapper's bundled config"
else
fail "a --config-less invocation found no config — external pre-commit consumers get E100, the bug this test guards against"
fi
# --- 13. No GNU-only `realpath -m`. macOS ships the BSD realpath, which has no
# -m (canonicalize-missing) — and every scratch destination is a path that does
# not exist yet, so a plain `realpath` exits 1 and set -e aborts the hook.
echo ""
echo "--- runs with a BSD realpath that has no -m option ---"
STUB13="$(mktemp -d)"
trap 'rm -rf "$FIXTURE1" "$FIXTURE2" "$FIXTURE3" "$FIXTURE4" "$FIXTURE5" "$FIXTURE6" "$FIXTURE7" "$FIXTURE8" "$FIXTURE10" "$FIXTURE11" "$FIXTURE12" "$STUB13"' EXIT
REAL_REALPATH="$(command -v realpath || echo /bin/false)"
cat > "$STUB13/realpath" <<EOF
#!/usr/bin/env bash
for a in "\$@"; do
case "\$a" in
-m|--canonicalize-missing)
echo "realpath: illegal option -- m" >&2
exit 1
;;
esac
done
exec "$REAL_REALPATH" "\$@"
EOF
chmod +x "$STUB13/realpath"
OUT13=$(cd "$FIXTURE12" && PATH="$STUB13:$PATH" bash "$SCRIPT" --config "$VALE_CONFIG" \
plugins/testplugin/skills/zzzskill/SKILL.md 2>&1 || true)
if echo "$OUT13" | grep -q "illegal option"; then
fail "invoked realpath -m — fails on macOS's BSD realpath, the bug this test guards against"
elif echo "$OUT13" | grep -q "VagueWording"; then
pass "flattens and flags with no GNU realpath available"
else
fail "produced no alert under a BSD-style realpath: $OUT13"
fi
# --- 14. A directory argument is walked and its files flattened. The classifier
# used to accept only regular files, so a directory fell through to the vale
# flag list, left the file list empty, and exec'd bare vale — silently skipping
# the flattening. `lint`'s vale-run skill documents `vale <path-or-glob>` as
# normal usage, so this is a reachable path.
echo ""
echo "--- flattens files reached through a directory argument ---"
FIXTURE14="$(make_fixture 2)"
trap 'rm -rf "$FIXTURE1" "$FIXTURE2" "$FIXTURE3" "$FIXTURE4" "$FIXTURE5" "$FIXTURE6" "$FIXTURE7" "$FIXTURE8" "$FIXTURE10" "$FIXTURE11" "$FIXTURE12" "$STUB13" "$FIXTURE14"' EXIT
WRAPPED_DIR=$(run_wrap "$FIXTURE14" --config "$VALE_CONFIG" plugins)
BARE_DIR=$(cd "$FIXTURE14" && vale --config "$VALE_CONFIG" plugins 2>&1 || true)
if ! echo "$WRAPPED_DIR" | grep -q "VagueWording"; then
fail "a directory argument produced no alert — flattening was silently skipped, the bug this test guards against"
elif echo "$BARE_DIR" | grep -q "VagueWording"; then
fail "bare vale already flags this fixture, so the test can't detect a silently-skipped flattening"
else
pass "a directory argument is walked and its files flattened"
fi
# --- 15. Directory walking must survive paths with spaces ---
echo ""
echo "--- walks a directory containing a path with spaces ---"
SPACED15="$FIXTURE14/plugins/testplugin/skills/zzz skill"
mkdir -p "$SPACED15"
cp "$FIXTURE14/plugins/testplugin/skills/zzzskill/SKILL.md" "$SPACED15/SKILL.md"
rm -rf "$FIXTURE14/plugins/testplugin/skills/zzzskill"
OUT15=$(run_wrap "$FIXTURE14" --config "$VALE_CONFIG" plugins/testplugin/skills)
if echo "$OUT15" | grep -q "zzz skill" && echo "$OUT15" | grep -q "VagueWording"; then
pass "a file under a directory whose name contains a space is walked and flattened"
else
fail "a path with a space was dropped from the directory walk"
fi
echo "" echo ""
echo "Results: $PASS passed, $FAIL failed" echo "Results: $PASS passed, $FAIL failed"
[[ $FAIL -eq 0 ]] [[ $FAIL -eq 0 ]]