fix(lint): flatten every multi-line description form in vale-wrap.sh

Vale locates a frontmatter description by matching the parsed YAML value back
against the source text, so any scalar whose value is not spelled out verbatim
loses the `text.frontmatter.description` scope entirely. The wrapper only
flattened `>` folded scalars, so plain, double-quoted and single-quoted
multi-line descriptions silently reported zero alerts and exit 0 — a clean pass
indistinguishable from a real one, in a prefilter whose callers are instructed
not to re-derive its verdict by judgment.

Implementation notes:

- Classify the scalar kind after `^description:[ \t]*` and reuse one shared
  continuation-line generator for every form; `|` literal blocks keep their
  line breaks, stay verbatim-matchable, and are still left untouched.
- Emit the flattened value in whichever scalar form needs no escape at all
  (plain, then single-quoted, then double-quoted), because any escape breaks
  the verbatim match. The old blanket `'` -> U+2019 substitution silently made
  apostrophe-bearing rule tokens unmatchable across 63% of the corpus; it now
  survives only for the one combination no YAML scalar can carry verbatim.
- Terminate continuations at a line flush with the key, not only on a shallower
  indent — a `description:` followed by a flush-left line previously swallowed
  the rest of the frontmatter.
- Route vale's value-taking flags explicitly instead of inferring targets by
  file existence, and absolutize relative `--output`/`--path` values the way
  `--config` already was, since the run `cd`s into the scratch mirror.
- Fail loudly on a nonexistent path instead of inheriting bare vale's fallback
  to stdin, which rendered a typo'd path as `0 errors ... in stdin`, exit 0 —
  a form the callers' `0 files` NOT-RUN guard cannot match.
- Follow symlinks when walking a directory argument, matching bare vale.

Refs: #85
This commit is contained in:
2026-08-09 15:44:04 +00:00
parent afc2b7fdfd
commit aa8cc22695
3 changed files with 934 additions and 196 deletions

View File

