fix(lint): make Vale prefilter portable via the plugin
skill-audit/agent-audit's Step 1 resolved vale-wrap.sh/.vale.ini via `git rev-parse --show-toplevel`, which returns whichever repo the skill happens to run in. Inside ai-development that works; in any external repo that installs kyberforge@holocron as a plugin, it resolves to that repo's own root, which has no .vale.ini — the prefilter silently fell back to full LLM judgment. ADR-0013 named this as a deliberately deferred gap. Vale's config/styles/wrapper now ship inside the plugin itself: a canonical copy in agent-audit/assets/vale/ (Kyberforge + KyberforgeCopilot, the superset agent-audit needs) and a smaller duplicate in skill-audit/assets/vale/ (Kyberforge only) — per the no-cross-skill-path rule already established for plugin cache-installs. Both skills resolve these relative to their own directory, same as scripts/validate.sh already does. A new root .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, independent of Claude Code entirely — the same mechanism covers CI. This repo's own pre-commit hook now consumes the identical plugin-bundled copies via repo: local (not a third root copy, and not a pinned self-reference, which would lint working-tree edits against the last tagged release instead of the change being made). Split into vale-audit-prefilter-skill/-agent hooks after confirming, by diffing the full corpus against both old and new config before deleting the old files, that one combined hook pointed at only one copy silently 0-file- skips the other file type. scripts/check-vale-style-sync.sh guards the two copies against drift, wired at pre-push alongside check-manifests. ADR: 0014
This commit is contained in:
@@ -34,14 +34,14 @@ metadata:
|
||||
```bash
|
||||
bash scripts/validate.sh <skill-dir>
|
||||
bash scripts/validate-provenance.sh <skill-dir>
|
||||
"$(git rev-parse --show-toplevel)/scripts/vale-wrap.sh" --config "$(git rev-parse --show-toplevel)/.vale.ini" <skill-dir>/SKILL.md
|
||||
scripts/vale-wrap.sh --config assets/vale/.vale.ini <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 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.
|
||||
|
||||
`vale-wrap.sh` resolves its own path and `.vale.ini` via `git rev-parse --show-toplevel`; pass the absolute paths shown above so the invocation holds from any cwd. It applies `.vale.ini`'s `Kyberforge` style — a deterministic prefilter for a subset of the Description/Patterns/Body dimensions below, not a replacement for Step 3. Every Vale alert is a `FAIL` — all rules are graded `error` — so report each one citing its rule ID (e.g. `Kyberforge.DescriptionOpener`). Skip and fall back to Step 3 judgment if vale or `.vale.ini` is unavailable. If Vale reports `0 files` scanned, treat the pass as NOT RUN — not as clean — and fall back to full Step 3 judgment for the dimensions it would have covered.
|
||||
`vale-wrap.sh` and `.vale.ini` ship inside this skill's own `scripts/`/`assets/` — resolve them relative to this skill's directory the same way `scripts/validate.sh` is resolved above, so the invocation works whether this skill is running from this repo or from an installed plugin cache. It applies `.vale.ini`'s `Kyberforge` style — a deterministic prefilter for a subset of the Description/Patterns/Body dimensions below, not a replacement for Step 3. Every Vale alert is a `FAIL` — all rules are graded `error` — so report each one citing its rule ID (e.g. `Kyberforge.DescriptionOpener`). Skip and fall back to Step 3 judgment if vale or `.vale.ini` is unavailable. If Vale reports `0 files` scanned, treat the pass as NOT RUN — not as clean — and fall back to full Step 3 judgment for the dimensions it would have covered.
|
||||
|
||||
## Step 2 — Read all skill files
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
StylesPath = styles
|
||||
|
||||
[**/SKILL.md]
|
||||
BasedOnStyles = Kyberforge
|
||||
@@ -0,0 +1,7 @@
|
||||
extends: existence
|
||||
message: "Description opens with '%s' — use an imperative 'Use when...' opener instead"
|
||||
level: error
|
||||
scope: text.frontmatter.description
|
||||
ignorecase: true
|
||||
raw:
|
||||
- '^This (skill|agent)\b'
|
||||
@@ -0,0 +1,7 @@
|
||||
extends: existence
|
||||
message: "Generic reference pointer: '%s' — use the specific 'If X, read `references/file.md`' form instead"
|
||||
level: error
|
||||
scope: text
|
||||
ignorecase: true
|
||||
raw:
|
||||
- 'see references?/? for (more )?(info|information|details)\b'
|
||||
@@ -0,0 +1,7 @@
|
||||
extends: existence
|
||||
message: "Don't start a sentence with '%s' — name the subject directly"
|
||||
level: error
|
||||
scope: sentence
|
||||
ignorecase: false
|
||||
raw:
|
||||
- '^There\s(is|are)\b'
|
||||
@@ -0,0 +1,10 @@
|
||||
extends: existence
|
||||
message: "Vague capability wording: '%s' — state the capability precisely instead"
|
||||
level: error
|
||||
scope: text.frontmatter.description
|
||||
ignorecase: true
|
||||
tokens:
|
||||
- helps with
|
||||
- utilize
|
||||
- assists with
|
||||
- used for
|
||||
177
plugins/kyberforge/skills/skill-audit/scripts/vale-wrap.sh
Executable file
177
plugins/kyberforge/skills/skill-audit/scripts/vale-wrap.sh
Executable file
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Works around a Vale limitation: the `text.frontmatter.description` NLP scope
|
||||
# silently stops matching once the `description:` value is a YAML block scalar
|
||||
# (`>`/`|`) spanning 2+ physical lines — the style used by most skills/agents in
|
||||
# this repo. Flattens the description to one physical line in a scratch copy
|
||||
# (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
|
||||
# `vale` directly: same args, same exit code.
|
||||
#
|
||||
# "Same args" means relative paths — `--config` values and file arguments alike
|
||||
# — resolve against the caller's current directory, exactly as bare `vale`
|
||||
# resolves them. (An earlier version resolved them against the repo root, an
|
||||
# invented convention that hard-errored on `--config ../../.vale.ini` from a
|
||||
# subdirectory and, worse, silently dropped file arguments that didn't happen to
|
||||
# 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
|
||||
# mirrors the caller's absolute cwd: a relative file argument is passed through
|
||||
# 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
|
||||
# keep that property. Only an absolute path outside the cwd is rewritten to its
|
||||
# scratch copy and so reports a scratch path — unavoidable, since a file can
|
||||
# only be read from where it actually is.
|
||||
|
||||
cwd="$(pwd -P)"
|
||||
|
||||
vale_args=()
|
||||
file_args=()
|
||||
config_next=false
|
||||
for arg in "$@"; do
|
||||
if [[ "$config_next" == true ]]; then
|
||||
config_next=false
|
||||
if [[ "$arg" == /* ]]; then
|
||||
vale_args+=("$arg")
|
||||
else
|
||||
vale_args+=("$cwd/$arg")
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
case "$arg" in
|
||||
--config)
|
||||
vale_args+=("$arg")
|
||||
config_next=true
|
||||
continue
|
||||
;;
|
||||
--config=/*)
|
||||
vale_args+=("$arg")
|
||||
continue
|
||||
;;
|
||||
--config=*)
|
||||
vale_args+=("--config=$cwd/${arg#--config=}")
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
# `-f` resolves relative paths against the caller's cwd, same as vale does.
|
||||
if [[ "$arg" != -* && -f "$arg" ]]; then
|
||||
# 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
|
||||
# rewritten to its scratch copy and printed as `/tmp/tmp.XXXX/...` — a real
|
||||
# path to a file that is deleted on exit, which reads as a bug in any report
|
||||
# quoting it. Absolute paths outside the cwd have no relative form and keep
|
||||
# the scratch-path behaviour documented above.
|
||||
if [[ "$arg" == "$cwd"/* ]]; then
|
||||
file_args+=("${arg#"$cwd"/}")
|
||||
else
|
||||
file_args+=("$arg")
|
||||
fi
|
||||
else
|
||||
vale_args+=("$arg")
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ${#file_args[@]} -eq 0 ]]; then
|
||||
# Nothing to flatten. Hand off directly, with stdin closed so vale doesn't
|
||||
# block waiting on a pipe that will never carry content.
|
||||
exec vale "${vale_args[@]}" < /dev/null
|
||||
fi
|
||||
|
||||
tmpdir="$(realpath -m "$(mktemp -d)")"
|
||||
trap 'rm -rf "$tmpdir"' EXIT
|
||||
|
||||
# Mirror of the caller's cwd inside the scratch tree; relative file arguments
|
||||
# are resolved from here.
|
||||
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 sys
|
||||
|
||||
src, dest = sys.argv[1], sys.argv[2]
|
||||
with open(src) as fh:
|
||||
content = fh.read()
|
||||
|
||||
fm_match = re.match(r'^(---\n)(.*?\n)(---\n)', content, re.DOTALL)
|
||||
if fm_match:
|
||||
fm = fm_match.group(2)
|
||||
# Only `>`/`>-`/`>+` (folded) scalars break Vale's frontmatter-description
|
||||
# scope. `|`/`|-`/`|+` (literal) scalars already work fine with bare vale,
|
||||
# so they're deliberately left unmatched here.
|
||||
header_m = re.search(r'^description:[ \t]*(>[+-]?)[ \t]*\n', fm, re.MULTILINE)
|
||||
if header_m:
|
||||
# Body capture is indentation-based and blank-line-tolerant, per YAML
|
||||
# block-scalar rules: a blank line (any amount of whitespace) always
|
||||
# stays inside the block; the indent is set by the first content line;
|
||||
# the block ends at the first line indented less than that, or EOF.
|
||||
rest = fm[header_m.end():]
|
||||
indent = None
|
||||
body_lines = []
|
||||
for line in rest.splitlines(keepends=True):
|
||||
text = line.rstrip('\n')
|
||||
if text.strip() == '':
|
||||
body_lines.append(line)
|
||||
continue
|
||||
line_indent = len(text) - len(text.lstrip(' \t'))
|
||||
if indent is None:
|
||||
indent = line_indent
|
||||
elif line_indent < indent:
|
||||
break
|
||||
body_lines.append(line)
|
||||
raw = ''.join(body_lines)
|
||||
if raw.count('\n') >= 2:
|
||||
flat = re.sub(r'\s+', ' ', raw).strip()
|
||||
# YAML single-quoted scalars have no backslash-escape mechanism at
|
||||
# all, so wrapping in single quotes sidesteps the backslash-escape
|
||||
# bug entirely for embedded double quotes, backslashes, and
|
||||
# non-ASCII text. The one YAML-spec-correct way to embed a literal
|
||||
# apostrophe is to double it ('') — but Vale's own frontmatter
|
||||
# scanner isn't a full YAML parser and doesn't understand that
|
||||
# doubling: empirically, it silently truncates the value at the
|
||||
# first ' it sees, hiding everything after it from the NLP scope
|
||||
# (a different flavor of the same bug this whole script exists to
|
||||
# work around). Since this copy is scratch-only and never written
|
||||
# back, sidestep it by substituting a Unicode right single
|
||||
# quotation mark (U+2019) for any literal apostrophe instead of
|
||||
# doubling it — visually a smart quote, but never triggers a YAML
|
||||
# escape sequence at all.
|
||||
flat_q = "'" + flat.replace("'", "’") + "'"
|
||||
pad = '\n' * raw.count('\n')
|
||||
start = header_m.start()
|
||||
end = header_m.end() + len(raw)
|
||||
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():]
|
||||
|
||||
with open(dest, 'w') as fh:
|
||||
fh.write(content)
|
||||
PYTHON
|
||||
if [[ "$arg" == /* ]]; then
|
||||
argv_files+=("$dest")
|
||||
else
|
||||
argv_files+=("$arg")
|
||||
fi
|
||||
done
|
||||
|
||||
cd "$mirror"
|
||||
vale "${vale_args[@]}" "${argv_files[@]}"
|
||||
Reference in New Issue
Block a user