#!/usr/bin/env bash set -euo pipefail # Works around a Vale limitation: the `text.frontmatter.description` NLP scope # silently stops matching once the `description:` value is a YAML block scalar # (`>`/`|`) spanning 2+ physical lines — the style used by most skills/agents in # this repo. Flattens the description to one physical line in a scratch copy # (padding with blank lines so every other line number is unchanged), then runs # the real `vale` binary against the copies. Drop-in replacement for calling # `vale` directly: same args, same exit code. # # "Same args" means relative paths — `--config` values and 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.) # # 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. # # Vale prints each path exactly as it was handed to it, so the scratch tree # mirrors the caller's absolute cwd: a relative path argument is passed through # verbatim and resolves to its flattened copy, keeping the report byte-identical # to bare `vale`'s. An absolute path inside the cwd is relativized to keep that # property. Only an absolute path outside the cwd is rewritten to its scratch # copy and so reports a scratch path — unavoidable, since a file can only be # read from where it actually is. cwd="$(pwd -P)" vale_args=() path_args=() config_next=false 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 continue fi case "$arg" in --config) vale_args+=("$arg") config_next=true config_given=true continue ;; --config=/*) vale_args+=("$arg") config_given=true continue ;; --config=*) vale_args+=("--config=$cwd/${arg#--config=}") config_given=true 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 vale_args+=("$arg") fi done if [[ "$config_given" == false ]]; then vale_args+=(--config "$(cd "$(dirname "${BASH_SOURCE[0]}")/../assets/vale" && pwd)/.vale.ini") fi if [[ ${#path_args[@]} -eq 0 ]]; then # Nothing to flatten. Hand off directly, with stdin closed so vale doesn't # block waiting on a pipe that will never carry content. exec vale "${vale_args[@]}" < /dev/null fi # `realpath -m` would be the obvious normalizer, but `-m` (canonicalize-missing) # is a GNU extension the BSD realpath on macOS doesn't have — and every dest # below is a path that doesn't exist yet. python3 is already a hard dependency. abspath() { python3 -c 'import os, sys; print(os.path.abspath(sys.argv[1]))' "$1" } flatten() { python3 - "$1" "$2" <<'PYTHON' import re import sys src, dest = sys.argv[1], sys.argv[2] # surrogateescape keeps a non-UTF-8 file (reachable via a directory argument) # a byte-for-byte round trip instead of aborting the whole run on a decode error. with open(src, encoding='utf-8', errors='surrogateescape') as fh: content = fh.read() fm_match = re.match(r'^(---\n)(.*?\n)(---\n)', content, re.DOTALL) if fm_match: fm = fm_match.group(2) # Only `>`/`>-`/`>+` (folded) scalars break Vale's frontmatter-description # scope. `|`/`|-`/`|+` (literal) scalars already work fine with bare vale, # so they're deliberately left unmatched here. header_m = re.search(r'^description:[ \t]*(>[+-]?)[ \t]*\n', fm, re.MULTILINE) if header_m: # Body capture is indentation-based and blank-line-tolerant, per YAML # block-scalar rules: a blank line (any amount of whitespace) always # stays inside the block; the indent is set by the first content line; # the block ends at the first line indented less than that, or EOF. rest = fm[header_m.end():] indent = None body_lines = [] for line in rest.splitlines(keepends=True): text = line.rstrip('\n') if text.strip() == '': body_lines.append(line) continue line_indent = len(text) - len(text.lstrip(' \t')) if indent is None: indent = line_indent elif line_indent < indent: break body_lines.append(line) raw = ''.join(body_lines) if raw.count('\n') >= 2: flat = re.sub(r'\s+', ' ', raw).strip() # YAML single-quoted scalars have no backslash-escape mechanism at # all, so wrapping in single quotes sidesteps the backslash-escape # bug entirely for embedded double quotes, backslashes, and # non-ASCII text. The one YAML-spec-correct way to embed a literal # apostrophe is to double it ('') — but Vale's own frontmatter # scanner isn't a full YAML parser and doesn't understand that # doubling: empirically, it silently truncates the value at the # first ' it sees, hiding everything after it from the NLP scope # (a different flavor of the same bug this whole script exists to # work around). Since this copy is scratch-only and never written # back, sidestep it by substituting a Unicode right single # quotation mark (U+2019) for any literal apostrophe instead of # doubling it — visually a smart quote, but never triggers a YAML # escape sequence at all. flat_q = "'" + flat.replace("'", "’") + "'" pad = '\n' * raw.count('\n') start = header_m.start() end = header_m.end() + len(raw) new_fm = fm[:start] + f'description: {flat_q}\n{pad}' + fm[end:] content = fm_match.group(1) + new_fm + fm_match.group(3) + content[fm_match.end():] with open(dest, 'w', encoding='utf-8', errors='surrogateescape') as fh: fh.write(content) PYTHON } tmpdir="$(cd "$(mktemp -d)" && pwd -P)" trap 'rm -rf "$tmpdir"' EXIT # Mirror of the caller's cwd inside the scratch tree; relative path arguments # are resolved from here. mirror="$tmpdir$cwd" mkdir -p "$mirror" argv_paths=() for arg in "${path_args[@]}"; do if [[ "$arg" == /* ]]; then dest="$tmpdir$arg" else dest="$mirror/$arg" fi dest="$(abspath "$dest")" # A path argument with enough leading `..` to climb past the mirror root would # write outside the scratch dir. The real filesystem clamps such a path at # `/`; the mirror can't, so refuse rather than scribble outside the sandbox. case "$dest" in "$tmpdir"/*) ;; *) echo "vale-wrap.sh: refusing to lint '$arg': its scratch copy would land outside $tmpdir" >&2 exit 2 ;; esac mkdir -p "$(dirname "$dest")" if [[ -d "$arg" ]]; then # A directory is mirrored whole — vale applies its own format filtering to # the tree, so any file dropped here would be silently unlinted — and then # every markdown file in the copy is flattened in place. `.git` is pruned: # vale never lints it and copying it can dwarf the rest of the tree. mkdir -p "$dest" while IFS= read -r -d '' rel; do mkdir -p "$dest/$(dirname "$rel")" cp "$arg/$rel" "$dest/$rel" done < <(cd "$arg" && find . -name .git -prune -o -type f -print0) while IFS= read -r -d '' md; do flatten "$md" "$md" done < <(find "$dest" -type f -name '*.md' -print0) else flatten "$arg" "$dest" fi if [[ "$arg" == /* ]]; then argv_paths+=("$dest") else argv_paths+=("$arg") fi done cd "$mirror" vale "${vale_args[@]}" "${argv_paths[@]}"