fix(lint): make the Vale prefilter work for external consumers
pre-commit prefixes only entry[0] with the hook-repo clone path (cmd = (prefix.path(cmd[0]), *cmd[1:])), so the --config argument in .pre-commit-hooks.yaml resolved against the *consuming* repo's root and hard-failed every external run with E100. Two of the three hooks ADR-0014 promises were unusable. vale-wrap.sh now self-locates its config from BASH_SOURCE when no --config is supplied; an explicit --config still wins in all three argv forms and stays cwd-relative. Both manifests drop the argument and are kept byte-identical: the local repo: local config resolved --config correctly only because the consuming repo *was* this repo, and that divergence is why three review rounds missed the defect. Also in the wrapper: - replace GNU-only `realpath -m` with a portable abspath helper; -m is load-bearing (dest does not exist yet), so BSD realpath aborted the script under set -e on macOS - walk directory arguments instead of passing them through unflattened, which reported a clean 0-error run for files that fail when named explicitly - read/write with errors='surrogateescape' so one non-UTF-8 .md under a directory argument cannot abort the hook New test-vale-hooks-consumer.sh builds the hook repo from the working tree and points a file:// consumer at it, covering the manifest as a hook repo for the first time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MCQ648fLSFXPHGZdQ8gn58
This commit is contained in:
@@ -9,26 +9,35 @@ set -euo pipefail
|
||||
# the real `vale` binary against the copies. Drop-in replacement for calling
|
||||
# `vale` directly: same args, same exit code.
|
||||
#
|
||||
# "Same args" means relative paths — `--config` values and file arguments alike
|
||||
# "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.)
|
||||
#
|
||||
# Vale prints each file path exactly as it was handed to it, so the scratch tree
|
||||
# mirrors the caller's absolute cwd: a relative file argument is passed through
|
||||
# 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 file argument inside the cwd is relativized to
|
||||
# keep that property. Only an absolute path outside the cwd is rewritten to its
|
||||
# scratch copy and so reports a scratch path — unavoidable, since a file can
|
||||
# only be read from where it actually is.
|
||||
# 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=()
|
||||
file_args=()
|
||||
path_args=()
|
||||
config_next=false
|
||||
config_given=false
|
||||
for arg in "$@"; do
|
||||
if [[ "$config_next" == true ]]; then
|
||||
config_next=false
|
||||
@@ -43,19 +52,24 @@ for arg in "$@"; do
|
||||
--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` resolves relative paths against the caller's cwd, same as vale does.
|
||||
if [[ "$arg" != -* && -f "$arg" ]]; then
|
||||
# `-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
|
||||
@@ -63,54 +77,41 @@ for arg in "$@"; do
|
||||
# quoting it. Absolute paths outside the cwd have no relative form and keep
|
||||
# the scratch-path behaviour documented above.
|
||||
if [[ "$arg" == "$cwd"/* ]]; then
|
||||
file_args+=("${arg#"$cwd"/}")
|
||||
path_args+=("${arg#"$cwd"/}")
|
||||
else
|
||||
file_args+=("$arg")
|
||||
path_args+=("$arg")
|
||||
fi
|
||||
else
|
||||
vale_args+=("$arg")
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ${#file_args[@]} -eq 0 ]]; then
|
||||
if [[ "$config_given" == false ]]; then
|
||||
vale_args+=(--config "$(cd "$(dirname "${BASH_SOURCE[0]}")/../assets/vale" && pwd)/.vale.ini")
|
||||
fi
|
||||
|
||||
if [[ ${#path_args[@]} -eq 0 ]]; then
|
||||
# Nothing to flatten. Hand off directly, with stdin closed so vale doesn't
|
||||
# block waiting on a pipe that will never carry content.
|
||||
exec vale "${vale_args[@]}" < /dev/null
|
||||
fi
|
||||
|
||||
tmpdir="$(realpath -m "$(mktemp -d)")"
|
||||
trap 'rm -rf "$tmpdir"' EXIT
|
||||
# `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"
|
||||
}
|
||||
|
||||
# Mirror of the caller's cwd inside the scratch tree; relative file arguments
|
||||
# are resolved from here.
|
||||
mirror="$tmpdir$cwd"
|
||||
mkdir -p "$mirror"
|
||||
|
||||
argv_files=()
|
||||
for arg in "${file_args[@]}"; do
|
||||
if [[ "$arg" == /* ]]; then
|
||||
dest="$tmpdir$arg"
|
||||
else
|
||||
dest="$mirror/$arg"
|
||||
fi
|
||||
dest="$(realpath -m "$dest")"
|
||||
# A file argument with enough leading `..` to climb past the mirror root would
|
||||
# write outside the scratch dir. The real filesystem clamps such a path at
|
||||
# `/`; the mirror can't, so refuse rather than scribble outside the sandbox.
|
||||
case "$dest" in
|
||||
"$tmpdir"/*) ;;
|
||||
*)
|
||||
echo "vale-wrap.sh: refusing to lint '$arg': its scratch copy would land outside $tmpdir" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
mkdir -p "$(dirname "$dest")"
|
||||
python3 - "$arg" "$dest" <<'PYTHON'
|
||||
flatten() {
|
||||
python3 - "$1" "$2" <<'PYTHON'
|
||||
import re
|
||||
import sys
|
||||
|
||||
src, dest = sys.argv[1], sys.argv[2]
|
||||
with open(src) as fh:
|
||||
# surrogateescape keeps a non-UTF-8 file (reachable via a directory argument)
|
||||
# a byte-for-byte round trip instead of aborting the whole run on a decode error.
|
||||
with open(src, encoding='utf-8', errors='surrogateescape') as fh:
|
||||
content = fh.read()
|
||||
|
||||
fm_match = re.match(r'^(---\n)(.*?\n)(---\n)', content, re.DOTALL)
|
||||
@@ -163,15 +164,60 @@ if fm_match:
|
||||
new_fm = fm[:start] + f'description: {flat_q}\n{pad}' + fm[end:]
|
||||
content = fm_match.group(1) + new_fm + fm_match.group(3) + content[fm_match.end():]
|
||||
|
||||
with open(dest, 'w') as fh:
|
||||
with open(dest, 'w', encoding='utf-8', errors='surrogateescape') as fh:
|
||||
fh.write(content)
|
||||
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
|
||||
argv_files+=("$dest")
|
||||
dest="$tmpdir$arg"
|
||||
else
|
||||
argv_files+=("$arg")
|
||||
dest="$mirror/$arg"
|
||||
fi
|
||||
dest="$(abspath "$dest")"
|
||||
# A path argument with enough leading `..` to climb past the mirror root would
|
||||
# write outside the scratch dir. The real filesystem clamps such a path at
|
||||
# `/`; the mirror can't, so refuse rather than scribble outside the sandbox.
|
||||
case "$dest" in
|
||||
"$tmpdir"/*) ;;
|
||||
*)
|
||||
echo "vale-wrap.sh: refusing to lint '$arg': its scratch copy would land outside $tmpdir" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
mkdir -p "$(dirname "$dest")"
|
||||
if [[ -d "$arg" ]]; then
|
||||
# A directory is mirrored whole — vale applies its own format filtering to
|
||||
# the tree, so any file dropped here would be silently unlinted — and then
|
||||
# every markdown file in the copy is flattened in place. `.git` is pruned:
|
||||
# vale never lints it and copying it can dwarf the rest of the tree.
|
||||
mkdir -p "$dest"
|
||||
while IFS= read -r -d '' rel; do
|
||||
mkdir -p "$dest/$(dirname "$rel")"
|
||||
cp "$arg/$rel" "$dest/$rel"
|
||||
done < <(cd "$arg" && find . -name .git -prune -o -type f -print0)
|
||||
while IFS= read -r -d '' md; do
|
||||
flatten "$md" "$md"
|
||||
done < <(find "$dest" -type f -name '*.md' -print0)
|
||||
else
|
||||
flatten "$arg" "$dest"
|
||||
fi
|
||||
if [[ "$arg" == /* ]]; then
|
||||
argv_paths+=("$dest")
|
||||
else
|
||||
argv_paths+=("$arg")
|
||||
fi
|
||||
done
|
||||
|
||||
cd "$mirror"
|
||||
vale "${vale_args[@]}" "${argv_files[@]}"
|
||||
vale "${vale_args[@]}" "${argv_paths[@]}"
|
||||
|
||||
@@ -9,26 +9,35 @@ set -euo pipefail
|
||||
# the real `vale` binary against the copies. Drop-in replacement for calling
|
||||
# `vale` directly: same args, same exit code.
|
||||
#
|
||||
# "Same args" means relative paths — `--config` values and file arguments alike
|
||||
# "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.)
|
||||
#
|
||||
# Vale prints each file path exactly as it was handed to it, so the scratch tree
|
||||
# mirrors the caller's absolute cwd: a relative file argument is passed through
|
||||
# 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 file argument inside the cwd is relativized to
|
||||
# keep that property. Only an absolute path outside the cwd is rewritten to its
|
||||
# scratch copy and so reports a scratch path — unavoidable, since a file can
|
||||
# only be read from where it actually is.
|
||||
# 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=()
|
||||
file_args=()
|
||||
path_args=()
|
||||
config_next=false
|
||||
config_given=false
|
||||
for arg in "$@"; do
|
||||
if [[ "$config_next" == true ]]; then
|
||||
config_next=false
|
||||
@@ -43,19 +52,24 @@ for arg in "$@"; do
|
||||
--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` resolves relative paths against the caller's cwd, same as vale does.
|
||||
if [[ "$arg" != -* && -f "$arg" ]]; then
|
||||
# `-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
|
||||
@@ -63,54 +77,41 @@ for arg in "$@"; do
|
||||
# quoting it. Absolute paths outside the cwd have no relative form and keep
|
||||
# the scratch-path behaviour documented above.
|
||||
if [[ "$arg" == "$cwd"/* ]]; then
|
||||
file_args+=("${arg#"$cwd"/}")
|
||||
path_args+=("${arg#"$cwd"/}")
|
||||
else
|
||||
file_args+=("$arg")
|
||||
path_args+=("$arg")
|
||||
fi
|
||||
else
|
||||
vale_args+=("$arg")
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ${#file_args[@]} -eq 0 ]]; then
|
||||
if [[ "$config_given" == false ]]; then
|
||||
vale_args+=(--config "$(cd "$(dirname "${BASH_SOURCE[0]}")/../assets/vale" && pwd)/.vale.ini")
|
||||
fi
|
||||
|
||||
if [[ ${#path_args[@]} -eq 0 ]]; then
|
||||
# Nothing to flatten. Hand off directly, with stdin closed so vale doesn't
|
||||
# block waiting on a pipe that will never carry content.
|
||||
exec vale "${vale_args[@]}" < /dev/null
|
||||
fi
|
||||
|
||||
tmpdir="$(realpath -m "$(mktemp -d)")"
|
||||
trap 'rm -rf "$tmpdir"' EXIT
|
||||
# `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"
|
||||
}
|
||||
|
||||
# Mirror of the caller's cwd inside the scratch tree; relative file arguments
|
||||
# are resolved from here.
|
||||
mirror="$tmpdir$cwd"
|
||||
mkdir -p "$mirror"
|
||||
|
||||
argv_files=()
|
||||
for arg in "${file_args[@]}"; do
|
||||
if [[ "$arg" == /* ]]; then
|
||||
dest="$tmpdir$arg"
|
||||
else
|
||||
dest="$mirror/$arg"
|
||||
fi
|
||||
dest="$(realpath -m "$dest")"
|
||||
# A file argument with enough leading `..` to climb past the mirror root would
|
||||
# write outside the scratch dir. The real filesystem clamps such a path at
|
||||
# `/`; the mirror can't, so refuse rather than scribble outside the sandbox.
|
||||
case "$dest" in
|
||||
"$tmpdir"/*) ;;
|
||||
*)
|
||||
echo "vale-wrap.sh: refusing to lint '$arg': its scratch copy would land outside $tmpdir" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
mkdir -p "$(dirname "$dest")"
|
||||
python3 - "$arg" "$dest" <<'PYTHON'
|
||||
flatten() {
|
||||
python3 - "$1" "$2" <<'PYTHON'
|
||||
import re
|
||||
import sys
|
||||
|
||||
src, dest = sys.argv[1], sys.argv[2]
|
||||
with open(src) as fh:
|
||||
# surrogateescape keeps a non-UTF-8 file (reachable via a directory argument)
|
||||
# a byte-for-byte round trip instead of aborting the whole run on a decode error.
|
||||
with open(src, encoding='utf-8', errors='surrogateescape') as fh:
|
||||
content = fh.read()
|
||||
|
||||
fm_match = re.match(r'^(---\n)(.*?\n)(---\n)', content, re.DOTALL)
|
||||
@@ -163,15 +164,60 @@ if fm_match:
|
||||
new_fm = fm[:start] + f'description: {flat_q}\n{pad}' + fm[end:]
|
||||
content = fm_match.group(1) + new_fm + fm_match.group(3) + content[fm_match.end():]
|
||||
|
||||
with open(dest, 'w') as fh:
|
||||
with open(dest, 'w', encoding='utf-8', errors='surrogateescape') as fh:
|
||||
fh.write(content)
|
||||
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
|
||||
argv_files+=("$dest")
|
||||
dest="$tmpdir$arg"
|
||||
else
|
||||
argv_files+=("$arg")
|
||||
dest="$mirror/$arg"
|
||||
fi
|
||||
dest="$(abspath "$dest")"
|
||||
# A path argument with enough leading `..` to climb past the mirror root would
|
||||
# write outside the scratch dir. The real filesystem clamps such a path at
|
||||
# `/`; the mirror can't, so refuse rather than scribble outside the sandbox.
|
||||
case "$dest" in
|
||||
"$tmpdir"/*) ;;
|
||||
*)
|
||||
echo "vale-wrap.sh: refusing to lint '$arg': its scratch copy would land outside $tmpdir" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
mkdir -p "$(dirname "$dest")"
|
||||
if [[ -d "$arg" ]]; then
|
||||
# A directory is mirrored whole — vale applies its own format filtering to
|
||||
# the tree, so any file dropped here would be silently unlinted — and then
|
||||
# every markdown file in the copy is flattened in place. `.git` is pruned:
|
||||
# vale never lints it and copying it can dwarf the rest of the tree.
|
||||
mkdir -p "$dest"
|
||||
while IFS= read -r -d '' rel; do
|
||||
mkdir -p "$dest/$(dirname "$rel")"
|
||||
cp "$arg/$rel" "$dest/$rel"
|
||||
done < <(cd "$arg" && find . -name .git -prune -o -type f -print0)
|
||||
while IFS= read -r -d '' md; do
|
||||
flatten "$md" "$md"
|
||||
done < <(find "$dest" -type f -name '*.md' -print0)
|
||||
else
|
||||
flatten "$arg" "$dest"
|
||||
fi
|
||||
if [[ "$arg" == /* ]]; then
|
||||
argv_paths+=("$dest")
|
||||
else
|
||||
argv_paths+=("$arg")
|
||||
fi
|
||||
done
|
||||
|
||||
cd "$mirror"
|
||||
vale "${vale_args[@]}" "${argv_files[@]}"
|
||||
vale "${vale_args[@]}" "${argv_paths[@]}"
|
||||
|
||||
Reference in New Issue
Block a user