diff --git a/plugins/kyberforge/skills/agent-audit/scripts/vale-wrap.sh b/plugins/kyberforge/skills/agent-audit/scripts/vale-wrap.sh index f3d891b..66f3057 100755 --- a/plugins/kyberforge/skills/agent-audit/scripts/vale-wrap.sh +++ b/plugins/kyberforge/skills/agent-audit/scripts/vale-wrap.sh @@ -2,27 +2,45 @@ 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. +# silently stops matching once the `description:` value spans 2+ physical lines +# in any form YAML joins back into one string — a `>`/`>-`/`>+` folded block +# scalar (the style used by most skills/agents in this repo), a plain scalar +# wrapped onto continuation lines, or a double- or single-quoted scalar wrapped +# the same way. A `|`/`|-`/`|+` literal block scalar is NOT affected: its parsed +# value keeps exactly the line breaks the source has, and vale matches it fine +# (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 -# — 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.) +# "Same args" means relative paths — path arguments and the values of the +# path-valued flags (`--config`, `--output`, `--path`) alike — resolve against +# the caller's current directory, exactly as bare `vale` resolves them. The flag +# values are rewritten to absolute form because the run ends up `cd`'d into the +# scratch mirror, where a relative one would no longer resolve. (An earlier +# 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 -# 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. +# Divergence 1: with no `--config` at all, this script's own sibling +# `assets/vale/.vale.ini` is used instead of vale's upward search. pre-commit +# prefixes only `entry[0]` with the hook-repo clone path, so a `--config` in +# `.pre-commit-hooks.yaml` would resolve against the *consuming* repo and +# hard-fail (E100) for every external consumer. The manifest therefore passes the +# script alone, and an explicit `--config` from any other caller still wins. +# +# 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 # 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. vale_args=() path_args=() -config_next=false +pending_flag="" config_given=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 + if [[ -n "$pending_flag" ]]; then + # Value of a separated two-argv flag. It is never a lint target, however + # file-like it looks. The run ends up `cd`'d into the scratch mirror, so a + # value naming a file has to be absolutized here or it stops resolving. + case "$pending_flag" in + --config) + # 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 fi case "$arg" in --config) vale_args+=("$arg") - config_next=true + pending_flag="$arg" config_given=true continue ;; @@ -70,24 +110,60 @@ for arg in "$@"; do config_given=true 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 - # `-f`/`-d` resolve relative paths against the caller's cwd, same as vale does. - # 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 + if [[ "$arg" == -* ]]; then 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 done @@ -119,55 +195,210 @@ src, dest = sys.argv[1], sys.argv[2] with open(src, encoding='utf-8', errors='surrogateescape') as fh: content = fh.read() +# YAML 1.2 double-quoted escapes (spec 5.7 / 7.3.1). `\` 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) 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: + header_m = re.search(r'^description:[ \t]*', fm, re.MULTILINE) +else: + header_m = None + +if header_m: + head_start = header_m.start() + value_start = header_m.end() + header_end = fm.find('\n', value_start) + header_end = len(fm) if header_end == -1 else header_end + first = fm[value_start:header_end] + body_start = header_end + 1 + indicator = first.rstrip() + + block_m = re.fullmatch(r'([|>])([+-]?[0-9]*|[0-9]*[+-]?)', indicator) + if block_m and block_m.group(1) == '|': + kind = None # literal blocks keep their line breaks; vale is fine + elif block_m: + kind = 'block' # folded (`>`): the value starts on the next line + elif indicator == '': + kind = 'block' # bare `description:`: a plain scalar on later lines + 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 - 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():] + idx = close_quote(acc, quote) + if idx is None: + kind = None # unterminated quote: invalid YAML, leave it to vale + else: + inner = acc[:idx] + value_end = inner_start + idx + 1 + text = unescape_double(inner) if quote == '"' else inner.replace("''", "'") + value_lines = 1 + inner.count('\n') + + flat = re.sub(r'\s+', ' ', text).strip() + if kind and flat and value_lines >= 2: + # `value_end` can land mid-line, just past a closing quote, so extend to + # the end of that physical line and carry whatever follows (a trailing + # comment) across unchanged. + if value_end > 0 and fm[value_end - 1] == '\n': + span_end = value_end + trailer = '' + else: + newline = fm.find('\n', value_end) + span_end = len(fm) if newline == -1 else newline + 1 + trailer = fm[value_end:span_end].rstrip('\n') + # One line replaces the span, so the blank-line pad is one short of the + # newline count it displaced — every later line number is unchanged. + 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: 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 # 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. + # `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" 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) + done < <(cd "$arg" && find -L . -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) diff --git a/plugins/kyberforge/skills/skill-audit/scripts/vale-wrap.sh b/plugins/kyberforge/skills/skill-audit/scripts/vale-wrap.sh index f3d891b..66f3057 100755 --- a/plugins/kyberforge/skills/skill-audit/scripts/vale-wrap.sh +++ b/plugins/kyberforge/skills/skill-audit/scripts/vale-wrap.sh @@ -2,27 +2,45 @@ 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. +# silently stops matching once the `description:` value spans 2+ physical lines +# in any form YAML joins back into one string — a `>`/`>-`/`>+` folded block +# scalar (the style used by most skills/agents in this repo), a plain scalar +# wrapped onto continuation lines, or a double- or single-quoted scalar wrapped +# the same way. A `|`/`|-`/`|+` literal block scalar is NOT affected: its parsed +# value keeps exactly the line breaks the source has, and vale matches it fine +# (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 -# — 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.) +# "Same args" means relative paths — path arguments and the values of the +# path-valued flags (`--config`, `--output`, `--path`) alike — resolve against +# the caller's current directory, exactly as bare `vale` resolves them. The flag +# values are rewritten to absolute form because the run ends up `cd`'d into the +# scratch mirror, where a relative one would no longer resolve. (An earlier +# 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 -# 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. +# Divergence 1: with no `--config` at all, this script's own sibling +# `assets/vale/.vale.ini` is used instead of vale's upward search. pre-commit +# prefixes only `entry[0]` with the hook-repo clone path, so a `--config` in +# `.pre-commit-hooks.yaml` would resolve against the *consuming* repo and +# hard-fail (E100) for every external consumer. The manifest therefore passes the +# script alone, and an explicit `--config` from any other caller still wins. +# +# 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 # 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. vale_args=() path_args=() -config_next=false +pending_flag="" config_given=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 + if [[ -n "$pending_flag" ]]; then + # Value of a separated two-argv flag. It is never a lint target, however + # file-like it looks. The run ends up `cd`'d into the scratch mirror, so a + # value naming a file has to be absolutized here or it stops resolving. + case "$pending_flag" in + --config) + # 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 fi case "$arg" in --config) vale_args+=("$arg") - config_next=true + pending_flag="$arg" config_given=true continue ;; @@ -70,24 +110,60 @@ for arg in "$@"; do config_given=true 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 - # `-f`/`-d` resolve relative paths against the caller's cwd, same as vale does. - # 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 + if [[ "$arg" == -* ]]; then 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 done @@ -119,55 +195,210 @@ src, dest = sys.argv[1], sys.argv[2] with open(src, encoding='utf-8', errors='surrogateescape') as fh: content = fh.read() +# YAML 1.2 double-quoted escapes (spec 5.7 / 7.3.1). `\` 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) 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: + header_m = re.search(r'^description:[ \t]*', fm, re.MULTILINE) +else: + header_m = None + +if header_m: + head_start = header_m.start() + value_start = header_m.end() + header_end = fm.find('\n', value_start) + header_end = len(fm) if header_end == -1 else header_end + first = fm[value_start:header_end] + body_start = header_end + 1 + indicator = first.rstrip() + + block_m = re.fullmatch(r'([|>])([+-]?[0-9]*|[0-9]*[+-]?)', indicator) + if block_m and block_m.group(1) == '|': + kind = None # literal blocks keep their line breaks; vale is fine + elif block_m: + kind = 'block' # folded (`>`): the value starts on the next line + elif indicator == '': + kind = 'block' # bare `description:`: a plain scalar on later lines + 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 - 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():] + idx = close_quote(acc, quote) + if idx is None: + kind = None # unterminated quote: invalid YAML, leave it to vale + else: + inner = acc[:idx] + value_end = inner_start + idx + 1 + text = unescape_double(inner) if quote == '"' else inner.replace("''", "'") + value_lines = 1 + inner.count('\n') + + flat = re.sub(r'\s+', ' ', text).strip() + if kind and flat and value_lines >= 2: + # `value_end` can land mid-line, just past a closing quote, so extend to + # the end of that physical line and carry whatever follows (a trailing + # comment) across unchanged. + if value_end > 0 and fm[value_end - 1] == '\n': + span_end = value_end + trailer = '' + else: + newline = fm.find('\n', value_end) + span_end = len(fm) if newline == -1 else newline + 1 + trailer = fm[value_end:span_end].rstrip('\n') + # One line replaces the span, so the blank-line pad is one short of the + # newline count it displaced — every later line number is unchanged. + 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: 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 # 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. + # `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" 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) + done < <(cd "$arg" && find -L . -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) diff --git a/tests/test-vale-wrap.sh b/tests/test-vale-wrap.sh index 3fc4d0a..fa1cc48 100755 --- a/tests/test-vale-wrap.sh +++ b/tests/test-vale-wrap.sh @@ -321,7 +321,12 @@ else fail "an absolute path was silently skipped — the bug this test guards against" 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 "--- leaves a literal (|) block scalar untouched (narrowed >-only scope) ---" 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" WRAPPED_OUT=$(run_wrap "$FIXTURE11" --config "$VALE_CONFIG" "$REL11") 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" else 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" fi -# --- 16. No unguarded `"${arr[@]}"` expansion survives in the wrapper. bash -# before 4.4 — including the 3.2 that macOS still ships as /bin/bash — treats -# that form on an empty array as an unbound variable under `set -u` and aborts. -# The portable form is `${arr[@]+"${arr[@]}"}`. This is a static check because -# no bash 5 host can reproduce the abort at runtime: the construct is only fatal -# on the older shell, so absence of the construct is the property to assert. -# `${#arr[@]}` is deliberately not flagged — the count form is safe on 3.2. +# --- 16. No unguarded `"${arr[@]}"` expansion survives in any script that runs +# on macOS. bash before 4.4 — including the 3.2 that macOS still ships as +# /bin/bash — treats that form on an *empty* array as an unbound variable under +# `set -u` and aborts. The portable form is `${arr[@]+"${arr[@]}"}`. This is a +# static check because no bash 5 host can reproduce the abort at runtime: the +# construct is only fatal on the older shell, so absence of the construct is the +# 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 "--- no unguarded array expansion remains in vale-wrap.sh ---" +echo "--- no unguarded array expansion remains in the macOS-facing scripts ---" unguarded_expansions() { - # Blank out whole-line comments (keeping line numbers), delete every correctly - # guarded expansion, then anything still matching is a real hazard. - awk '{ if ($0 ~ /^[[:space:]]*#/) print ""; else print }' "$1" \ - | sed -E 's/\$\{([A-Za-z_][A-Za-z0-9_]*)\[@\]\+"\$\{\1\[@\]\}"\}//g' \ - | grep -nE '\$\{[A-Za-z_][A-Za-z0-9_]*\[@\]\}' || true + local file="$1" hit name + while IFS= read -r hit; do + name="$(printf '%s\n' "$hit" \ + | grep -oE '\$\{[A-Za-z_][A-Za-z0-9_]*\[@\]\}' | head -1 \ + | 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 fail "unguarded array expansion(s) abort on bash < 4.4 under set -u: $(echo "$HAZARDS16" | tr '\n' ' ')" else @@ -500,6 +534,238 @@ else fail "a path argument with a space was split by the array expansion: $OUT18" 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 "Results: $PASS passed, $FAIL failed" [[ $FAIL -eq 0 ]]