@@ -2,27 +2,45 @@
set -euo pipefail set -euo pipefail
# Works around a Vale limitation: the `text.frontmatter.description` NLP scope # Works around a Vale limitation: the `text.frontmatter.description` NLP scope
# silently stops matching once the `description:` value is a YAML block scalar # silently stops matching once the `description:` value spans 2+ physical lines
# (`>`/`|`) spanning 2+ physical lines — the style used by most skills/agents in # in any form YAML joins back into one string — a `>`/`>-`/`>+` folded block
# this repo. Flattens the description to one physical line in a scratch copy # scalar (the style used by most skills/agents in this repo), a plain scalar
# (padding with blank lines so every other line number is unchanged), then runs # wrapped onto continuation lines, or a double- or single-quoted scalar wrapped
# the real `vale` binary against the copies. Drop-in replacement for calling # the same way. A `|`/`|-`/`|+` literal block scalar is NOT affected: its parsed
# `vale` directly: same args, same exit code. # 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.
# This script flattens an affected 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, bar the two documented divergences
# below.
# #
# "Same args" means relative paths — `--config` values and path arguments alike # "Same args" means relative paths — path arguments and the values of the
# — resolve against the caller's current directory, exactly as bare `vale` # path-valued flags (`--config`, `--output`, `--path`) alike — resolve against
# resolves them. (An earlier version resolved them against the repo root, an # the caller's current directory, exactly as bare `vale` resolves them. The flag
# invented convention that hard-errored on `--config ../../.vale.ini` from a # values are rewritten to absolute form because the run ends up `cd`'d into the
# subdirectory and, worse, silently dropped file arguments that didn't happen to # scratch mirror, where a relative one would no longer resolve. (An earlier
# resolve from the repo root — skipping the flattening this script exists for.) # version resolved path arguments 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.)
# #
# The one addition to bare `vale`'s argument handling: with no `--config` at # Divergence 1: with no `--config` at all, this script's own sibling
# all, this script's own sibling `assets/vale/.vale.ini` is used instead of # `assets/vale/.vale.ini` is used instead of vale's upward search. pre-commit
# vale's upward search. pre-commit prefixes only `entry[0]` with the hook-repo # prefixes only `entry[0]` with the hook-repo clone path, so a `--config` in
# clone path, so a `--config` in `.pre-commit-hooks.yaml` would resolve against # `.pre-commit-hooks.yaml` would resolve against the *consuming* repo and
# the *consuming* repo and hard-fail (E100) for every external consumer. The # hard-fail (E100) for every external consumer. The manifest therefore passes the
# manifest therefore passes the script alone, and an explicit `--config` from # script alone, and an explicit `--config` from any other caller still wins.
# any other caller still wins. #
# Divergence 2: a path-shaped argument that does not exist is a hard error
# (exit 2). Bare vale drops it, falls back to reading stdin, and prints
# `0 errors ... in stdin` with exit 0 — a typo'd target is then indistinguishable
# from a clean run. Both audit skills treat a `0 files` report as NOT RUN rather
# than clean, and `in stdin` does not match that guard, so the silent form would
# read as "prefilter clean" and skip the LLM fallback. Erroring is the only way
# to keep that guard honest. Linting prose piped on stdin is therefore
# unsupported here — it already was, since the no-path handoff closes stdin so
# vale can't block on a pipe that will never carry content.
# #
# Vale prints each path exactly as it was handed to it, so the scratch tree # 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 # mirrors the caller's absolute cwd: a relative path argument is passed through
@@ -41,22 +59,44 @@ cwd="$(pwd -P)"
# later edit breaking that invariant, not a live fix. # later edit breaking that invariant, not a live fix.
vale_args=() vale_args=()
path_args=() path_args=()
config_next=false pending_flag=""
config_given=false config_given=false
for arg in "$@"; do for arg in "$@"; do
if [[ "$config_next" == true ]]; then if [[ -n "$pending_flag" ]]; then
config_next=false # Value of a separated two-argv flag. It is never a lint target, however
if [[ "$arg" == /* ]]; then # file-like it looks. The run ends up `cd`'d into the scratch mirror, so a
vale_args+=("$arg") # value naming a file has to be absolutized here or it stops resolving.
else case "$pending_flag" in
vale_args+=("$cwd/$arg") --config)
fi # Always a path, and required to exist.
if [[ "$arg" == /* ]]; then
vale_args+=("$arg")
else
vale_args+=("$cwd/$arg")
fi
;;
--output|--path)
# `--output` is either a built-in style name (`line`, `JSON`) or a
# template file; only the file form needs resolving. `--path` is
# likewise a path. Anything that names nothing is passed through and
# left for vale to interpret.
if [[ "$arg" != /* && -e "$arg" ]]; then
vale_args+=("$cwd/$arg")
else
vale_args+=("$arg")
fi
;;
*)
vale_args+=("$arg")
;;
esac
pending_flag=""
continue continue
fi fi
case "$arg" in case "$arg" in
--config) --config)
vale_args+=("$arg") vale_args+=("$arg")
config_next=true pending_flag="$arg"
config_given=true config_given=true
continue continue
;; ;;
@@ -70,24 +110,60 @@ for arg in "$@"; do
config_given=true config_given=true
continue continue
;; ;;
# Same cwd-relative resolution for the `--flag=value` spelling of the two
# other path-valued flags.
--output=*|--path=*)
flag_val="${arg#*=}"
if [[ "$flag_val" != /* && -n "$flag_val" && -e "$flag_val" ]]; then
vale_args+=("${arg%%=*}=$cwd/$flag_val")
else
vale_args+=("$arg")
fi
continue
;;
# Vale's remaining value-taking flags, per `vale --help` (3.x). In the
# separated two-argv form the value must not be classified as a lint target
# — `--output tmpl.tmpl` names a real template file, and treating it as
# input both lints the template and reorders argv so vale sees
# `--output --no-wrap`. The `--flag=value` form needs no entry here: it
# starts with `-` and falls through to vale untouched. A value flag added by
# some future vale release is simply absent from this list and lands back on
# today's behaviour, so this list going stale is never worse than not having
# it.
--ext|--filter|--glob|--minAlertLevel|--output|--path)
vale_args+=("$arg")
pending_flag="$arg"
continue
;;
# Vale's subcommands are bare words that name no file, so they would trip
# the not-found error below. A lint target literally named `sync` (no
# extension, no slash) is misread as the subcommand — accepted, because the
# alternative is failing every `vale-wrap.sh ls-config`.
ls-config|ls-dirs|ls-metrics|ls-vars|sync)
vale_args+=("$arg")
continue
;;
esac esac
# `-f`/`-d` resolve relative paths against the caller's cwd, same as vale does. if [[ "$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
# 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
path_args+=("${arg#"$cwd"/}")
else
path_args+=("$arg")
fi
else
vale_args+=("$arg") vale_args+=("$arg")
continue
fi
# Everything left is a lint target: `vale [options] [input...]` has no third
# kind of argument. See divergence 2 above for why a missing one is fatal here.
if [[ ! -e "$arg" ]]; then
echo "vale-wrap.sh: no such file or directory: $arg" >&2
exit 2
fi
# 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
path_args+=("${arg#"$cwd"/}")
else
path_args+=("$arg")
fi fi
done done
@@ -119,55 +195,210 @@ src, dest = sys.argv[1], sys.argv[2]
with open(src, encoding='utf-8', errors='surrogateescape') as fh: with open(src, encoding='utf-8', errors='surrogateescape') as fh:
content = fh.read() content = fh.read()
# YAML 1.2 double-quoted escapes (spec 5.7 / 7.3.1). `\<newline>` is handled
# separately in unescape_double because it also swallows the next indentation.
DQ_ESCAPES = {
'0': '\0', 'a': '\a', 'b': '\b', 't': '\t', '\t': '\t', 'n': '\n',
'v': '\v', 'f': '\f', 'r': '\r', 'e': '\x1b', ' ': ' ', '"': '"',
'/': '/', '\\': '\\', 'N': '\x85', '_': '\xa0', 'L': '\u2028',
'P': '\u2029',
}
# First characters that make a plain (unquoted) scalar mean something other than
# text: YAML's c-indicator set.
PLAIN_UNSAFE_FIRST = '-?:,[]{}#&*!|>\'"%@`'
def unescape_double(text):
"""Decode a double-quoted YAML scalar's body to the string YAML parses."""
out = []
i = 0
while i < len(text):
char = text[i]
if char != '\\':
out.append(char)
i += 1
continue
i += 1
if i >= len(text):
break
esc = text[i]
if esc == '\n':
i += 1
while i < len(text) and text[i] in ' \t':
i += 1
continue
if esc in 'xuU':
width = {'x': 2, 'u': 4, 'U': 8}[esc]
digits = text[i + 1:i + 1 + width]
if len(digits) == width:
try:
out.append(chr(int(digits, 16)))
except ValueError:
pass
else:
i += 1 + width
continue
out.append(DQ_ESCAPES.get(esc, esc))
i += 1
return ''.join(out)
def close_quote(text, quote):
"""Index of the closing `quote` in `text`, which starts just past the
opening one. None while the scalar is still unterminated."""
i = 0
while i < len(text):
char = text[i]
if quote == '"' and char == '\\':
i += 2
continue
if char == quote:
if quote == "'" and text[i + 1:i + 2] == "'":
i += 2
continue
return i
i += 1
return None
def continuation_lines(rest):
"""Yield the physical lines of `rest` that continue the value started on the
`description:` line. Indentation-based and blank-line-tolerant, per YAML:
a blank line (any amount of whitespace) always stays inside; the indent is
set by the first content line; the value ends at the first line indented
less than that, at any line flush with the key (that is the next mapping
key, not a continuation), or at EOF."""
indent = None
for line in rest.splitlines(keepends=True):
text = line.rstrip('\n')
if text.strip() == '':
yield line
continue
line_indent = len(text) - len(text.lstrip(' \t'))
if line_indent == 0:
return
if indent is None:
indent = line_indent
elif line_indent < indent:
return
yield line
def emit(value):
"""Render `value` as a one-line YAML scalar whose source text spells the
value out verbatim. Vale locates the description by matching the parsed
value back against the source, so a scalar carrying any escape — `''` in a
single-quoted scalar, `\\"` or `\\\\` in a double-quoted one — makes the
whole `text.frontmatter.description` scope vanish, the same failure this
script exists to work around. Verbatim forms only, therefore, tried in
descending order of fidelity."""
if (value
and value[0] not in PLAIN_UNSAFE_FIRST
and ': ' not in value
and not value.endswith(':')
and ' #' not in value):
return value # plain: nothing needs escaping at all
if "'" not in value:
return "'" + value + "'" # single-quoted: only `'` would escape
if '"' not in value and '\\' not in value:
return '"' + value + '"' # double-quoted: only `"`/`\` would
# 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
# U+2019 for the apostrophe keeps the scope alive, at the cost of any style
# rule whose token contains a literal ASCII apostrophe. Scratch-copy only —
# never written back to the real file.
return "'" + value.replace("'", '’') + "'"
fm_match = re.match(r'^(---\n)(.*?\n)(---\n)', content, re.DOTALL) fm_match = re.match(r'^(---\n)(.*?\n)(---\n)', content, re.DOTALL)
if fm_match: if fm_match:
fm = fm_match.group(2) fm = fm_match.group(2)
# Only `>`/`>-`/`>+` (folded) scalars break Vale's frontmatter-description header_m = re.search(r'^description:[ \t]*', fm, re.MULTILINE)
# scope. `|`/`|-`/`|+` (literal) scalars already work fine with bare vale, else:
# so they're deliberately left unmatched here. header_m = None
header_m = re.search(r'^description:[ \t]*(>[+-]?)[ \t]*\n', fm, re.MULTILINE)
if header_m: if header_m:
# Body capture is indentation-based and blank-line-tolerant, per YAML head_start = header_m.start()
# block-scalar rules: a blank line (any amount of whitespace) always value_start = header_m.end()
# stays inside the block; the indent is set by the first content line; header_end = fm.find('\n', value_start)
# the block ends at the first line indented less than that, or EOF. header_end = len(fm) if header_end == -1 else header_end
rest = fm[header_m.end():] first = fm[value_start:header_end]
indent = None body_start = header_end + 1
body_lines = [] indicator = first.rstrip()
for line in rest.splitlines(keepends=True):
text = line.rstrip('\n') block_m = re.fullmatch(r'([|>])([+-]?[0-9]*|[0-9]*[+-]?)', indicator)
if text.strip() == '': if block_m and block_m.group(1) == '|':
body_lines.append(line) kind = None # literal blocks keep their line breaks; vale is fine
continue elif block_m:
line_indent = len(text) - len(text.lstrip(' \t')) kind = 'block' # folded (`>`): the value starts on the next line
if indent is None: elif indicator == '':
indent = line_indent kind = 'block' # bare `description:`: a plain scalar on later lines
elif line_indent < indent: elif first[:1] == '"':
kind = 'double'
elif first[:1] == "'":
kind = 'single'
elif first[:1] in '#&*!':
kind = None # comment, anchor, alias or tag — not a plain scalar
else:
kind = 'plain'
text = ''
value_end = value_start
value_lines = 0
if kind in ('block', 'plain'):
body = ''.join(continuation_lines(fm[body_start:]))
value_end = body_start + len(body)
if kind == 'block':
text = body
value_lines = body.count('\n')
else:
text = fm[value_start:value_end]
value_lines = 1 + body.count('\n')
if ' #' in text or text.lstrip().startswith('#'):
# A `#` opens a comment inside a plain scalar. Folding it in
# would lint text YAML never treats as part of the value, so
# leave the file alone rather than lint the wrong string.
kind = None
elif kind in ('double', 'single'):
quote = '"' if kind == 'double' else "'"
inner_start = value_start + 1
acc = fm[inner_start:body_start]
idx = close_quote(acc, quote)
lines = continuation_lines(fm[body_start:])
while idx is None:
try:
acc += next(lines)
except StopIteration:
break break
body_lines.append(line) idx = close_quote(acc, quote)
raw = ''.join(body_lines) if idx is None:
if raw.count('\n') >= 2: kind = None # unterminated quote: invalid YAML, leave it to vale
flat = re.sub(r'\s+', ' ', raw).strip() else:
# YAML single-quoted scalars have no backslash-escape mechanism at inner = acc[:idx]
# all, so wrapping in single quotes sidesteps the backslash-escape value_end = inner_start + idx + 1
# bug entirely for embedded double quotes, backslashes, and text = unescape_double(inner) if quote == '"' else inner.replace("''", "'")
# non-ASCII text. The one YAML-spec-correct way to embed a literal value_lines = 1 + inner.count('\n')
# apostrophe is to double it ('') — but Vale's own frontmatter
# scanner isn't a full YAML parser and doesn't understand that flat = re.sub(r'\s+', ' ', text).strip()
# doubling: empirically, it silently truncates the value at the if kind and flat and value_lines >= 2:
# first ' it sees, hiding everything after it from the NLP scope # `value_end` can land mid-line, just past a closing quote, so extend to
# (a different flavor of the same bug this whole script exists to # the end of that physical line and carry whatever follows (a trailing
# work around). Since this copy is scratch-only and never written # comment) across unchanged.
# back, sidestep it by substituting a Unicode right single if value_end > 0 and fm[value_end - 1] == '\n':
# quotation mark (U+2019) for any literal apostrophe instead of span_end = value_end
# doubling it — visually a smart quote, but never triggers a YAML trailer = ''
# escape sequence at all. else:
flat_q = "'" + flat.replace("'", "’") + "'" newline = fm.find('\n', value_end)
pad = '\n' * raw.count('\n') span_end = len(fm) if newline == -1 else newline + 1
start = header_m.start() trailer = fm[value_end:span_end].rstrip('\n')
end = header_m.end() + len(raw) # One line replaces the span, so the blank-line pad is one short of the
new_fm = fm[:start] + f'description: {flat_q}\n{pad}' + fm[end:] # newline count it displaced — every later line number is unchanged.
content = fm_match.group(1) + new_fm + fm_match.group(3) + content[fm_match.end():] pad = '\n' * (fm[head_start:span_end].count('\n') - 1)
new_fm = (fm[:head_start] + 'description: ' + emit(flat) + trailer
+ '\n' + pad + fm[span_end:])
content = (fm_match.group(1) + new_fm + fm_match.group(3)
+ content[fm_match.end():])
with open(dest, 'w', encoding='utf-8', errors='surrogateescape') as fh: with open(dest, 'w', encoding='utf-8', errors='surrogateescape') as fh:
fh.write(content) fh.write(content)
@@ -206,11 +437,16 @@ for arg in ${path_args[@]+"${path_args[@]}"}; do
# the tree, so any file dropped here would be silently unlinted — and then # 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: # 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. # vale never lints it and copying it can dwarf the rest of the tree.
# `find -L` follows symlinks because vale does: it lints both a symlinked
# file and a file under a symlinked directory, and a bare `-type f` walk
# would report "0 files" where bare vale reports one. (A symlink loop makes
# `find` warn on stderr and carry on, which is also what vale does.) The
# second walk needs no `-L`: the mirror is all real files by construction.
mkdir -p "$dest" mkdir -p "$dest"
while IFS= read -r -d '' rel; do while IFS= read -r -d '' rel; do
mkdir -p "$dest/$(dirname "$rel")" mkdir -p "$dest/$(dirname "$rel")"
cp "$arg/$rel" "$dest/$rel" cp "$arg/$rel" "$dest/$rel"
done < <(cd "$arg" && find . -name .git -prune -o -type f -print0) done < <(cd "$arg" && find -L . -name .git -prune -o -type f -print0)
while IFS= read -r -d '' md; do while IFS= read -r -d '' md; do
flatten "$md" "$md" flatten "$md" "$md"
done < <(find "$dest" -type f -name '*.md' -print0) done < <(find "$dest" -type f -name '*.md' -print0)

View File

@@ -2,27 +2,45 @@
set -euo pipefail set -euo pipefail
# Works around a Vale limitation: the `text.frontmatter.description` NLP scope # Works around a Vale limitation: the `text.frontmatter.description` NLP scope
# silently stops matching once the `description:` value is a YAML block scalar # silently stops matching once the `description:` value spans 2+ physical lines
# (`>`/`|`) spanning 2+ physical lines — the style used by most skills/agents in # in any form YAML joins back into one string — a `>`/`>-`/`>+` folded block
# this repo. Flattens the description to one physical line in a scratch copy # scalar (the style used by most skills/agents in this repo), a plain scalar
# (padding with blank lines so every other line number is unchanged), then runs # wrapped onto continuation lines, or a double- or single-quoted scalar wrapped
# the real `vale` binary against the copies. Drop-in replacement for calling # the same way. A `|`/`|-`/`|+` literal block scalar is NOT affected: its parsed
# `vale` directly: same args, same exit code. # 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.
# This script flattens an affected 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, bar the two documented divergences
# below.
# #
# "Same args" means relative paths — `--config` values and path arguments alike # "Same args" means relative paths — path arguments and the values of the
# — resolve against the caller's current directory, exactly as bare `vale` # path-valued flags (`--config`, `--output`, `--path`) alike — resolve against
# resolves them. (An earlier version resolved them against the repo root, an # the caller's current directory, exactly as bare `vale` resolves them. The flag
# invented convention that hard-errored on `--config ../../.vale.ini` from a # values are rewritten to absolute form because the run ends up `cd`'d into the
# subdirectory and, worse, silently dropped file arguments that didn't happen to # scratch mirror, where a relative one would no longer resolve. (An earlier
# resolve from the repo root — skipping the flattening this script exists for.) # version resolved path arguments 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.)
# #
# The one addition to bare `vale`'s argument handling: with no `--config` at # Divergence 1: with no `--config` at all, this script's own sibling
# all, this script's own sibling `assets/vale/.vale.ini` is used instead of # `assets/vale/.vale.ini` is used instead of vale's upward search. pre-commit
# vale's upward search. pre-commit prefixes only `entry[0]` with the hook-repo # prefixes only `entry[0]` with the hook-repo clone path, so a `--config` in
# clone path, so a `--config` in `.pre-commit-hooks.yaml` would resolve against # `.pre-commit-hooks.yaml` would resolve against the *consuming* repo and
# the *consuming* repo and hard-fail (E100) for every external consumer. The # hard-fail (E100) for every external consumer. The manifest therefore passes the
# manifest therefore passes the script alone, and an explicit `--config` from # script alone, and an explicit `--config` from any other caller still wins.
# any other caller still wins. #
# Divergence 2: a path-shaped argument that does not exist is a hard error
# (exit 2). Bare vale drops it, falls back to reading stdin, and prints
# `0 errors ... in stdin` with exit 0 — a typo'd target is then indistinguishable
# from a clean run. Both audit skills treat a `0 files` report as NOT RUN rather
# than clean, and `in stdin` does not match that guard, so the silent form would
# read as "prefilter clean" and skip the LLM fallback. Erroring is the only way
# to keep that guard honest. Linting prose piped on stdin is therefore
# unsupported here — it already was, since the no-path handoff closes stdin so
# vale can't block on a pipe that will never carry content.
# #
# Vale prints each path exactly as it was handed to it, so the scratch tree # 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 # mirrors the caller's absolute cwd: a relative path argument is passed through
@@ -41,22 +59,44 @@ cwd="$(pwd -P)"
# later edit breaking that invariant, not a live fix. # later edit breaking that invariant, not a live fix.
vale_args=() vale_args=()
path_args=() path_args=()
config_next=false pending_flag=""
config_given=false config_given=false
for arg in "$@"; do for arg in "$@"; do
if [[ "$config_next" == true ]]; then if [[ -n "$pending_flag" ]]; then
config_next=false # Value of a separated two-argv flag. It is never a lint target, however
if [[ "$arg" == /* ]]; then # file-like it looks. The run ends up `cd`'d into the scratch mirror, so a
vale_args+=("$arg") # value naming a file has to be absolutized here or it stops resolving.
else case "$pending_flag" in
vale_args+=("$cwd/$arg") --config)
fi # Always a path, and required to exist.
if [[ "$arg" == /* ]]; then
vale_args+=("$arg")
else
vale_args+=("$cwd/$arg")
fi
;;
--output|--path)
# `--output` is either a built-in style name (`line`, `JSON`) or a
# template file; only the file form needs resolving. `--path` is
# likewise a path. Anything that names nothing is passed through and
# left for vale to interpret.
if [[ "$arg" != /* && -e "$arg" ]]; then
vale_args+=("$cwd/$arg")
else
vale_args+=("$arg")
fi
;;
*)
vale_args+=("$arg")
;;
esac
pending_flag=""
continue continue
fi fi
case "$arg" in case "$arg" in
--config) --config)
vale_args+=("$arg") vale_args+=("$arg")
config_next=true pending_flag="$arg"
config_given=true config_given=true
continue continue
;; ;;
@@ -70,24 +110,60 @@ for arg in "$@"; do
config_given=true config_given=true
continue continue
;; ;;
# Same cwd-relative resolution for the `--flag=value` spelling of the two
# other path-valued flags.
--output=*|--path=*)
flag_val="${arg#*=}"
if [[ "$flag_val" != /* && -n "$flag_val" && -e "$flag_val" ]]; then
vale_args+=("${arg%%=*}=$cwd/$flag_val")
else
vale_args+=("$arg")
fi
continue
;;
# Vale's remaining value-taking flags, per `vale --help` (3.x). In the
# separated two-argv form the value must not be classified as a lint target
# — `--output tmpl.tmpl` names a real template file, and treating it as
# input both lints the template and reorders argv so vale sees
# `--output --no-wrap`. The `--flag=value` form needs no entry here: it
# starts with `-` and falls through to vale untouched. A value flag added by
# some future vale release is simply absent from this list and lands back on
# today's behaviour, so this list going stale is never worse than not having
# it.
--ext|--filter|--glob|--minAlertLevel|--output|--path)
vale_args+=("$arg")
pending_flag="$arg"
continue
;;
# Vale's subcommands are bare words that name no file, so they would trip
# the not-found error below. A lint target literally named `sync` (no
# extension, no slash) is misread as the subcommand — accepted, because the
# alternative is failing every `vale-wrap.sh ls-config`.
ls-config|ls-dirs|ls-metrics|ls-vars|sync)
vale_args+=("$arg")
continue
;;
esac esac
# `-f`/`-d` resolve relative paths against the caller's cwd, same as vale does. if [[ "$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
# 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
path_args+=("${arg#"$cwd"/}")
else
path_args+=("$arg")
fi
else
vale_args+=("$arg") vale_args+=("$arg")
continue
fi
# Everything left is a lint target: `vale [options] [input...]` has no third
# kind of argument. See divergence 2 above for why a missing one is fatal here.
if [[ ! -e "$arg" ]]; then
echo "vale-wrap.sh: no such file or directory: $arg" >&2
exit 2
fi
# 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
path_args+=("${arg#"$cwd"/}")
else
path_args+=("$arg")
fi fi
done done
@@ -119,55 +195,210 @@ src, dest = sys.argv[1], sys.argv[2]
with open(src, encoding='utf-8', errors='surrogateescape') as fh: with open(src, encoding='utf-8', errors='surrogateescape') as fh:
content = fh.read() content = fh.read()
# YAML 1.2 double-quoted escapes (spec 5.7 / 7.3.1). `\<newline>` is handled
# separately in unescape_double because it also swallows the next indentation.
DQ_ESCAPES = {
'0': '\0', 'a': '\a', 'b': '\b', 't': '\t', '\t': '\t', 'n': '\n',
'v': '\v', 'f': '\f', 'r': '\r', 'e': '\x1b', ' ': ' ', '"': '"',
'/': '/', '\\': '\\', 'N': '\x85', '_': '\xa0', 'L': '\u2028',
'P': '\u2029',
}
# First characters that make a plain (unquoted) scalar mean something other than
# text: YAML's c-indicator set.
PLAIN_UNSAFE_FIRST = '-?:,[]{}#&*!|>\'"%@`'
def unescape_double(text):
"""Decode a double-quoted YAML scalar's body to the string YAML parses."""
out = []
i = 0
while i < len(text):
char = text[i]
if char != '\\':
out.append(char)
i += 1
continue
i += 1
if i >= len(text):
break
esc = text[i]
if esc == '\n':
i += 1
while i < len(text) and text[i] in ' \t':
i += 1
continue
if esc in 'xuU':
width = {'x': 2, 'u': 4, 'U': 8}[esc]
digits = text[i + 1:i + 1 + width]
if len(digits) == width:
try:
out.append(chr(int(digits, 16)))
except ValueError:
pass
else:
i += 1 + width
continue
out.append(DQ_ESCAPES.get(esc, esc))
i += 1
return ''.join(out)
def close_quote(text, quote):
"""Index of the closing `quote` in `text`, which starts just past the
opening one. None while the scalar is still unterminated."""
i = 0
while i < len(text):
char = text[i]
if quote == '"' and char == '\\':
i += 2
continue
if char == quote:
if quote == "'" and text[i + 1:i + 2] == "'":
i += 2
continue
return i
i += 1
return None
def continuation_lines(rest):
"""Yield the physical lines of `rest` that continue the value started on the
`description:` line. Indentation-based and blank-line-tolerant, per YAML:
a blank line (any amount of whitespace) always stays inside; the indent is
set by the first content line; the value ends at the first line indented
less than that, at any line flush with the key (that is the next mapping
key, not a continuation), or at EOF."""
indent = None
for line in rest.splitlines(keepends=True):
text = line.rstrip('\n')
if text.strip() == '':
yield line
continue
line_indent = len(text) - len(text.lstrip(' \t'))
if line_indent == 0:
return
if indent is None:
indent = line_indent
elif line_indent < indent:
return
yield line
def emit(value):
"""Render `value` as a one-line YAML scalar whose source text spells the
value out verbatim. Vale locates the description by matching the parsed
value back against the source, so a scalar carrying any escape — `''` in a
single-quoted scalar, `\\"` or `\\\\` in a double-quoted one — makes the
whole `text.frontmatter.description` scope vanish, the same failure this
script exists to work around. Verbatim forms only, therefore, tried in
descending order of fidelity."""
if (value
and value[0] not in PLAIN_UNSAFE_FIRST
and ': ' not in value
and not value.endswith(':')
and ' #' not in value):
return value # plain: nothing needs escaping at all
if "'" not in value:
return "'" + value + "'" # single-quoted: only `'` would escape
if '"' not in value and '\\' not in value:
return '"' + value + '"' # double-quoted: only `"`/`\` would
# 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
# U+2019 for the apostrophe keeps the scope alive, at the cost of any style
# rule whose token contains a literal ASCII apostrophe. Scratch-copy only —
# never written back to the real file.
return "'" + value.replace("'", '’') + "'"
fm_match = re.match(r'^(---\n)(.*?\n)(---\n)', content, re.DOTALL) fm_match = re.match(r'^(---\n)(.*?\n)(---\n)', content, re.DOTALL)
if fm_match: if fm_match:
fm = fm_match.group(2) fm = fm_match.group(2)
# Only `>`/`>-`/`>+` (folded) scalars break Vale's frontmatter-description header_m = re.search(r'^description:[ \t]*', fm, re.MULTILINE)
# scope. `|`/`|-`/`|+` (literal) scalars already work fine with bare vale, else:
# so they're deliberately left unmatched here. header_m = None
header_m = re.search(r'^description:[ \t]*(>[+-]?)[ \t]*\n', fm, re.MULTILINE)
if header_m: if header_m:
# Body capture is indentation-based and blank-line-tolerant, per YAML head_start = header_m.start()
# block-scalar rules: a blank line (any amount of whitespace) always value_start = header_m.end()
# stays inside the block; the indent is set by the first content line; header_end = fm.find('\n', value_start)
# the block ends at the first line indented less than that, or EOF. header_end = len(fm) if header_end == -1 else header_end
rest = fm[header_m.end():] first = fm[value_start:header_end]
indent = None body_start = header_end + 1
body_lines = [] indicator = first.rstrip()
for line in rest.splitlines(keepends=True):
text = line.rstrip('\n') block_m = re.fullmatch(r'([|>])([+-]?[0-9]*|[0-9]*[+-]?)', indicator)
if text.strip() == '': if block_m and block_m.group(1) == '|':
body_lines.append(line) kind = None # literal blocks keep their line breaks; vale is fine
continue elif block_m:
line_indent = len(text) - len(text.lstrip(' \t')) kind = 'block' # folded (`>`): the value starts on the next line
if indent is None: elif indicator == '':
indent = line_indent kind = 'block' # bare `description:`: a plain scalar on later lines
elif line_indent < indent: elif first[:1] == '"':
kind = 'double'
elif first[:1] == "'":
kind = 'single'
elif first[:1] in '#&*!':
kind = None # comment, anchor, alias or tag — not a plain scalar
else:
kind = 'plain'
text = ''
value_end = value_start
value_lines = 0
if kind in ('block', 'plain'):
body = ''.join(continuation_lines(fm[body_start:]))
value_end = body_start + len(body)
if kind == 'block':
text = body
value_lines = body.count('\n')
else:
text = fm[value_start:value_end]
value_lines = 1 + body.count('\n')
if ' #' in text or text.lstrip().startswith('#'):
# A `#` opens a comment inside a plain scalar. Folding it in
# would lint text YAML never treats as part of the value, so
# leave the file alone rather than lint the wrong string.
kind = None
elif kind in ('double', 'single'):
quote = '"' if kind == 'double' else "'"
inner_start = value_start + 1
acc = fm[inner_start:body_start]
idx = close_quote(acc, quote)
lines = continuation_lines(fm[body_start:])
while idx is None:
try:
acc += next(lines)
except StopIteration:
break break
body_lines.append(line) idx = close_quote(acc, quote)
raw = ''.join(body_lines) if idx is None:
if raw.count('\n') >= 2: kind = None # unterminated quote: invalid YAML, leave it to vale
flat = re.sub(r'\s+', ' ', raw).strip() else:
# YAML single-quoted scalars have no backslash-escape mechanism at inner = acc[:idx]
# all, so wrapping in single quotes sidesteps the backslash-escape value_end = inner_start + idx + 1
# bug entirely for embedded double quotes, backslashes, and text = unescape_double(inner) if quote == '"' else inner.replace("''", "'")
# non-ASCII text. The one YAML-spec-correct way to embed a literal value_lines = 1 + inner.count('\n')
# apostrophe is to double it ('') — but Vale's own frontmatter
# scanner isn't a full YAML parser and doesn't understand that flat = re.sub(r'\s+', ' ', text).strip()
# doubling: empirically, it silently truncates the value at the if kind and flat and value_lines >= 2:
# first ' it sees, hiding everything after it from the NLP scope # `value_end` can land mid-line, just past a closing quote, so extend to
# (a different flavor of the same bug this whole script exists to # the end of that physical line and carry whatever follows (a trailing
# work around). Since this copy is scratch-only and never written # comment) across unchanged.
# back, sidestep it by substituting a Unicode right single if value_end > 0 and fm[value_end - 1] == '\n':
# quotation mark (U+2019) for any literal apostrophe instead of span_end = value_end
# doubling it — visually a smart quote, but never triggers a YAML trailer = ''
# escape sequence at all. else:
flat_q = "'" + flat.replace("'", "’") + "'" newline = fm.find('\n', value_end)
pad = '\n' * raw.count('\n') span_end = len(fm) if newline == -1 else newline + 1
start = header_m.start() trailer = fm[value_end:span_end].rstrip('\n')
end = header_m.end() + len(raw) # One line replaces the span, so the blank-line pad is one short of the
new_fm = fm[:start] + f'description: {flat_q}\n{pad}' + fm[end:] # newline count it displaced — every later line number is unchanged.
content = fm_match.group(1) + new_fm + fm_match.group(3) + content[fm_match.end():] pad = '\n' * (fm[head_start:span_end].count('\n') - 1)
new_fm = (fm[:head_start] + 'description: ' + emit(flat) + trailer
+ '\n' + pad + fm[span_end:])
content = (fm_match.group(1) + new_fm + fm_match.group(3)
+ content[fm_match.end():])
with open(dest, 'w', encoding='utf-8', errors='surrogateescape') as fh: with open(dest, 'w', encoding='utf-8', errors='surrogateescape') as fh:
fh.write(content) fh.write(content)
@@ -206,11 +437,16 @@ for arg in ${path_args[@]+"${path_args[@]}"}; do
# the tree, so any file dropped here would be silently unlinted — and then # 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: # 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. # vale never lints it and copying it can dwarf the rest of the tree.
# `find -L` follows symlinks because vale does: it lints both a symlinked
# file and a file under a symlinked directory, and a bare `-type f` walk
# would report "0 files" where bare vale reports one. (A symlink loop makes
# `find` warn on stderr and carry on, which is also what vale does.) The
# second walk needs no `-L`: the mirror is all real files by construction.
mkdir -p "$dest" mkdir -p "$dest"
while IFS= read -r -d '' rel; do while IFS= read -r -d '' rel; do
mkdir -p "$dest/$(dirname "$rel")" mkdir -p "$dest/$(dirname "$rel")"
cp "$arg/$rel" "$dest/$rel" cp "$arg/$rel" "$dest/$rel"
done < <(cd "$arg" && find . -name .git -prune -o -type f -print0) done < <(cd "$arg" && find -L . -name .git -prune -o -type f -print0)
while IFS= read -r -d '' md; do while IFS= read -r -d '' md; do
flatten "$md" "$md" flatten "$md" "$md"
done < <(find "$dest" -type f -name '*.md' -print0) done < <(find "$dest" -type f -name '*.md' -print0)

View File

@@ -321,7 +321,12 @@ else
fail "an absolute path was silently skipped — the bug this test guards against" fail "an absolute path was silently skipped — the bug this test guards against"
fi fi
# --- 11. A literal (|) block scalar passes through unflattened (no regression) --- # --- 11. A literal (|) block scalar passes through unflattened. Unlike every
# other multi-line form, `|` is not broken in Vale: its parsed value keeps the
# same line breaks the source has, so the description scope still matches. The
# second assertion pins that down — without it, a wrapper that broke `|` and a
# Vale that never matched `|` would agree on zero alerts and the comparison
# would pass vacuously.
echo "" echo ""
echo "--- leaves a literal (|) block scalar untouched (narrowed >-only scope) ---" echo "--- leaves a literal (|) block scalar untouched (narrowed >-only scope) ---"
FIXTURE11="$(make_raw_fixture <<'EOF' FIXTURE11="$(make_raw_fixture <<'EOF'
@@ -339,7 +344,9 @@ trap 'rm -rf "$FIXTURE1" "$FIXTURE2" "$FIXTURE3" "$FIXTURE4" "$FIXTURE5" "$FIXTU
REL11="plugins/testplugin/skills/zzzskill/SKILL.md" REL11="plugins/testplugin/skills/zzzskill/SKILL.md"
WRAPPED_OUT=$(run_wrap "$FIXTURE11" --config "$VALE_CONFIG" "$REL11") WRAPPED_OUT=$(run_wrap "$FIXTURE11" --config "$VALE_CONFIG" "$REL11")
BARE_OUT=$(cd "$FIXTURE11" && vale --config "$VALE_CONFIG" "$REL11" 2>&1 || true) BARE_OUT=$(cd "$FIXTURE11" && vale --config "$VALE_CONFIG" "$REL11" 2>&1 || true)
if [[ "$WRAPPED_OUT" == "$BARE_OUT" ]]; then if ! echo "$BARE_OUT" | grep -q "VagueWording"; then
fail "bare vale reports nothing for a literal (|) block scalar — the 'literal blocks are not broken' premise is wrong"
elif [[ "$WRAPPED_OUT" == "$BARE_OUT" ]]; then
pass "literal (|) block scalar output matches bare vale exactly — untouched by flattening" pass "literal (|) block scalar output matches bare vale exactly — untouched by flattening"
else 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"
@@ -425,23 +432,50 @@ else
fail "a path with a space was dropped from the directory walk" fail "a path with a space was dropped from the directory walk"
fi fi
# --- 16. No unguarded `"${arr[@]}"` expansion survives in the wrapper. bash # --- 16. No unguarded `"${arr[@]}"` expansion survives in any script that runs
# before 4.4 — including the 3.2 that macOS still ships as /bin/bash — treats # on macOS. bash before 4.4 — including the 3.2 that macOS still ships as
# that form on an empty array as an unbound variable under `set -u` and aborts. # /bin/bash — treats that form on an *empty* array as an unbound variable under
# The portable form is `${arr[@]+"${arr[@]}"}`. This is a static check because # `set -u` and aborts. The portable form is `${arr[@]+"${arr[@]}"}`. This is a
# no bash 5 host can reproduce the abort at runtime: the construct is only fatal # static check because no bash 5 host can reproduce the abort at runtime: the
# on the older shell, so absence of the construct is the property to assert. # construct is only fatal on the older shell, so absence of the construct is the
# `${#arr[@]}` is deliberately not flagged — the count form is safe on 3.2. # property to assert. `${#arr[@]}` is deliberately not flagged — the count form
# is safe on 3.2. Neither is an array seeded with at least one element where it
# 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
# 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
# hook scripts.
echo "" echo ""
echo "--- no unguarded array expansion remains in vale-wrap.sh ---" echo "--- no unguarded array expansion remains in the macOS-facing scripts ---"
unguarded_expansions() { unguarded_expansions() {
# Blank out whole-line comments (keeping line numbers), delete every correctly local file="$1" hit name
# guarded expansion, then anything still matching is a real hazard. while IFS= read -r hit; do
awk '{ if ($0 ~ /^[[:space:]]*#/) print ""; else print }' "$1" \ name="$(printf '%s\n' "$hit" \
| sed -E 's/\$\{([A-Za-z_][A-Za-z0-9_]*)\[@\]\+"\$\{\1\[@\]\}"\}//g' \ | grep -oE '\$\{[A-Za-z_][A-Za-z0-9_]*\[@\]\}' | head -1 \
| grep -nE '\$\{[A-Za-z_][A-Za-z0-9_]*\[@\]\}' || true | sed -E 's/^\$\{//; s/\[@\]\}$//')"
if grep -qE "^[[:space:]]*((local|declare|readonly)[[:space:]]+)?(-a[[:space:]]+)?$name=\([^)]" "$file" \
&& ! grep -qE "^[[:space:]]*$name=\(\)" "$file"; then
continue
fi
printf '%s:%s\n' "${file##*/}" "$hit"
done < <(
# Blank out whole-line comments (keeping line numbers), delete every
# correctly guarded expansion, then anything still matching is a candidate.
awk '{ if ($0 ~ /^[[:space:]]*#/) print ""; else print }' "$file" \
| sed -E 's/\$\{([A-Za-z_][A-Za-z0-9_]*)\[@\]\+"\$\{\1\[@\]\}"\}//g' \
| grep -nE '\$\{[A-Za-z_][A-Za-z0-9_]*\[@\]\}' || true
)
} }
HAZARDS16="$(unguarded_expansions "$SCRIPT")" HAZARDS16=""
for BASH32_SCRIPT in \
"$SCRIPT" \
"$REPO_ROOT/scripts/skill-size-check.sh" \
"$REPO_ROOT/scripts/check-release-needed.sh"; do
FOUND16="$(unguarded_expansions "$BASH32_SCRIPT")"
if [[ -n "$FOUND16" ]]; then
HAZARDS16+="$FOUND16 "
fi
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' ' ')"
else else
@@ -500,6 +534,238 @@ else
fail "a path argument with a space was split by the array expansion: $OUT18" fail "a path argument with a space was split by the array expansion: $OUT18"
fi fi
# The cases below share one cleanup list. The per-case trap rebuilding above
# does not scale past the fixture count it already carries, and this trap is
# installed last, so it is the one that runs.
EXTRA_FIXTURES=()
new_fixture() { EXTRA_FIXTURES+=("$1"); }
cleanup_all() {
rm -rf "$FIXTURE1" "$FIXTURE2" "$FIXTURE3" "$FIXTURE4" "$FIXTURE5" "$FIXTURE6" \
"$FIXTURE7" "$FIXTURE8" "$FIXTURE10" "$FIXTURE11" "$FIXTURE12" "$STUB13" \
"$FIXTURE14" "$FIXTURE17" "$FIXTURE18" \
${EXTRA_FIXTURES[@]+"${EXTRA_FIXTURES[@]}"}
}
trap cleanup_all EXIT
# --- 19. Every YAML form whose parsed value is joined back out of 2+ physical
# lines breaks the `text.frontmatter.description` scope identically, not just
# the `>` folded block the flattener originally handled: a plain scalar wrapped
# onto continuation lines, a double-quoted one, a single-quoted one, and a bare
# `description:` whose value starts on the next line all report zero alerts
# under bare vale. Each must come back with the same alerts as the single-line
# spelling of the same sentence. Line and column numbers legitimately move (the
# value lands on one physical line), so the comparison drops the `line:col`
# prefix and compares the alert text — message, matched token, and rule name.
REL_SKILL19="plugins/testplugin/skills/zzzskill/SKILL.md"
DESC19_A="Use when the caller helps with a specific job"
DESC19_B="and the second physical line will utilize the wrap"
# make_form_fixture spells the same two-clause description in one YAML scalar
# form: single, folded, plain, dquote, squote, or keyonly.
make_form_fixture() {
local form="$1" dir
dir="$(mktemp -d)"
new_fixture "$dir"
(cd "$dir" && git init -q)
mkdir -p "$dir/plugins/testplugin/skills/zzzskill"
{
echo "---"
echo "name: zzzskill"
case "$form" in
single) echo "description: $DESC19_A $DESC19_B" ;;
folded) echo "description: >"; echo " $DESC19_A"; echo " $DESC19_B" ;;
plain) echo "description: $DESC19_A"; echo " $DESC19_B" ;;
dquote) echo "description: \"$DESC19_A"; echo " $DESC19_B\"" ;;
squote) echo "description: '$DESC19_A"; echo " $DESC19_B'" ;;
keyonly) echo "description:"; echo " $DESC19_A"; echo " $DESC19_B" ;;
*) echo "make_form_fixture: unknown form '$form'" >&2; exit 1 ;;
esac
echo "---"
echo ""
echo "Body."
} > "$dir/plugins/testplugin/skills/zzzskill/SKILL.md"
echo "$dir"
}
# Alert text with the `line:col` prefix and ANSI colouring stripped, sorted.
alert_text() {
echo "$1" \
| sed -E 's/\x1b\[[0-9;]*m//g' \
| grep -oE '(error|warning|suggestion)[[:space:]]+.*' \
| sed -E 's/[[:space:]]+/ /g' \
| sort
}
echo ""
echo "--- every multi-line description form reports what its single-line form reports ---"
FIXTURE19_SINGLE="$(make_form_fixture single)"
BASELINE19="$(alert_text "$(run_wrap "$FIXTURE19_SINGLE" --config "$VALE_CONFIG" "$REL_SKILL19")")"
if [[ -z "$BASELINE19" ]]; then
fail "the single-line baseline reported nothing — the comparison below would be vacuous"
fi
for FORM19 in folded plain dquote squote keyonly; do
DIR19="$(make_form_fixture "$FORM19")"
BARE19="$(cd "$DIR19" && vale --config "$VALE_CONFIG" "$REL_SKILL19" 2>&1 || true)"
GOT19="$(alert_text "$(run_wrap "$DIR19" --config "$VALE_CONFIG" "$REL_SKILL19")")"
if echo "$BARE19" | grep -q "VagueWording"; then
fail "bare vale already flags the $FORM19 form, so this case can't detect a silently-skipped flattening"
elif [[ "$GOT19" == "$BASELINE19" ]]; then
pass "a $FORM19 multi-line description reports the same alerts as its single-line form"
else
fail "a $FORM19 multi-line description diverged from its single-line form: got [$GOT19]"
fi
done
# --- 20. A style token containing an ASCII apostrophe matches inside a
# flattened description. The flattener used to substitute U+2019 for every `'`
# 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.
# Both branches that can hold an apostrophe verbatim are exercised: a value that
# is safe unquoted, and one that must be quoted (it contains `: `) and so has to
# land in a double-quoted scalar, since a single-quoted one would need the `''`
# escape that kills the scope outright.
echo ""
echo "--- a style token containing an apostrophe matches in a flattened description ---"
APOS_STYLE="$(mktemp -d)"
new_fixture "$APOS_STYLE"
mkdir -p "$APOS_STYLE/styles/Apostrophe"
cat > "$APOS_STYLE/styles/Apostrophe/Token.yml" <<'EOF'
extends: existence
message: "apostrophe token: '%s'"
level: error
scope: text.frontmatter.description
ignorecase: true
tokens:
- "user's task"
EOF
cat > "$APOS_STYLE/.vale.ini" <<'EOF'
StylesPath = styles
[**/SKILL.md]
BasedOnStyles = Apostrophe
EOF
FIXTURE20_PLAIN="$(make_raw_fixture <<'EOF'
---
name: zzzskill
description: >
Use when the user's task needs handling, and a second physical
line continues the folded scalar.
---
Body.
EOF
)"
new_fixture "$FIXTURE20_PLAIN"
FIXTURE20_QUOTED="$(make_raw_fixture <<'EOF'
---
name: zzzskill
description: >
Triggers on: the user's task needing handling, and a second
physical line continues the folded scalar.
---
Body.
EOF
)"
new_fixture "$FIXTURE20_QUOTED"
for CASE20 in "unquoted:$FIXTURE20_PLAIN" "double-quoted:$FIXTURE20_QUOTED"; do
if run_wrap "${CASE20#*:}" --config "$APOS_STYLE/.vale.ini" "$REL_SKILL19" \
| grep -q "Apostrophe.Token"; then
pass "an apostrophe-bearing token matches in a flattened ${CASE20%%:*} description"
else
fail "an apostrophe-bearing token was rewritten out of a flattened ${CASE20%%:*} description"
fi
done
# --- 20b. The one combination no verbatim YAML scalar can carry — needs
# quoting, holds an apostrophe, and holds a double quote — falls back to the
# lossy U+2019 substitution. Apostrophe-bearing tokens are lost there by
# design, but the scope must stay alive so every other rule still fires.
echo ""
echo "--- the unrepresentable combination keeps the description scope alive ---"
FIXTURE20C="$(make_raw_fixture <<'EOF'
---
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"
else
fail "a description needing quotes with both an apostrophe and a double quote produced no alerts"
fi
# --- 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
# directory), so a `-type f` walk of the tree reported "0 files" where bare vale
# reports one — and the audit skills read a "0 files" report as NOT RUN.
echo ""
echo "--- mirrors a symlinked file reached through a directory argument ---"
FIXTURE21="$(make_fixture 2)"
new_fixture "$FIXTURE21"
mkdir -p "$FIXTURE21/real"
mv "$FIXTURE21/$REL_SKILL19" "$FIXTURE21/real/SKILL.md"
ln -s ../../../../real/SKILL.md "$FIXTURE21/$REL_SKILL19"
BARE21="$(cd "$FIXTURE21" && vale --config "$VALE_CONFIG" plugins 2>&1 || true)"
WRAPPED21="$(run_wrap "$FIXTURE21" --config "$VALE_CONFIG" plugins)"
BARE21_FILES="$(echo "$BARE21" | sed -E 's/\x1b\[[0-9;]*m//g' | grep -oE 'in [0-9]+ files?' | tail -1)"
WRAPPED21_FILES="$(echo "$WRAPPED21" | sed -E 's/\x1b\[[0-9;]*m//g' | grep -oE 'in [0-9]+ files?' | tail -1)"
if [[ "$BARE21_FILES" != "in 1 file" ]]; then
fail "bare vale did not lint the symlinked file ($BARE21_FILES), so this case can't detect the walk dropping it"
elif [[ "$WRAPPED21_FILES" != "$BARE21_FILES" ]]; then
fail "the directory walk dropped a symlinked file: wrapper saw '$WRAPPED21_FILES', bare vale '$BARE21_FILES'"
elif echo "$WRAPPED21" | grep -q "VagueWording"; then
pass "a symlinked file under a directory argument is mirrored, flattened and flagged"
else
fail "a symlinked file was mirrored but not flattened — no alert came back"
fi
# --- 22. The value of a separated two-argv flag is never treated as a lint
# target, however file-like it looks. `--output tmpl.tmpl` names a real
# template file: classifying it as input both linted the template and reordered
# argv, so vale received `--output --no-wrap` and died on `open :`.
echo ""
echo "--- a separated flag value that names a real file is not linted as a target ---"
FIXTURE22="$(make_fixture 1)"
new_fixture "$FIXTURE22"
printf 'TMPL{{range .Files}} {{.Path}}{{end}}\n' > "$FIXTURE22/tmpl.tmpl"
WRAPPED22="$(run_wrap "$FIXTURE22" --config "$VALE_CONFIG" --output tmpl.tmpl --no-wrap "$REL_SKILL19")"
BARE22="$(cd "$FIXTURE22" && vale --config "$VALE_CONFIG" --output tmpl.tmpl --no-wrap "$REL_SKILL19" 2>&1 || true)"
# The fixture's description is a single physical line, so flattening is a no-op
# and the two invocations must agree byte for byte.
if [[ "$WRAPPED22" == "$BARE22" ]]; then
pass "a separated --output value is passed through to vale, not linted"
else
fail "a separated --output value was misrouted: wrapper gave [$WRAPPED22], bare vale [$BARE22]"
fi
# --- 23. A path argument that does not exist is a hard error. Bare vale drops
# it, falls back to stdin and prints `0 errors ... in stdin` with exit 0, so a
# typo'd target is indistinguishable from a clean run — and the audit skills'
# NOT RUN guard string-matches `0 files`, which `in stdin` never produces. This
# is a deliberate divergence from bare vale, documented in the wrapper header.
echo ""
echo "--- a nonexistent path argument fails loudly instead of falling back to stdin ---"
set +e
OUT23="$(cd "$FIXTURE22" && bash "$SCRIPT" --config "$VALE_CONFIG" plugins/testplugin/skills/zzzskill/SKILLL.md 2>&1)"
RC23=$?
set -e
if [[ $RC23 -eq 0 ]]; then
fail "a typo'd path exited 0 — indistinguishable from a clean run, the bug this test guards against"
elif echo "$OUT23" | grep -q "in stdin"; then
fail "a typo'd path fell back to reading stdin and reported 'in stdin' instead of erroring"
elif echo "$OUT23" | grep -q "SKILLL.md"; then
pass "a typo'd path exits nonzero with a message naming the path"
else
fail "a typo'd path exited $RC23 but the message does not name it: $OUT23"
fi
echo "" echo ""
echo "Results: $PASS passed, $FAIL failed" echo "Results: $PASS passed, $FAIL failed"
[[ $FAIL -eq 0 ]] [[ $FAIL -eq 0 ]]