From 0f75f606d8f60fee4a471f67c156fcd0e498db62 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:10:33 -0400 Subject: [PATCH 1/5] perf(hook-utils): build the telemetry envelope and read file_path with builtins Phase 4b of the hook-performance program (#3623). With a telemetry sink wired, every guard that reached hook::emit_telemetry paid two jq processes, a mktemp and an rm per run, and hook::read_file_path cost a jq plus four realpath and two cygpath processes on every Write and Edit. On Windows Git Bash each spawn is tens of milliseconds. hook::emit_telemetry now assembles the envelope with shell builtins: hook::json_escape_jq_to escapes the string fields exactly as jq does and hook::json_compact_to compacts the caller's data object exactly as jq -c does, when that can be proven (jq's own output, pretty or compact, and the compact literal fallbacks the hooks carry all qualify). Anything it cannot prove (a \u or \/ escape, a raw control byte, a fraction or exponent, a non-object) goes to the jq path, which now runs jq -nc so both paths write the same bytes. The envelope is one compact line where it was jq's pretty-printed document before; the sink contract is one JSON document on stdin and the repo's own sink appends a byte-identical record for both forms. jq's absence is fail-open only on the fallback. hook::read_file_path reads stdin into the shell and takes .tool_input.file_path with hook::_fast_file_path_to when it can prove jq's answer: one string decoding to tool_input and one to file_path in the whole payload, tool_input a direct member of the root object whose value is a flat object, file_path a plain string inside it. Any other shape, a payload over 64 KiB, a NUL byte, or a non-ASCII \u escape falls back to the unchanged jq filter. The membership comparison then resolves the file, the project root and the temp roots with one batched realpath (hook::_physical_prime) into a per-process cache of plain arrays, and hook::under_temp_root reads the cache instead of resolving each candidate again. dirname is hook::dirname_to. Differential harness (origin/main lib vs this lib, separate processes): emit_telemetry 28 cases, 0 diffs (same document, new raw bytes equal jq -c of old, same sink record; 9 take the jq fallback); read_file_path 39 cases, identical stdout and status on all (7 take the jq fallback). typos-format PostToolUse Write with a sink: 24 external execs to 17, wall median 1130 ms to 861 ms. lib/hook-utils.test.sh PASS=278 FAIL=0. Co-Authored-By: Claude Fable 5.1 --- lib/hook-utils.sh | 766 ++++++++++++++++++++++++++++++++++++----- lib/hook-utils.test.sh | 267 +++++++++++++- 2 files changed, 948 insertions(+), 85 deletions(-) diff --git a/lib/hook-utils.sh b/lib/hook-utils.sh index 9818a6a73d..18e4dd5745 100644 --- a/lib/hook-utils.sh +++ b/lib/hook-utils.sh @@ -390,19 +390,34 @@ hook::require_jq_blocking() { # collapse with `/c/repo` and the membership guard would admit a sibling # outside CLAUDE_PROJECT_DIR. The result is used ONLY for comparison; the # emitted path is always the caller's original. -hook::normalize_path() { - local p="${1//\\//}" +# +# Two spellings of every path helper below: `hook::` prints the answer +# (the public, subshell-friendly form every caller already uses) and +# `hook::_to ` stores it in the caller's variable instead. +# The `_to` form is what the hot path uses: a `$(...)` capture is a fork, and +# on Windows Git Bash a fork costs milliseconds, so the file_path guard that +# ran a dozen of them per hook now runs none it does not need. The `_to` +# helpers keep their locals under a `__hu_` prefix so the caller's variable +# name cannot collide with them. +hook::normalize_path_to() { + local __hu_p="${2//\\//}" case "${OSTYPE:-}" in msys* | cygwin* | win32) - if [[ "$p" =~ ^/([a-zA-Z])/ || "$p" =~ ^([a-zA-Z]):/ ]]; then - local rest="${p:2}" - printf '%s' "${BASH_REMATCH[1]^}:${rest,,}" - return + if [[ "$__hu_p" =~ ^/([a-zA-Z])/ || "$__hu_p" =~ ^([a-zA-Z]):/ ]]; then + local __hu_rest="${__hu_p:2}" + printf -v "$1" '%s' "${BASH_REMATCH[1]^}:${__hu_rest,,}" + return 0 fi ;; *) ;; # POSIX hosts: case-sensitive FS, no drive fold — pass through below esac - printf '%s' "$p" + printf -v "$1" '%s' "$__hu_p" +} + +hook::normalize_path() { + local __hu_n + hook::normalize_path_to __hu_n "$1" + printf '%s' "$__hu_n" } # Expand Windows 8.3 short-name components (KYLESE~1 → KyleSexton) on @@ -424,25 +439,31 @@ hook::normalize_path() { # class: cygpath ships with Git Bash (the documented Windows bash), so its # absence or failure keeps the resolver's answer unchanged — degrading to the # pre-expansion comparison, same doctrine as the resolver fallback below. -hook::expand_8dot3() { - local p="$1" +hook::expand_8dot3_to() { + local __hu_p="$2" case "${OSTYPE:-}" in msys* | cygwin* | win32) ;; *) - printf '%s' "$p" - return + printf -v "$1" '%s' "$__hu_p" + return 0 ;; esac - if [[ "$p" == *~* ]] && command -v cygpath >/dev/null 2>&1; then - local plain long - if plain=$(cygpath -m -- "$p" 2>/dev/null) && - long=$(cygpath -l -m -- "$p" 2>/dev/null) && - [[ -n "$long" && "$long" != "$plain" ]]; then - printf '%s' "$long" - return + if [[ "$__hu_p" == *~* ]] && command -v cygpath >/dev/null 2>&1; then + local __hu_plain __hu_long + if __hu_plain=$(cygpath -m -- "$__hu_p" 2>/dev/null) && + __hu_long=$(cygpath -l -m -- "$__hu_p" 2>/dev/null) && + [[ -n "$__hu_long" && "$__hu_long" != "$__hu_plain" ]]; then + printf -v "$1" '%s' "$__hu_long" + return 0 fi fi - printf '%s' "$p" + printf -v "$1" '%s' "$__hu_p" +} + +hook::expand_8dot3() { + local __hu_e + hook::expand_8dot3_to __hu_e "$1" + printf '%s' "$__hu_e" } # Canonicalize to a physical path — symlinks resolved, Windows 8.3 short names @@ -462,20 +483,148 @@ hook::expand_8dot3() { # expansion applies only on the resolver's success path: consumers that fail # closed on an unresolved signature must not see a form-converted path instead. # shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED -hook::physical_path() { - local resolved +hook::physical_path_to() { + local __hu_r HOOK_PHYSICAL_PATH_UNRESOLVED=0 - if resolved=$(realpath -- "$1" 2>/dev/null) || resolved=$(readlink -f -- "$1" 2>/dev/null); then - if [[ -n "$resolved" ]]; then - hook::expand_8dot3 "$resolved" + if __hu_r=$(realpath -- "$2" 2>/dev/null) || __hu_r=$(readlink -f -- "$2" 2>/dev/null); then + if [[ -n "$__hu_r" ]]; then + hook::expand_8dot3_to "$1" "$__hu_r" return 0 fi fi HOOK_PHYSICAL_PATH_UNRESOLVED=1 - printf '%s' "$1" + printf -v "$1" '%s' "$2" + return 1 +} + +# shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED +hook::physical_path() { + local __hu_v __hu_rc=0 + hook::physical_path_to __hu_v "$1" || __hu_rc=$? + printf '%s' "$__hu_v" + return "$__hu_rc" +} + +# --- Per-process physical-path cache -------------------------------------- +# The membership guard resolves the same few directories on every call: the +# project root and this host's temp roots. Their physical form does not change +# within one hook process, so each spelling is resolved once and remembered in +# three parallel indexed arrays (keys, values, resolver status). Plain arrays, +# not an associative array, so the cache runs on Bash 3.2. Only directories are +# meant to live here for a process lifetime; hook::read_file_path forgets the +# edited file's entry as soon as it has read it, so a later call in the same +# process sees the file as it is then. +_HOOK_PHYS_KEYS=() +_HOOK_PHYS_VALS=() +_HOOK_PHYS_RCS=() +_HOOK_PHYS_I=-1 + +# hook::_phys_cache_index : sets _HOOK_PHYS_I to the slot holding , +# returns 1 when it is not cached. A forgotten slot has an empty key, which no +# real path can match. +hook::_phys_cache_index() { + local __hu_i + [[ -n "$1" ]] || return 1 + for ((__hu_i = 0; __hu_i < ${#_HOOK_PHYS_KEYS[@]}; __hu_i++)); do + if [[ "${_HOOK_PHYS_KEYS[__hu_i]}" == "$1" ]]; then + _HOOK_PHYS_I=$__hu_i + return 0 + fi + done return 1 } +hook::_phys_cache_forget() { + hook::_phys_cache_index "$1" && _HOOK_PHYS_KEYS[_HOOK_PHYS_I]="" + return 0 +} + +# hook::_physical_cached_to : hook::physical_path_to through the +# cache. Same value, same return status, same HOOK_PHYSICAL_PATH_UNRESOLVED as +# the uncached call; a miss resolves the one path and stores it. +# shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED +hook::_physical_cached_to() { + if hook::_phys_cache_index "$2"; then + printf -v "$1" '%s' "${_HOOK_PHYS_VALS[_HOOK_PHYS_I]}" + HOOK_PHYSICAL_PATH_UNRESOLVED=${_HOOK_PHYS_RCS[_HOOK_PHYS_I]} + return "${_HOOK_PHYS_RCS[_HOOK_PHYS_I]}" + fi + local __hu_v __hu_rc=0 + hook::physical_path_to __hu_v "$2" || __hu_rc=$? + if [[ -n "$2" ]]; then + _HOOK_PHYS_KEYS+=("$2") + _HOOK_PHYS_VALS+=("$__hu_v") + _HOOK_PHYS_RCS+=("$__hu_rc") + fi + printf -v "$1" '%s' "$__hu_v" + return "$__hu_rc" +} + +# hook::_physical_prime ...: resolve every uncached path with ONE +# realpath process and store the answers. realpath resolves each argument on +# its own and prints one line per argument in argument order, so the batch +# answer for a path is the same string the per-path call returns; the 8.3 +# expansion is applied per answer exactly as hook::physical_path_to does. Any +# doubt (a path carrying a newline, a non-zero exit, a line count that does not +# match) stores nothing and leaves the per-path resolver to answer lazily, so +# the batch can only ever save work, never change an answer. readlink -f hosts +# (no realpath) skip the batch for the same reason. +hook::_physical_prime() { + local -a __hu_todo=() + local __hu_p __hu_out __hu_i __hu_seen="" __hu_v + for __hu_p in "$@"; do + [[ -n "$__hu_p" ]] || continue + [[ "$__hu_p" == *$'\n'* ]] && return 0 + case "$__hu_seen" in + *"|$__hu_p|"*) continue ;; + *) ;; # first sighting + esac + __hu_seen="$__hu_seen|$__hu_p|" + hook::_phys_cache_index "$__hu_p" && continue + __hu_todo+=("$__hu_p") + done + ((${#__hu_todo[@]} > 1)) || return 0 + command -v realpath >/dev/null 2>&1 || return 0 + # portability-ok: realpath with several operands is GNU and BSD alike; a host whose realpath rejects it fails the exit-status check and falls back per path + __hu_out=$(realpath -- "${__hu_todo[@]}" 2>/dev/null) || return 0 + local -a __hu_lines=() + local __hu_glob=0 + [[ $- == *f* ]] || __hu_glob=1 + set -f + local IFS=$'\n' + # shellcheck disable=SC2206 # splitting realpath's one-line-per-operand output is the intent + __hu_lines=($__hu_out) + ((__hu_glob)) && set +f + ((${#__hu_lines[@]} == ${#__hu_todo[@]})) || return 0 + for ((__hu_i = 0; __hu_i < ${#__hu_todo[@]}; __hu_i++)); do + [[ -n "${__hu_lines[__hu_i]}" ]] || return 0 + hook::expand_8dot3_to __hu_v "${__hu_lines[__hu_i]}" + _HOOK_PHYS_KEYS+=("${__hu_todo[__hu_i]}") + _HOOK_PHYS_VALS+=("$__hu_v") + _HOOK_PHYS_RCS+=(0) + done + return 0 +} + +# The temp-root candidates hook::under_temp_root compares against: the +# environment's own answer (TMPDIR/TMP/TEMP) plus the POSIX defaults, never a +# hardcoded platform assumption. Existing directories only, each spelling +# once, in _HOOK_TEMP_CANDS. +_HOOK_TEMP_CANDS=() +hook::_temp_root_candidates() { + local __hu_cand __hu_seen="" + _HOOK_TEMP_CANDS=() + for __hu_cand in "${TMPDIR:-}" "${TMP:-}" "${TEMP:-}" /tmp /var/tmp; do + [[ -n "$__hu_cand" && -d "$__hu_cand" ]] || continue + case "$__hu_seen" in + *"|$__hu_cand|"*) continue ;; + *) ;; # first sighting of this candidate + esac + __hu_seen="$__hu_seen|$__hu_cand|" + _HOOK_TEMP_CANDS+=("$__hu_cand") + done +} + # True when sits inside one of this host's temp trees. # Both arguments and candidates go through the same canonicalize+normalize # pipeline as the membership comparison, because the same directory has several @@ -483,21 +632,40 @@ hook::physical_path() { # of the identical directory, and `realpath` resolves the Windows form to a # drive path while leaving `/tmp` as `/tmp`. Neither form alone matches a # `file_path` that could arrive in either, so every candidate is compared and a -# match on any one is a match. Duplicates are resolved once. +# match on any one is a match. Duplicates are resolved once, and each +# candidate's physical form is remembered for the process (see the cache +# above), so a second call costs no resolver process. # -# Candidates are the environment's own answer (TMPDIR/TMP/TEMP) plus the POSIX -# defaults, never a hardcoded platform assumption. +# Raw-spelling shortcut: when the caller vouches that is a +# PHYSICAL path (_HOOK_UTR_TARGET_PHYSICAL=1, set only by hook::read_file_path +# after the resolver succeeded), a candidate whose raw normalized spelling is +# already a prefix of the target is a match without resolving it. A physical +# path contains no symlink component, so a candidate that spells one of its +# prefixes names a chain of real directories whose physical form is that same +# prefix; the resolver would answer the same. A candidate that does not match +# raw is still resolved, so a symlinked temp root (macOS /tmp) is found the +# way it always was. Without the vouch the shortcut is off and every candidate +# is resolved, exactly as before. Note that hook::read_file_path primes every +# candidate into the cache with one batched realpath before calling here, so +# on that path the shortcut skips a lookup, not a process; the batch is what +# saves the processes. A direct caller without the batch saves the resolver. # hook::under_temp_root "$norm_path" && ... +_HOOK_UTR_TARGET_PHYSICAL=0 hook::under_temp_root() { - local target="$1" cand norm seen="" - for cand in "${TMPDIR:-}" "${TMP:-}" "${TEMP:-}" /tmp /var/tmp; do - [[ -n "$cand" && -d "$cand" ]] || continue - case "$seen" in - *"|$cand|"*) continue ;; - *) ;; # first sighting of this candidate — resolve it below - esac - seen="$seen|$cand|" - norm=$(hook::normalize_path "$(hook::physical_path "$cand")") + local target="$1" cand norm phys + hook::_temp_root_candidates + for cand in ${_HOOK_TEMP_CANDS[@]+"${_HOOK_TEMP_CANDS[@]}"}; do + if ((_HOOK_UTR_TARGET_PHYSICAL)); then + hook::normalize_path_to norm "$cand" + if [[ "$norm" != / ]]; then + norm="${norm%/}" + if [[ -n "$norm" && ("$target" == "$norm" || "$target" == "$norm"/*) ]]; then + return 0 + fi + fi + fi + hook::_physical_cached_to phys "$cand" || : + hook::normalize_path_to norm "$phys" # The filesystem root as a temp candidate contains every absolute path; # trimming its only slash would empty the candidate and discard it. [[ "$norm" == / ]] && return 0 @@ -523,21 +691,421 @@ hook::in_git_working_tree() { ) >/dev/null 2>&1 } +# --- Builtin JSON helpers ---------------------------------------------------- +# hook::read_file_path and hook::emit_telemetry used to spend a jq process each +# on every hook run. On Windows Git Bash one spawn costs tens of milliseconds, +# so the helpers here handle the well-formed shapes those two functions see in +# practice with shell builtins and hand anything they cannot PROVE to the +# unchanged jq path. The contract is "the bytes jq would produce, or fall +# back", never "close enough". +# +# What the builtin paths rely on: the text is valid JSON. Every hook payload +# reaching hook::read_file_path has passed hook::buffer_stdin's jq validation, +# and every telemetry data object is built with jq. The helpers still verify +# the structure they walk (terminated strings, well-formed tokens, properly +# nested brackets, one root, no raw control bytes inside strings), but they +# are not a full JSON parser and do not claim to reject every malformed text. +# +# Why no scanning: on this repo's Windows hosts bash regex matching and the +# `%%`/`##` pattern operators cost about a microsecond per character, so a +# 60 KB Write payload would cost more than the jq process they replace. The +# only string operations used on the whole payload are literal-substring +# replacement, `[[ == *x* ]]` containment, IFS word splitting and offset +# slicing, all of which run at C speed. + +# hook::_json_split +# Split at every unescaped double quote into the global array +# _HOOK_JSON_PARTS: even indices hold the structural text between strings, odd +# indices hold string bodies. Before splitting, every `\\` and `\"` pair is +# replaced by `@@` (same length), so a quote that survives is a real string +# delimiter and a body's ORIGINAL bytes are ${text:offset:length}. Returns 1 +# when the text is too large, its quotes do not pair up, or it holds more +# strings than the callers' loops are meant to walk. +_HOOK_JSON_PARTS=() +hook::_json_split() { + local __hu_s="$1" __hu_t __hu_q __hu_n + ((${#__hu_s} <= 65536)) || return 1 + __hu_t=${__hu_s//"\\\\"/@@} + __hu_t=${__hu_t//"\\\""/@@} + __hu_q=${__hu_t//\"/} + __hu_n=$((${#__hu_t} - ${#__hu_q})) + ((__hu_n % 2 == 0)) || return 1 + ((__hu_n <= 800)) || return 1 + local __hu_glob=0 + [[ $- == *f* ]] || __hu_glob=1 + set -f + local IFS='"' + # shellcheck disable=SC2206 # splitting on IFS='"' is the whole point + _HOOK_JSON_PARTS=($__hu_t) + ((__hu_glob)) && set +f + ((${#_HOOK_JSON_PARTS[@]} % 2 == 1)) || return 1 + return 0 +} + +# hook::_json_skeleton +# Split (hook::_json_split), record each string body's byte offset in +# _HOOK_JSON_OFF, and build _HOOK_JSON_SK: the structural text with every +# string replaced by a numbered placeholder `"#"` and all JSON +# whitespace removed. The skeleton is small (no string bodies), so regex and +# per-token work on it is cheap. Returns 1 unless the text is exactly one JSON +# value by the JSON grammar (well-formed tokens, whitespace only between them, +# objects as key:value lists, arrays as value lists, brackets nested and +# closed once) whose strings carry no raw control byte, which jq rejects. +_HOOK_JSON_SK="" +_HOOK_JSON_OFF=() +hook::_json_skeleton() { + local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top + hook::_json_split "$__hu_s" || return 1 + __hu_n=${#_HOOK_JSON_PARTS[@]} + _HOOK_JSON_OFF=() + for ((__hu_i = 0; __hu_i < __hu_n; __hu_i++)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + if ((__hu_i % 2 == 0)); then + __hu_sk+=$__hu_part + else + _HOOK_JSON_OFF[__hu_i]=$__hu_off + [[ "$__hu_part" == *[[:cntrl:]]* ]] && return 1 + __hu_sk+="\"#$__hu_i\"" + fi + __hu_off=$((__hu_off + ${#__hu_part} + 1)) + done + # Tokenize the skeleton and run it through the JSON grammar. Each token is + # consumed by one anchored regex on the (small) remainder; whitespace is + # allowed only between tokens, so `tr ue` is two bad tokens, not `true`. + local __hu_re=$'^[ \t\n\r]*(\\{|\\}|\\[|\\]|,|:|"#[0-9]+"|-?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][-+]?[0-9]+)?|true|false|null)' + local __hu_ws=$'^[ \t\n\r]*$' + __hu_rest=$__hu_sk + while [[ -n "$__hu_rest" ]] && ! [[ "$__hu_rest" =~ $__hu_ws ]]; do + [[ "$__hu_rest" =~ $__hu_re ]] || return 1 + __hu_tok=${BASH_REMATCH[1]} + __hu_rest=${__hu_rest:${#BASH_REMATCH[0]}} + __hu_top=${__hu_stack:${#__hu_stack}-1:1} + case "$__hu_expect" in + value | value_or_end) + case "$__hu_tok" in + '{') + __hu_stack+='{' + __hu_expect=key_or_end + continue + ;; + '[') + __hu_stack+='[' + __hu_expect=value_or_end + continue + ;; + ']') + [[ "$__hu_expect" == value_or_end ]] || return 1 + ;; + '}' | ',' | ':') return 1 ;; + *) + # A scalar. Inside a container the next token is , or the close; + # at the top level nothing may follow. + if [[ -n "$__hu_stack" ]]; then __hu_expect=comma_or_end; else __hu_expect=end; fi + continue + ;; + esac + ;; + key_or_end | key) + case "$__hu_tok" in + \"#*) + __hu_expect='colon' + continue + ;; + '}') [[ "$__hu_expect" == key_or_end ]] || return 1 ;; + *) return 1 ;; + esac + ;; + colon) + [[ "$__hu_tok" == ':' ]] || return 1 + __hu_expect=value + continue + ;; + comma_or_end) + case "$__hu_tok" in + ',') + if [[ "$__hu_top" == '{' ]]; then __hu_expect=key; else __hu_expect=value; fi + continue + ;; + '}') [[ "$__hu_top" == '{' ]] || return 1 ;; + ']') [[ "$__hu_top" == '[' ]] || return 1 ;; + *) return 1 ;; + esac + ;; + *) return 1 ;; # `end`: a token after the root value closed + esac + # Reaching here means a container just closed. + [[ -n "$__hu_stack" ]] || return 1 + __hu_stack=${__hu_stack:0:${#__hu_stack}-1} + if [[ -n "$__hu_stack" ]]; then __hu_expect=comma_or_end; else __hu_expect=end; fi + done + [[ "$__hu_expect" == end ]] || return 1 + _HOOK_JSON_SK=${__hu_sk//[$' \t\n\r']/} + return 0 +} + +# hook::json_unescape_to +# Decode a JSON string body (the bytes between the quotes) into . Handles +# \" \\ \/ \b \f \n \r \t and \uXXXX for U+0001 to U+007F. Returns 1 on any +# other escape (a \u outside that range, U+0000 which bash cannot hold, or an +# invalid escape), leaving the caller to fall back to jq. +hook::json_unescape_to() { + local __hu_raw="$2" __hu_out="" __hu_pre __hu_c __hu_hex __hu_oct __hu_ch + while [[ "$__hu_raw" == *\\* ]]; do + __hu_pre=${__hu_raw%%\\*} + __hu_out+=$__hu_pre + __hu_raw=${__hu_raw:${#__hu_pre}+1} + __hu_c=${__hu_raw:0:1} + __hu_raw=${__hu_raw:1} + case "$__hu_c" in + '"') __hu_out+='"' ;; + "\\") __hu_out+="\\" ;; + '/') __hu_out+='/' ;; + b) __hu_out+=$'\b' ;; # portability-ok: bash ANSI-C backspace byte, not a GNU grep word boundary + f) __hu_out+=$'\f' ;; + n) __hu_out+=$'\n' ;; + r) __hu_out+=$'\r' ;; + t) __hu_out+=$'\t' ;; + u) + __hu_hex=${__hu_raw:0:4} + [[ "$__hu_hex" =~ ^00[0-7][0-9a-fA-F]$ ]] || return 1 + [[ "$__hu_hex" == 0000 ]] && return 1 + __hu_raw=${__hu_raw:4} + printf -v __hu_oct '%03o' "$((16#$__hu_hex))" + printf -v __hu_ch '%b' "\\0$__hu_oct" + __hu_out+=$__hu_ch + ;; + *) return 1 ;; + esac + done + __hu_out+=$__hu_raw + printf -v "$1" '%s' "$__hu_out" +} + +# hook::_fast_file_path_to +# The builtin answer to `jq -r '(.tool_input.file_path // empty) | +# gsub("\r";"")'` followed by the `$(...)` capture. Returns +# 0 proven: holds exactly what jq would have printed +# 1 proven absent: jq would have printed nothing (the guard skips) +# 2 not proven: run jq +# Proof, on top of hook::_json_skeleton's structural checks: the root is an +# object; exactly one string in the whole payload decodes to `tool_input` and +# exactly one to `file_path` (so neither a duplicate key nor a same-named key +# in another object can change jq's answer); `tool_input` is a key of the root +# (depth 1, key position) whose value is a flat object (no nested object or +# array, else jq); and inside it `file_path` is a key with a plain string +# value. A payload where `file_path` names a value or sits in another object +# has no `.tool_input.file_path`, which jq reports as nothing. Key strings are +# compared AFTER decoding their escapes, so a key spelled with \u escapes is +# still recognized; a body longer than any escaped spelling of either name is +# skipped without decoding. +hook::_fast_file_path_to() { + local __hu_s="$2" __hu_i __hu_n __hu_part __hu_body __hu_ti=-1 __hu_fp=-1 __hu_m __hu_raw __hu_pre __hu_re __hu_file + hook::_json_skeleton "$__hu_s" || return 2 + [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 + __hu_n=${#_HOOK_JSON_PARTS[@]} + for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + ((${#__hu_part} <= 60)) || continue + __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} + if [[ "$__hu_body" == *\\* ]]; then + hook::json_unescape_to __hu_body "$__hu_body" || continue + fi + case "$__hu_body" in + tool_input) + ((__hu_ti < 0)) || return 2 + __hu_ti=$__hu_i + ;; + file_path) + ((__hu_fp < 0)) || return 2 + __hu_fp=$__hu_i + ;; + *) ;; + esac + done + ((__hu_fp >= 0)) || return 1 + ((__hu_ti >= 0)) || return 1 + __hu_re="(^|[{,])\"#$__hu_ti\":\\{([^][{}]*)\\}" + if [[ "$_HOOK_JSON_SK" =~ $__hu_re ]]; then + __hu_body=${BASH_REMATCH[2]} + __hu_pre=${_HOOK_JSON_SK%%\"#"$__hu_ti"\"*} + # Depth of the key: opening brackets minus closing ones before it must be + # exactly one, i.e. a direct member of the root object. + local __hu_o1=${__hu_pre//\{/} __hu_o2=${__hu_pre//\[/} __hu_c1=${__hu_pre//\}/} __hu_c2=${__hu_pre//\]/} + local __hu_depth=$(((${#__hu_pre} - ${#__hu_o1}) + (${#__hu_pre} - ${#__hu_o2}) - (${#__hu_pre} - ${#__hu_c1}) - (${#__hu_pre} - ${#__hu_c2}))) + ((__hu_depth == 1)) || return 2 + else + return 2 + fi + __hu_re="(^|,)\"#$__hu_fp\":\"#([0-9]+)\"(,|$)" + if [[ "$__hu_body" =~ $__hu_re ]]; then + __hu_m=${BASH_REMATCH[2]} + elif [[ "$__hu_body" == *\"#$__hu_fp\"* ]]; then + return 2 + else + return 1 + fi + __hu_raw=${__hu_s:${_HOOK_JSON_OFF[__hu_m]}:${#_HOOK_JSON_PARTS[__hu_m]}} + if [[ "$__hu_raw" == *\\* ]]; then + hook::json_unescape_to __hu_file "$__hu_raw" || return 2 + else + __hu_file=$__hu_raw + fi + __hu_file=${__hu_file//$'\r'/} + while [[ "$__hu_file" == *$'\n' ]]; do + __hu_file=${__hu_file%$'\n'} + done + printf -v "$1" '%s' "$__hu_file" + return 0 +} + +# hook::dirname_to : dirname with builtins for the resolver's +# answer. Strips the last segment; a bare name lives in `.`, a root-level file +# in `/`. The path comes from realpath, so the trailing-slash and doubled-slash +# spellings dirname also collapses never occur here. +hook::dirname_to() { + local __hu_d="${2%/*}" + [[ "$__hu_d" == "$2" ]] && __hu_d=. + [[ -n "$__hu_d" ]] || __hu_d=/ + printf -v "$1" '%s' "$__hu_d" +} + +# Print the arguments joined by NUL bytes, no trailing NUL: the byte stream +# hook::read_file_path read from stdin, rebuilt for the jq fallback. +hook::_print_nul_joined() { + local __hu_first=1 __hu_c + for __hu_c in "$@"; do + ((__hu_first)) || printf '\0' + __hu_first=0 + printf '%s' "$__hu_c" + done +} + +# hook::json_compact_to +# The bytes `jq -c .` prints for , when that can be proven with +# builtins: JSON whitespace between tokens removed, everything else verbatim. +# jq's own output, compact or pretty, satisfies the proof, which is what every +# telemetry data builder in this marketplace produces (jq -c, jq -n, or a +# compact literal). Returns 1 when the text is not a single object, or a +# string carries something jq would re-encode (a \u or \/ escape, an escape +# outside the JSON set, a raw control byte), or the structure fails +# hook::_json_skeleton; the caller then runs jq. +hook::json_compact_to() { + local __hu_s="$2" __hu_out="" __hu_i __hu_part __hu_n + hook::_json_skeleton "$__hu_s" || return 1 + [[ "$_HOOK_JSON_SK" == \{*\} ]] || return 1 + # Numbers: only short integer literals are passed through. A fraction, an + # exponent or an integer beyond 15 digits is rendered differently by + # different jq releases (1.6 canonicalizes, 1.7+ preserves the literal), so + # the bytes cannot be proven; jq decides. In the skeleton every string is a + # `"#N"` placeholder and letters occur only in true/false/null, so a digit + # followed by `.`, `e` or `E` can only be part of a number. + local __hu_num='[0-9][.eE]|[0-9]{16}' + [[ "$_HOOK_JSON_SK" =~ $__hu_num ]] && return 1 + __hu_n=${#_HOOK_JSON_PARTS[@]} + for ((__hu_i = 0; __hu_i < __hu_n; __hu_i++)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + if ((__hu_i % 2 == 0)); then + __hu_out+=${__hu_part//[$' \t\n\r']/} + else + if [[ "$__hu_part" == *\\* ]]; then + [[ "$__hu_part" == *'\u'* || "$__hu_part" == *'\/'* ]] && return 1 + [[ "$__hu_part" == *\\[!bfnrt]* ]] && return 1 + fi + __hu_out+="\"${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}}\"" + fi + done + printf -v "$1" '%s' "$__hu_out" +} + +# hook::json_escape_jq_to / hook::json_escape_jq +# Escape exactly as jq serializes a string value (without the +# quotes): backslash and double quote, the five short forms \b \f \n \r \t, +# every other byte from 0x01 to 0x1f and 0x7f as lowercase \u00xx, and +# everything else, including non-ASCII, verbatim. This is the escaper the +# telemetry envelope needs for byte parity with the jq envelope it replaced; +# hook::json_escape above is a different tool (it DROPS the residual control +# bytes, which is right for a notice and wrong for parity) and is unchanged. +hook::json_escape_jq_to() { + local __hu_s="$2" __hu_i __hu_c __hu_oct __hu_hex + __hu_s="${__hu_s//\\/\\\\}" + __hu_s="${__hu_s//\"/\\\"}" + if [[ "$__hu_s" == *[[:cntrl:]]* ]]; then + __hu_s="${__hu_s//$'\n'/\\n}" + __hu_s="${__hu_s//$'\r'/\\r}" + __hu_s="${__hu_s//$'\t'/\\t}" + __hu_s="${__hu_s//$'\b'/\\b}" # portability-ok: bash ANSI-C backspace byte and the JSON \b escape, not a GNU grep word boundary + __hu_s="${__hu_s//$'\f'/\\f}" + for __hu_i in 1 2 3 4 5 6 7 11 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 127; do + printf -v __hu_oct '%03o' "$__hu_i" + printf -v __hu_c '%b' "\\0$__hu_oct" + [[ "$__hu_s" == *"$__hu_c"* ]] || continue + printf -v __hu_hex '%04x' "$__hu_i" + __hu_s="${__hu_s//"$__hu_c"/\\u$__hu_hex}" + done + fi + printf -v "$1" '%s' "$__hu_s" +} + +hook::json_escape_jq() { + local __hu_e + hook::json_escape_jq_to __hu_e "$1" + printf '%s' "$__hu_e" +} + # Parse file_path from PostToolUse JSON on stdin; validate existence and (when # CLAUDE_PROJECT_DIR is set) project membership. Both sides of the membership # comparison are canonicalized (symlinks resolved) first, so neither an # escaping symlink nor a project root reached via a symlinked path (e.g. # macOS /tmp) skews the verdict. Outputs the path on success. Returns 1 to skip. # FILE=$(hook::read_file_path) || exit 0 +# +# jq-free on the common shape. The payload is read into the shell and +# hook::_fast_file_path_to takes `.tool_input.file_path` with builtins when it +# can PROVE the answer jq would give (see that helper for what "prove" means); +# every other payload goes to the unchanged jq filter. The path comparison then +# resolves the file, the project root and the temp roots with one realpath +# process instead of one each (hook::_physical_prime), and remembers the +# directories for the process. Same verdict, same emitted path, fewer +# processes: on Windows Git Bash each spawn costs tens of milliseconds and this +# guard runs on every Write and Edit. hook::read_file_path() { - local file - file=$(jq -r '(.tool_input.file_path // empty) | gsub("\r";"")' 2>/dev/null) + local -a chunks=() + local chunk file="" mode=2 + # Builtin read to NUL or EOF. A NUL splits the payload into several chunks; + # the fast path only takes a single-chunk (NUL-free) payload, and the jq + # fallback is fed the chunks NUL-joined, so it sees the bytes jq used to + # read straight from stdin. + while :; do + chunk="" + if IFS= read -r -d '' chunk; then + chunks+=("$chunk") + continue + fi + chunks+=("$chunk") + break + done + if ((${#chunks[@]} == 1)); then + mode=0 + hook::_fast_file_path_to file "${chunks[0]}" || mode=$? + fi + case "$mode" in + 0) ;; # proven: `file` holds jq's answer + 1) return 1 ;; # proven absent + *) + file=$(hook::_print_nul_joined "${chunks[@]}" | jq -r '(.tool_input.file_path // empty) | gsub("\r";"")' 2>/dev/null) + ;; + esac [[ -n "$file" ]] || return 1 [[ -f "$file" ]] || return 1 if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then - local norm_file norm_project - norm_file=$(hook::normalize_path "$(hook::physical_path "$file")") - norm_project=$(hook::normalize_path "$(hook::physical_path "${CLAUDE_PROJECT_DIR}")") + local norm_file norm_project phys_file phys_project file_resolved=0 project_resolved=0 + hook::_temp_root_candidates + hook::_physical_prime "$file" "${CLAUDE_PROJECT_DIR}" ${_HOOK_TEMP_CANDS[@]+"${_HOOK_TEMP_CANDS[@]}"} + hook::_physical_cached_to phys_file "$file" && file_resolved=1 + hook::_phys_cache_forget "$file" + hook::_physical_cached_to phys_project "${CLAUDE_PROJECT_DIR}" && project_resolved=1 + hook::normalize_path_to norm_file "$phys_file" + hook::normalize_path_to norm_project "$phys_project" norm_project="${norm_project%/}" # Anchor on a path-segment boundary: accept the project root itself or a # child under it, but not a sibling whose name merely shares the prefix @@ -558,19 +1126,28 @@ hook::read_file_path() { # built by `mktemp -d`, which is how this repo's own hook suites run), so # the branch must not fire. Only a temp-tree file reached from a project # root OUTSIDE the temp tree is scratch. - if hook::under_temp_root "$norm_file" && ! hook::under_temp_root "$norm_project"; then - return 1 + local file_in_temp=0 + _HOOK_UTR_TARGET_PHYSICAL=$file_resolved + hook::under_temp_root "$norm_file" && file_in_temp=1 + _HOOK_UTR_TARGET_PHYSICAL=0 + if ((file_in_temp)); then + local project_in_temp=0 + _HOOK_UTR_TARGET_PHYSICAL=$project_resolved + hook::under_temp_root "$norm_project" && project_in_temp=1 + _HOOK_UTR_TARGET_PHYSICAL=0 + ((project_in_temp)) || return 1 fi elif command -v git >/dev/null 2>&1; then # When CLAUDE_PROJECT_DIR is unset, scope to git-working-tree membership so # scratch files outside any repository are not mutated by formatter hooks # (#1091 / #972). - local file_physical - file_physical=$(hook::physical_path "$file") + local file_physical file_dir + hook::physical_path_to file_physical "$file" || : if [[ -L "$file" && "$file_physical" == "$file" ]]; then return 1 fi - if ! hook::in_git_working_tree "$(dirname "$file_physical")"; then + hook::dirname_to file_dir "$file_physical" + if ! hook::in_git_working_tree "$file_dir"; then return 1 fi else @@ -1228,7 +1805,19 @@ hook::telemetry_enabled() { # Fire-and-forget: sink is dispatched in the background; the hook never waits # on it and its failure never affects the hook's own exit code or stdout. # Opt-in guard: HOOK_TELEMETRY_SINK unset or empty → return 0 immediately. -# Fail-open: jq absent → return 0 immediately. +# +# The envelope is assembled with shell builtins (hook::json_escape_jq_to for +# the string fields, hook::json_compact_to for the caller's data object) as +# ONE compact line, `jq -c` style: the same document, field order and +# escapes the jq filter below produces, with the data object compacted +# exactly as jq compacts its own output. That path needs no jq, no temp file +# and no subprocess. When the data object cannot be proven jq-idempotent (see +# hook::json_compact_to), the jq path runs, now with -c so both paths write +# the same bytes, and there jq's absence is still fail-open: return 0, no +# envelope. Before this the envelope was jq's default pretty-printed form +# (several lines, and CRLF line endings with a Windows jq); the sink contract +# was always "one JSON document on stdin", and a compact line is also valid +# JSONL for a sink that appends raw envelopes. # # Usage: # hook::emit_telemetry [repo_root] @@ -1251,7 +1840,6 @@ hook::telemetry_enabled() { # NEVER writes to fd1 (the hook's stdout / additionalContext channel). hook::emit_telemetry() { [[ -n "${HOOK_TELEMETRY_SINK:-}" ]] || return 0 - command -v jq >/dev/null 2>&1 || return 0 local hook_id="$1" local hook_event="$2" @@ -1272,35 +1860,59 @@ hook::emit_telemetry() { local e_s="${now%[.,]*}" e_f="${now#*[.,]}" local duration_ms=$(((e_s * 1000000 + 10#$e_f - s_s * 1000000 - 10#$s_f) / 1000)) - # True UTC timestamp (TZ= prefix overrides LC_ALL / local TZ; the Z is not a lie). - local timestamp - timestamp=$(TZ=UTC printf '%(%Y-%m-%dT%H:%M:%SZ)T' -1) - - # Build the envelope. The `data` object is written to a temp file, not passed - # as --argjson, so payloads larger than the Windows 32767-character command-line - # cap are not dropped before jq runs (#1595). /dev/stdin is not used: jq's - # --slurpfile there is not portable on Windows. Redirect jq stderr to - # /dev/null; output goes to a local variable — never to fd1. - local envelope data_file - data_file=$(mktemp) || return 0 - printf '%s' "$data_json" >"$data_file" || { - rm -f "$data_file" - return 0 - } - envelope=$(jq -n \ - --arg schema_version "1.0" \ - --arg timestamp "$timestamp" \ - --arg hook "$hook_id" \ - --arg hook_event "$hook_event" \ - --arg status "$status" \ - --argjson duration_ms "$duration_ms" \ - --slurpfile data "$data_file" \ - '{schema_version:$schema_version,timestamp:$timestamp,hook:$hook,hook_event:$hook_event,status:$status,duration_ms:$duration_ms,data:($data[0] // {})}' \ - 2>/dev/null) || { + # True UTC timestamp (TZ= prefix overrides LC_ALL / local TZ; the Z is not a + # lie). The %()T printf format is Bash 4.2+, and printf -v keeps it in the + # shell (a `$(...)` capture is a fork); an older bash, or a printf that + # binds nothing, falls back to date -u. + local timestamp="" + TZ=UTC printf -v timestamp '%(%Y-%m-%dT%H:%M:%SZ)T' -1 2>/dev/null || timestamp="" + [[ -n "$timestamp" ]] || timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null) || timestamp="" + + # Trailing whitespace after the object (a newline a caller left on the + # value) does not change what jq parses, so it does not block the builtin + # path either. + local envelope="" data="" data_trim="$data_json" + while [[ "$data_trim" == *[$' \t\r\n'] ]]; do + data_trim="${data_trim%?}" + done + if [[ "$data_trim" == \{*\} ]] && hook::json_compact_to data "$data_trim"; then + # Builtin envelope: the same document the jq filter below produces, field + # for field, with jq's string escaping and jq's compact data. + local e_timestamp e_hook e_event e_status + hook::json_escape_jq_to e_timestamp "$timestamp" + hook::json_escape_jq_to e_hook "$hook_id" + hook::json_escape_jq_to e_event "$hook_event" + hook::json_escape_jq_to e_status "$status" + envelope='{"schema_version":"1.0","timestamp":"'"$e_timestamp"'","hook":"'"$e_hook"'","hook_event":"'"$e_event"'","status":"'"$e_status"'","duration_ms":'"$duration_ms"',"data":'"$data"'}' + else + # jq path, for a data object the builtin compactor cannot prove. Fail-open: + # jq absent → return 0. The `data` object is written to a temp file, not + # passed as --argjson, so payloads larger than the Windows 32767-character + # command-line cap are not dropped before jq runs (#1595). /dev/stdin is + # not used: jq's --slurpfile there is not portable on Windows. Redirect jq + # stderr to /dev/null; output goes to a local variable — never to fd1. + command -v jq >/dev/null 2>&1 || return 0 + local data_file + data_file=$(mktemp) || return 0 + printf '%s' "$data_json" >"$data_file" || { + rm -f "$data_file" + return 0 + } + envelope=$(jq -nc \ + --arg schema_version "1.0" \ + --arg timestamp "$timestamp" \ + --arg hook "$hook_id" \ + --arg hook_event "$hook_event" \ + --arg status "$status" \ + --argjson duration_ms "$duration_ms" \ + --slurpfile data "$data_file" \ + '{schema_version:$schema_version,timestamp:$timestamp,hook:$hook,hook_event:$hook_event,status:$status,duration_ms:$duration_ms,data:($data[0] // {})}' \ + 2>/dev/null) || { + rm -f "$data_file" + return 0 + } rm -f "$data_file" - return 0 - } - rm -f "$data_file" + fi # Resolve the sink path. A relative HOOK_TELEMETRY_SINK is joined onto the # consuming repo root (portable, tracked wiring); absolute is used as-is. A diff --git a/lib/hook-utils.test.sh b/lib/hook-utils.test.sh index 5dde31f250..cad7259c13 100755 --- a/lib/hook-utils.test.sh +++ b/lib/hook-utils.test.sh @@ -72,19 +72,33 @@ else fail "sink unset: unexpected output: $out" fi -# --- Test 2: jq absent → fail-open (returns 0, no output) ------------------- -# Make `command -v jq` genuinely fail by running with a PATH that contains no -# jq. A shell-function shadow does NOT exercise the guard: `command -v jq` -# reports a defined function as present, so the absence branch is never taken. -# emit_telemetry uses only shell builtins until its jq calls, so an empty PATH -# is sufficient. Scoped to the command so PATH/sink never leak into the suite. +# --- Test 2: jq absent → the envelope is still delivered --------------------- +# This case used to assert the opposite ("jq absent returns 0 with no output"): +# the envelope was built by a jq process, so no jq meant no telemetry. The +# envelope is now assembled with shell builtins and jq is only the fallback +# for a data object the builtin compactor cannot prove, so with jq gone from +# PATH a plain envelope must still reach the sink. Two probes: +# (a) PATH holds nothing at all. The sink stub uses an absolute shebang and +# only builtins, so the delivery cannot lean on any external program. +# (b) a shell function shadows `jq` and fails loudly; the envelope arriving +# intact proves the builtin path never invoked it. +# Both keep the fail-open contract on stdout: return 0, nothing on fd1. EMPTY_BIN="$(mktemp -d)" +SINK_FILE2="$(mktemp)" +STUB2="$(mktemp)" +cat >"$STUB2" <"$SINK_FILE2" +EOF +chmod +x "$STUB2" # shellcheck disable=SC2030,SC2031 out_nojq=$( - export HOOK_TELEMETRY_SINK="cat" + export HOOK_TELEMETRY_SINK="$STUB2" PATH="$EMPTY_BIN" hook::emit_telemetry "sample-hook" "PostToolUse" "ok" "$EPOCHREALTIME" '{"tool":"Write","file":"foo.py","findings":[]}' 2>/dev/null + wait ) rc_nojq=$? +wait_for_sink "$SINK_FILE2" || true rmdir "$EMPTY_BIN" 2>/dev/null || true if [[ $rc_nojq -eq 0 ]]; then ok "jq absent: returns 0" @@ -92,10 +106,33 @@ else fail "jq absent: expected 0, got $rc_nojq" fi if [[ -z "$out_nojq" ]]; then - ok "jq absent: no output" + ok "jq absent: nothing on stdout" else fail "jq absent: unexpected output: $out_nojq" fi +if [[ -s "$SINK_FILE2" ]] && grep -q '"hook":"sample-hook","hook_event":"PostToolUse","status":"ok"' "$SINK_FILE2"; then + ok "jq absent (empty PATH): envelope still delivered by the builtin path" +else + fail "jq absent (empty PATH): envelope not delivered: $(cat "$SINK_FILE2" 2>/dev/null)" +fi +: >"$SINK_FILE2" +# shellcheck disable=SC2030,SC2031,SC2329 # subshell-local by design; jq is invoked through the lib +( + jq() { + echo "jq was invoked on the builtin path" >&2 + return 127 + } + export HOOK_TELEMETRY_SINK="$STUB2" + hook::emit_telemetry "sample-hook" "PostToolUse" "ok" "$EPOCHREALTIME" '{"tool":"Write","file":"foo.py","findings":[]}' + wait +) 2>"$SINK_FILE2.err" +wait_for_sink "$SINK_FILE2" || true +if [[ -s "$SINK_FILE2" ]] && ! grep -q 'jq was invoked' "$SINK_FILE2.err"; then + ok "jq shadowed: envelope delivered without invoking jq" +else + fail "jq shadowed: delivered=$([[ -s "$SINK_FILE2" ]] && echo yes || echo no) stderr=$(cat "$SINK_FILE2.err")" +fi +rm -f "$SINK_FILE2" "$SINK_FILE2.err" "$STUB2" # --- Test 3: envelope shape matches schema (7 required common fields + data) -- SINK_FILE="$(mktemp)" @@ -711,6 +748,220 @@ else fail "json_escape: wrong residual-C0 handling: $(printf '%q' "$esc2")" fi +# --- Test 14b: hook::json_escape_jq matches jq's own string escaping ---------- +# The telemetry envelope's string fields are escaped by this function instead +# of by jq; the corpus covers every class jq treats specially (backslash, +# quote, the five short-form controls, the other C0 bytes, DEL) plus the +# classes it passes through (non-ASCII, slash). +corpus14b=( + 'plain' + 'he said "hi" \ path' + $'tab\tnl\ncr\rbs\bff\f' # portability-ok: bash ANSI-C backspace byte, not a GNU grep word boundary + $'esc\033one\001us\037del\177' + 'slash/and é😀 and -- dash' + '' +) +for s in "${corpus14b[@]}"; do + want=$(jq -cn --arg s "$s" '$s' | tr -d '\r') + got="\"$(hook::json_escape_jq "$s")\"" + if [[ "$got" == "$want" ]]; then + ok "json_escape_jq: matches jq for $(printf '%q' "${s:0:24}")" + else + fail "json_escape_jq: bash=$got jq=$want" + fi +done + +# --- Test 14c: hook::json_compact_to matches jq -c on jq's own output --------- +# The data object spliced into the envelope must be the bytes jq would print. +# jq -c output, jq -n (pretty) output and the compact literal fallbacks the +# hooks carry all compact identically; anything the compactor cannot prove +# (a \u or \/ escape, a raw control byte, a non-object, invalid JSON) returns 1 +# so the caller runs jq instead. +compact_is() { # + local got="" + if hook::json_compact_to got "$2" && [[ "$got" == "$3" ]]; then + ok "json_compact_to: $1" + else + fail "json_compact_to ($1): got [$got] want [$3]" + fi +} +compact_refuses() { # + local got="" + if hook::json_compact_to got "$2"; then + fail "json_compact_to ($1): accepted [$got], must fall back to jq" + else + ok "json_compact_to: $1 falls back" + fi +} +pretty14c=$(jq -n --arg tool Bash --arg subject 'git commit -m "x"' --arg form '' '{tool:$tool,subject:$subject,form:$form,n:[1,2,{a:null,b:true}],e:{},f:[]}') +compact_is "pretty jq -n object" "$pretty14c" "$(jq -c . <<<"$pretty14c" | tr -d '\r')" +compact_is "compact jq -c object is unchanged" '{"tool":"Write","file":"a b.py","findings":["x: [1] {2}"]}' '{"tool":"Write","file":"a b.py","findings":["x: [1] {2}"]}' +compact_is "escaped quotes and backslashes kept verbatim" '{ "p": "C:\\repo\\a \"q\"" }' '{"p":"C:\\repo\\a \"q\""}' +compact_is "empty object" '{}' '{}' +compact_is "integer literals verbatim" '{ "n": -15, "m": 0, "k": 123456789012345 }' '{"n":-15,"m":0,"k":123456789012345}' +compact_refuses "fraction (jq releases render it differently)" '{"k":1.0}' +compact_refuses "exponent" '{"n":-1.5e3}' +compact_refuses "16-digit integer" '{"big":1234567890123456}' +compact_refuses "unicode escape" '{"a":"\u00e9"}' +compact_refuses "escaped slash" '{"a":"x\/y"}' +compact_refuses "raw control byte in a string" "$(printf '{"a":"x\001y"}')" +compact_refuses "array root" '[1,2]' +compact_refuses "trailing comma" '{"a":1,}' +compact_refuses "bad literal" '{"a":tru}' +compact_refuses "split literal" '{"a":tr ue}' +compact_refuses "unterminated string" '{"a":"x}' + +# --- Test 3b: builtin envelope is jq's compact rendering, byte for byte ------- +# The sink receives exactly one line, and re-rendering it with jq -c yields the +# same bytes: field order, spacing and escapes are jq's. Also: the fallback +# fires (and delivers, through jq) for a data object the compactor refuses. +SINK3B="$(mktemp)" +STUB3B="$(make_sink "$SINK3B")" +hook_event3b=$'Post\tTool\001Use' +# shellcheck disable=SC2030,SC2031 # subshell-local by design +( + export HOOK_TELEMETRY_SINK="$STUB3B" + hook::emit_telemetry "sam\"ple" "$hook_event3b" 'ok é' "$EPOCHREALTIME" "$pretty14c" 2>/dev/null + wait +) +wait_for_sink "$SINK3B" || true +if [[ -s "$SINK3B" ]]; then + lines3b=$(wc -l <"$SINK3B" | tr -d ' ') + rerender=$(jq -c . "$SINK3B" 2>/dev/null | tr -d '\r') + raw=$(tr -d '\r' <"$SINK3B") + if [[ "$lines3b" == 1 && "$raw" == "$rerender" ]]; then + ok "envelope: one compact line, byte-identical to jq -c of itself" + else + fail "envelope: lines=$lines3b raw=[$raw] jq -c=[$rerender]" + fi + if [[ "$raw" == '{"schema_version":"1.0","timestamp":"'*'","hook":"sam\"ple","hook_event":"Post\tTool\u0001Use","status":"ok é","duration_ms":'*',"data":{"tool":"Bash","subject":"git commit -m \"x\"","form":"","n":[1,2,{"a":null,"b":true}],"e":{},"f":[]}}' ]]; then + ok "envelope: field order, escapes and compacted data are jq's" + else + fail "envelope: unexpected bytes: $raw" + fi +else + fail "envelope (3b): sink empty" + fail "envelope (3b): bytes not verifiable" +fi +: >"$SINK3B" +# shellcheck disable=SC2030,SC2031,SC2329 # subshell-local by design; jq is invoked through the lib +( + # The lib discards jq's stderr, so the shadow records its invocation in a + # marker file instead. + jq() { + echo "fallback invoked jq" >"$SINK3B.err" + return 127 + } + export HOOK_TELEMETRY_SINK="$STUB3B" + hook::emit_telemetry "sample-hook" "PostToolUse" "ok" "$EPOCHREALTIME" '{"a":"\u00e9"}' 2>/dev/null + wait +) +if grep -q 'fallback invoked jq' "$SINK3B.err" 2>/dev/null && [[ ! -s "$SINK3B" ]]; then + ok "envelope: unprovable data falls back to jq (and fails open when jq fails)" +else + fail "envelope fallback: marker=$(cat "$SINK3B.err" 2>/dev/null) sink=$(cat "$SINK3B")" +fi +: >"$SINK3B" +# shellcheck disable=SC2030,SC2031 # subshell-local by design +( + export HOOK_TELEMETRY_SINK="$STUB3B" + hook::emit_telemetry "sample-hook" "PostToolUse" "ok" "$EPOCHREALTIME" '{"a":"\u00e9"}' 2>/dev/null + wait +) +wait_for_sink "$SINK3B" || true +if [[ "$(jq -r '.data.a' "$SINK3B" 2>/dev/null)" == "é" ]]; then + ok "envelope: the jq fallback delivers the decoded data" +else + fail "envelope fallback with jq: $(cat "$SINK3B")" +fi +rm -f "$SINK3B" "$SINK3B.err" "$STUB3B" + +# --- Test 12g: read_file_path fast path answers exactly what jq answers ------- +# hook::_fast_file_path_to is compared with the jq filter it replaces on a +# corpus of payload shapes: 0 must carry jq's value, 1 must mean jq printed +# nothing, and 2 (fall back) is always allowed. Includes the shapes that must +# NOT be taken from the wrong place: a top-level file_path, a tool_input +# nested in another object, two keys, a non-string value. +fast_is_jq() { # + local got="" rc=0 want + hook::_fast_file_path_to got "$2" || rc=$? + want=$(printf '%s' "$2" | jq -r '(.tool_input.file_path // empty) | gsub("\r";"")' 2>/dev/null) + case "$rc" in + 0) if [[ "$got" == "$want" ]]; then ok "fast file_path: $1 (proven: $(printf '%q' "$got"))"; else fail "fast file_path ($1): got [$got] jq [$want]"; fi ;; + 1) if [[ -z "$want" ]]; then ok "fast file_path: $1 (proven absent)"; else fail "fast file_path ($1): said absent, jq says [$want]"; fi ;; + 2) ok "fast file_path: $1 (falls back to jq)" ;; + *) fail "fast file_path ($1): rc=$rc" ;; + esac +} +fast_is_jq "Write payload" '{"session_id":"s","tool_name":"Write","tool_input":{"file_path":"src/app.py","content":"x = {\"a\": [1,2]}\n"},"tool_response":{"filePath":"src/app.py","success":true}}' +fast_is_jq "Edit payload with escapes" '{"tool_input":{"file_path":"C:\\repo\\a \"b\".md","old_string":"a\nb","new_string":"","replace_all":false}}' +fast_is_jq "pretty-printed payload" "$(jq -n '{tool_input:{file_path:"docs/x.md"}}')" +fast_is_jq "ASCII unicode escape in the value" '{"tool_input":{"file_path":"\u0041.md"}}' +fast_is_jq "trailing CR and newline stripped" '{"tool_input":{"file_path":"x.md\r\n"}}' +fast_is_jq "keys spelled with unicode escapes" '{"tool\u005finput":{"file\u005fpath":"esc.md"}}' +fast_is_jq "non-ASCII value" '{"tool_input":{"file_path":"é/日本.md"}}' +fast_is_jq "empty value" '{"tool_input":{"file_path":""}}' +fast_is_jq "top-level file_path only" '{"file_path":"top.md","tool_input":{"content":"y"}}' +fast_is_jq "tool_input nested in another object" '{"outer":{"tool_input":{"file_path":"deep.md"}}}' +fast_is_jq "file_path as a string value, not a key" '{"tool_input":{"content":"x"},"y":"file_path"}' +fast_is_jq "no file_path (Bash payload)" '{"tool_name":"Bash","tool_input":{"command":"ls"}}' +fast_is_jq "two file_path keys" '{"file_path":"top.md","tool_input":{"file_path":"in.md"}}' +fast_is_jq "nested object inside tool_input" '{"tool_input":{"file_path":"in.md","meta":{"a":1}}}' +fast_is_jq "non-string value" '{"tool_input":{"file_path":123}}' +fast_is_jq "non-ASCII unicode escape" '{"tool_input":{"file_path":"\u00e9.md"}}' +fast_is_jq "truncated payload" '{"tool_input":{"file_path":"in.md"},"tool_response":{"filePath' +fast_is_jq "array root" '[{"tool_input":{"file_path":"arr.md"}}]' +# The top-level key must never be taken: the case above proves parity with jq +# (absent); this pins the verdict itself. +rc12g=0 +hook::_fast_file_path_to got12g '{"file_path":"top.md","tool_input":{"content":"y"}}' || rc12g=$? +if [[ "$rc12g" -eq 1 ]]; then + ok "fast file_path: top-level file_path is proven absent, never taken" +else + fail "fast file_path: top-level file_path rc=$rc12g got=[${got12g:-}]" +fi +# The size threshold: a payload over 64 KiB is handed to jq unconditionally. +# Built in the shell, not through jq's argv: a 70 KB argument to a native +# Windows jq is truncated by the process command-line cap (see Test 19b), and +# a truncated payload would fall back for the wrong reason. The size is +# asserted before the verdict so a short payload cannot pass vacuously. +printf -v pad12g 'y%.0s' $(seq 1 70000) +big12g="{\"tool_input\":{\"file_path\":\"big.md\",\"content\":\"$pad12g\"}}" +rc12g=0 +hook::_fast_file_path_to got12g "$big12g" || rc12g=$? +if ((${#big12g} > 65536)) && [[ "$rc12g" -eq 2 ]]; then + ok "fast file_path: ${#big12g}-byte payload falls back to jq" +else + fail "fast file_path: ${#big12g}-byte payload rc=$rc12g (want 2 on a payload over 65536 bytes)" +fi +# Just under the cap, the same shape is proven by the fast path. +printf -v pad12g 'y%.0s' $(seq 1 60000) +big12g="{\"tool_input\":{\"file_path\":\"big.md\",\"content\":\"$pad12g\"}}" +rc12g=0 +hook::_fast_file_path_to got12g "$big12g" || rc12g=$? +if ((${#big12g} <= 65536)) && [[ "$rc12g" -eq 0 && "$got12g" == "big.md" ]]; then + ok "fast file_path: ${#big12g}-byte payload under the cap is proven by the fast path" +else + fail "fast file_path: ${#big12g}-byte payload rc=$rc12g got=[${got12g:-}] (want 0/big.md)" +fi +unset got12g rc12g big12g pad12g + +# --- Test 12h: hook::dirname_to, the builtin dirname of a resolver answer ----- +dirname_is() { # + local got="" + hook::dirname_to got "$1" + if [[ "$got" == "$2" ]]; then + ok "dirname_to: $1 -> $2" + else + fail "dirname_to: $1 -> [$got] want [$2]" + fi +} +dirname_is /a/b/c.md /a/b +dirname_is /c.md / +dirname_is c.md . +dirname_is C:/repo/x.md C:/repo +dirname_is /c/x.md /c + # --- Test 15: hook::emit_skip_notice — valid JSON, both channels, jq-free ----- notice=$(hook::emit_skip_notice PostToolUse 'my-plugin: tool "x" missing — skipped') if jq -e '.hookSpecificOutput.hookEventName == "PostToolUse" From 8a97debbc15e50b55ab3065fe2867256e3f30036 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:11:37 -0400 Subject: [PATCH 2/5] chore(plugins): sync hook-utils.sh to the 17 carriers and bump each scripts/sync-hook-utils.sh copies the builtin telemetry envelope and the builtin file_path reader into every carrying plugin; each carrier's manifest moves one patch above origin/main so the update cache delivers the change, with a CHANGELOG entry naming the shared-library change. Phase 4b of the hook-performance program (#3623). Co-Authored-By: Claude Fable 5.1 --- plugins/actionlint/.claude-plugin/plugin.json | 2 +- plugins/actionlint/CHANGELOG.md | 16 + plugins/actionlint/hooks/hook-utils.sh | 766 ++++++++++++++++-- plugins/autonomy/.claude-plugin/plugin.json | 2 +- plugins/autonomy/CHANGELOG.md | 16 + plugins/autonomy/hooks/hook-utils.sh | 766 ++++++++++++++++-- .../bash-format/.claude-plugin/plugin.json | 2 +- plugins/bash-format/CHANGELOG.md | 16 + plugins/bash-format/hooks/hook-utils.sh | 766 ++++++++++++++++-- .../biome-format/.claude-plugin/plugin.json | 2 +- plugins/biome-format/CHANGELOG.md | 16 + plugins/biome-format/hooks/hook-utils.sh | 766 ++++++++++++++++-- plugins/claude-ops/.claude-plugin/plugin.json | 2 +- plugins/claude-ops/CHANGELOG.md | 16 + plugins/claude-ops/hooks/hook-utils.sh | 766 ++++++++++++++++-- .../context-guard/.claude-plugin/plugin.json | 2 +- plugins/context-guard/CHANGELOG.md | 16 + plugins/context-guard/hooks/hook-utils.sh | 766 ++++++++++++++++-- .../.claude-plugin/plugin.json | 2 +- plugins/desktop-notification/CHANGELOG.md | 16 + .../desktop-notification/hooks/hook-utils.sh | 766 ++++++++++++++++-- .../eol-normalizer/.claude-plugin/plugin.json | 2 +- plugins/eol-normalizer/CHANGELOG.md | 16 + plugins/eol-normalizer/hooks/hook-utils.sh | 766 ++++++++++++++++-- plugins/go-format/.claude-plugin/plugin.json | 2 +- plugins/go-format/CHANGELOG.md | 16 + plugins/go-format/hooks/hook-utils.sh | 766 ++++++++++++++++-- plugins/guardrails/.claude-plugin/plugin.json | 2 +- plugins/guardrails/CHANGELOG.md | 16 + plugins/guardrails/hooks/hook-utils.sh | 766 ++++++++++++++++-- .../.claude-plugin/plugin.json | 2 +- plugins/instruction-placement/CHANGELOG.md | 16 + .../instruction-placement/hooks/hook-utils.sh | 766 ++++++++++++++++-- .../.claude-plugin/plugin.json | 2 +- plugins/markdown-format/CHANGELOG.md | 16 + plugins/markdown-format/hooks/hook-utils.sh | 766 ++++++++++++++++-- .../.claude-plugin/plugin.json | 2 +- plugins/powershell-format/CHANGELOG.md | 16 + plugins/powershell-format/hooks/hook-utils.sh | 766 ++++++++++++++++-- .../.claude-plugin/plugin.json | 2 +- plugins/rate-limit-guard/CHANGELOG.md | 16 + plugins/rate-limit-guard/hooks/hook-utils.sh | 766 ++++++++++++++++-- .../ruff-format/.claude-plugin/plugin.json | 2 +- plugins/ruff-format/CHANGELOG.md | 16 + plugins/ruff-format/hooks/hook-utils.sh | 766 ++++++++++++++++-- .../source-control/.claude-plugin/plugin.json | 2 +- plugins/source-control/CHANGELOG.md | 16 + plugins/source-control/hooks/hook-utils.sh | 766 ++++++++++++++++-- .../typos-format/.claude-plugin/plugin.json | 2 +- plugins/typos-format/CHANGELOG.md | 16 + plugins/typos-format/hooks/hook-utils.sh | 766 ++++++++++++++++-- 51 files changed, 12002 insertions(+), 1326 deletions(-) diff --git a/plugins/actionlint/.claude-plugin/plugin.json b/plugins/actionlint/.claude-plugin/plugin.json index 1a93be9c66..0b7c6f4efe 100644 --- a/plugins/actionlint/.claude-plugin/plugin.json +++ b/plugins/actionlint/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "actionlint", - "version": "0.8.29", + "version": "0.8.30", "description": "Lint GitHub Actions workflow files on edit via actionlint, surfacing findings as advisory context.", "author": { "name": "Melodic Software", diff --git a/plugins/actionlint/CHANGELOG.md b/plugins/actionlint/CHANGELOG.md index 5e69aead32..ff5c4e58cf 100644 --- a/plugins/actionlint/CHANGELOG.md +++ b/plugins/actionlint/CHANGELOG.md @@ -3,6 +3,22 @@ All notable changes to the `actionlint` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.8.30] + +### Changed + +- **Vendored `hook-utils.sh` builds the telemetry envelope and reads `file_path` + with shell builtins.** `hook::emit_telemetry` no longer spawns two jq + processes, a mktemp and an rm per run: the envelope is assembled in the shell + as one compact line (the same document jq produced, now `jq -c` shaped), with + jq kept only as the fallback for a data object the builtin compactor cannot + prove. `hook::read_file_path` takes `.tool_input.file_path` without jq on the + well-formed payload shape and resolves the file, project root and temp roots + with one batched `realpath` instead of one process each. Same verdicts, same + emitted path, same sink record; phase 4b of the hook-performance program + (#3623). The copy is bumped because `scripts/sync-hook-utils.sh` keeps every + carrying plugin byte-identical. + ## [0.8.29] ### Changed diff --git a/plugins/actionlint/hooks/hook-utils.sh b/plugins/actionlint/hooks/hook-utils.sh index 9818a6a73d..18e4dd5745 100644 --- a/plugins/actionlint/hooks/hook-utils.sh +++ b/plugins/actionlint/hooks/hook-utils.sh @@ -390,19 +390,34 @@ hook::require_jq_blocking() { # collapse with `/c/repo` and the membership guard would admit a sibling # outside CLAUDE_PROJECT_DIR. The result is used ONLY for comparison; the # emitted path is always the caller's original. -hook::normalize_path() { - local p="${1//\\//}" +# +# Two spellings of every path helper below: `hook::` prints the answer +# (the public, subshell-friendly form every caller already uses) and +# `hook::_to ` stores it in the caller's variable instead. +# The `_to` form is what the hot path uses: a `$(...)` capture is a fork, and +# on Windows Git Bash a fork costs milliseconds, so the file_path guard that +# ran a dozen of them per hook now runs none it does not need. The `_to` +# helpers keep their locals under a `__hu_` prefix so the caller's variable +# name cannot collide with them. +hook::normalize_path_to() { + local __hu_p="${2//\\//}" case "${OSTYPE:-}" in msys* | cygwin* | win32) - if [[ "$p" =~ ^/([a-zA-Z])/ || "$p" =~ ^([a-zA-Z]):/ ]]; then - local rest="${p:2}" - printf '%s' "${BASH_REMATCH[1]^}:${rest,,}" - return + if [[ "$__hu_p" =~ ^/([a-zA-Z])/ || "$__hu_p" =~ ^([a-zA-Z]):/ ]]; then + local __hu_rest="${__hu_p:2}" + printf -v "$1" '%s' "${BASH_REMATCH[1]^}:${__hu_rest,,}" + return 0 fi ;; *) ;; # POSIX hosts: case-sensitive FS, no drive fold — pass through below esac - printf '%s' "$p" + printf -v "$1" '%s' "$__hu_p" +} + +hook::normalize_path() { + local __hu_n + hook::normalize_path_to __hu_n "$1" + printf '%s' "$__hu_n" } # Expand Windows 8.3 short-name components (KYLESE~1 → KyleSexton) on @@ -424,25 +439,31 @@ hook::normalize_path() { # class: cygpath ships with Git Bash (the documented Windows bash), so its # absence or failure keeps the resolver's answer unchanged — degrading to the # pre-expansion comparison, same doctrine as the resolver fallback below. -hook::expand_8dot3() { - local p="$1" +hook::expand_8dot3_to() { + local __hu_p="$2" case "${OSTYPE:-}" in msys* | cygwin* | win32) ;; *) - printf '%s' "$p" - return + printf -v "$1" '%s' "$__hu_p" + return 0 ;; esac - if [[ "$p" == *~* ]] && command -v cygpath >/dev/null 2>&1; then - local plain long - if plain=$(cygpath -m -- "$p" 2>/dev/null) && - long=$(cygpath -l -m -- "$p" 2>/dev/null) && - [[ -n "$long" && "$long" != "$plain" ]]; then - printf '%s' "$long" - return + if [[ "$__hu_p" == *~* ]] && command -v cygpath >/dev/null 2>&1; then + local __hu_plain __hu_long + if __hu_plain=$(cygpath -m -- "$__hu_p" 2>/dev/null) && + __hu_long=$(cygpath -l -m -- "$__hu_p" 2>/dev/null) && + [[ -n "$__hu_long" && "$__hu_long" != "$__hu_plain" ]]; then + printf -v "$1" '%s' "$__hu_long" + return 0 fi fi - printf '%s' "$p" + printf -v "$1" '%s' "$__hu_p" +} + +hook::expand_8dot3() { + local __hu_e + hook::expand_8dot3_to __hu_e "$1" + printf '%s' "$__hu_e" } # Canonicalize to a physical path — symlinks resolved, Windows 8.3 short names @@ -462,20 +483,148 @@ hook::expand_8dot3() { # expansion applies only on the resolver's success path: consumers that fail # closed on an unresolved signature must not see a form-converted path instead. # shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED -hook::physical_path() { - local resolved +hook::physical_path_to() { + local __hu_r HOOK_PHYSICAL_PATH_UNRESOLVED=0 - if resolved=$(realpath -- "$1" 2>/dev/null) || resolved=$(readlink -f -- "$1" 2>/dev/null); then - if [[ -n "$resolved" ]]; then - hook::expand_8dot3 "$resolved" + if __hu_r=$(realpath -- "$2" 2>/dev/null) || __hu_r=$(readlink -f -- "$2" 2>/dev/null); then + if [[ -n "$__hu_r" ]]; then + hook::expand_8dot3_to "$1" "$__hu_r" return 0 fi fi HOOK_PHYSICAL_PATH_UNRESOLVED=1 - printf '%s' "$1" + printf -v "$1" '%s' "$2" + return 1 +} + +# shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED +hook::physical_path() { + local __hu_v __hu_rc=0 + hook::physical_path_to __hu_v "$1" || __hu_rc=$? + printf '%s' "$__hu_v" + return "$__hu_rc" +} + +# --- Per-process physical-path cache -------------------------------------- +# The membership guard resolves the same few directories on every call: the +# project root and this host's temp roots. Their physical form does not change +# within one hook process, so each spelling is resolved once and remembered in +# three parallel indexed arrays (keys, values, resolver status). Plain arrays, +# not an associative array, so the cache runs on Bash 3.2. Only directories are +# meant to live here for a process lifetime; hook::read_file_path forgets the +# edited file's entry as soon as it has read it, so a later call in the same +# process sees the file as it is then. +_HOOK_PHYS_KEYS=() +_HOOK_PHYS_VALS=() +_HOOK_PHYS_RCS=() +_HOOK_PHYS_I=-1 + +# hook::_phys_cache_index : sets _HOOK_PHYS_I to the slot holding , +# returns 1 when it is not cached. A forgotten slot has an empty key, which no +# real path can match. +hook::_phys_cache_index() { + local __hu_i + [[ -n "$1" ]] || return 1 + for ((__hu_i = 0; __hu_i < ${#_HOOK_PHYS_KEYS[@]}; __hu_i++)); do + if [[ "${_HOOK_PHYS_KEYS[__hu_i]}" == "$1" ]]; then + _HOOK_PHYS_I=$__hu_i + return 0 + fi + done return 1 } +hook::_phys_cache_forget() { + hook::_phys_cache_index "$1" && _HOOK_PHYS_KEYS[_HOOK_PHYS_I]="" + return 0 +} + +# hook::_physical_cached_to : hook::physical_path_to through the +# cache. Same value, same return status, same HOOK_PHYSICAL_PATH_UNRESOLVED as +# the uncached call; a miss resolves the one path and stores it. +# shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED +hook::_physical_cached_to() { + if hook::_phys_cache_index "$2"; then + printf -v "$1" '%s' "${_HOOK_PHYS_VALS[_HOOK_PHYS_I]}" + HOOK_PHYSICAL_PATH_UNRESOLVED=${_HOOK_PHYS_RCS[_HOOK_PHYS_I]} + return "${_HOOK_PHYS_RCS[_HOOK_PHYS_I]}" + fi + local __hu_v __hu_rc=0 + hook::physical_path_to __hu_v "$2" || __hu_rc=$? + if [[ -n "$2" ]]; then + _HOOK_PHYS_KEYS+=("$2") + _HOOK_PHYS_VALS+=("$__hu_v") + _HOOK_PHYS_RCS+=("$__hu_rc") + fi + printf -v "$1" '%s' "$__hu_v" + return "$__hu_rc" +} + +# hook::_physical_prime ...: resolve every uncached path with ONE +# realpath process and store the answers. realpath resolves each argument on +# its own and prints one line per argument in argument order, so the batch +# answer for a path is the same string the per-path call returns; the 8.3 +# expansion is applied per answer exactly as hook::physical_path_to does. Any +# doubt (a path carrying a newline, a non-zero exit, a line count that does not +# match) stores nothing and leaves the per-path resolver to answer lazily, so +# the batch can only ever save work, never change an answer. readlink -f hosts +# (no realpath) skip the batch for the same reason. +hook::_physical_prime() { + local -a __hu_todo=() + local __hu_p __hu_out __hu_i __hu_seen="" __hu_v + for __hu_p in "$@"; do + [[ -n "$__hu_p" ]] || continue + [[ "$__hu_p" == *$'\n'* ]] && return 0 + case "$__hu_seen" in + *"|$__hu_p|"*) continue ;; + *) ;; # first sighting + esac + __hu_seen="$__hu_seen|$__hu_p|" + hook::_phys_cache_index "$__hu_p" && continue + __hu_todo+=("$__hu_p") + done + ((${#__hu_todo[@]} > 1)) || return 0 + command -v realpath >/dev/null 2>&1 || return 0 + # portability-ok: realpath with several operands is GNU and BSD alike; a host whose realpath rejects it fails the exit-status check and falls back per path + __hu_out=$(realpath -- "${__hu_todo[@]}" 2>/dev/null) || return 0 + local -a __hu_lines=() + local __hu_glob=0 + [[ $- == *f* ]] || __hu_glob=1 + set -f + local IFS=$'\n' + # shellcheck disable=SC2206 # splitting realpath's one-line-per-operand output is the intent + __hu_lines=($__hu_out) + ((__hu_glob)) && set +f + ((${#__hu_lines[@]} == ${#__hu_todo[@]})) || return 0 + for ((__hu_i = 0; __hu_i < ${#__hu_todo[@]}; __hu_i++)); do + [[ -n "${__hu_lines[__hu_i]}" ]] || return 0 + hook::expand_8dot3_to __hu_v "${__hu_lines[__hu_i]}" + _HOOK_PHYS_KEYS+=("${__hu_todo[__hu_i]}") + _HOOK_PHYS_VALS+=("$__hu_v") + _HOOK_PHYS_RCS+=(0) + done + return 0 +} + +# The temp-root candidates hook::under_temp_root compares against: the +# environment's own answer (TMPDIR/TMP/TEMP) plus the POSIX defaults, never a +# hardcoded platform assumption. Existing directories only, each spelling +# once, in _HOOK_TEMP_CANDS. +_HOOK_TEMP_CANDS=() +hook::_temp_root_candidates() { + local __hu_cand __hu_seen="" + _HOOK_TEMP_CANDS=() + for __hu_cand in "${TMPDIR:-}" "${TMP:-}" "${TEMP:-}" /tmp /var/tmp; do + [[ -n "$__hu_cand" && -d "$__hu_cand" ]] || continue + case "$__hu_seen" in + *"|$__hu_cand|"*) continue ;; + *) ;; # first sighting of this candidate + esac + __hu_seen="$__hu_seen|$__hu_cand|" + _HOOK_TEMP_CANDS+=("$__hu_cand") + done +} + # True when sits inside one of this host's temp trees. # Both arguments and candidates go through the same canonicalize+normalize # pipeline as the membership comparison, because the same directory has several @@ -483,21 +632,40 @@ hook::physical_path() { # of the identical directory, and `realpath` resolves the Windows form to a # drive path while leaving `/tmp` as `/tmp`. Neither form alone matches a # `file_path` that could arrive in either, so every candidate is compared and a -# match on any one is a match. Duplicates are resolved once. +# match on any one is a match. Duplicates are resolved once, and each +# candidate's physical form is remembered for the process (see the cache +# above), so a second call costs no resolver process. # -# Candidates are the environment's own answer (TMPDIR/TMP/TEMP) plus the POSIX -# defaults, never a hardcoded platform assumption. +# Raw-spelling shortcut: when the caller vouches that is a +# PHYSICAL path (_HOOK_UTR_TARGET_PHYSICAL=1, set only by hook::read_file_path +# after the resolver succeeded), a candidate whose raw normalized spelling is +# already a prefix of the target is a match without resolving it. A physical +# path contains no symlink component, so a candidate that spells one of its +# prefixes names a chain of real directories whose physical form is that same +# prefix; the resolver would answer the same. A candidate that does not match +# raw is still resolved, so a symlinked temp root (macOS /tmp) is found the +# way it always was. Without the vouch the shortcut is off and every candidate +# is resolved, exactly as before. Note that hook::read_file_path primes every +# candidate into the cache with one batched realpath before calling here, so +# on that path the shortcut skips a lookup, not a process; the batch is what +# saves the processes. A direct caller without the batch saves the resolver. # hook::under_temp_root "$norm_path" && ... +_HOOK_UTR_TARGET_PHYSICAL=0 hook::under_temp_root() { - local target="$1" cand norm seen="" - for cand in "${TMPDIR:-}" "${TMP:-}" "${TEMP:-}" /tmp /var/tmp; do - [[ -n "$cand" && -d "$cand" ]] || continue - case "$seen" in - *"|$cand|"*) continue ;; - *) ;; # first sighting of this candidate — resolve it below - esac - seen="$seen|$cand|" - norm=$(hook::normalize_path "$(hook::physical_path "$cand")") + local target="$1" cand norm phys + hook::_temp_root_candidates + for cand in ${_HOOK_TEMP_CANDS[@]+"${_HOOK_TEMP_CANDS[@]}"}; do + if ((_HOOK_UTR_TARGET_PHYSICAL)); then + hook::normalize_path_to norm "$cand" + if [[ "$norm" != / ]]; then + norm="${norm%/}" + if [[ -n "$norm" && ("$target" == "$norm" || "$target" == "$norm"/*) ]]; then + return 0 + fi + fi + fi + hook::_physical_cached_to phys "$cand" || : + hook::normalize_path_to norm "$phys" # The filesystem root as a temp candidate contains every absolute path; # trimming its only slash would empty the candidate and discard it. [[ "$norm" == / ]] && return 0 @@ -523,21 +691,421 @@ hook::in_git_working_tree() { ) >/dev/null 2>&1 } +# --- Builtin JSON helpers ---------------------------------------------------- +# hook::read_file_path and hook::emit_telemetry used to spend a jq process each +# on every hook run. On Windows Git Bash one spawn costs tens of milliseconds, +# so the helpers here handle the well-formed shapes those two functions see in +# practice with shell builtins and hand anything they cannot PROVE to the +# unchanged jq path. The contract is "the bytes jq would produce, or fall +# back", never "close enough". +# +# What the builtin paths rely on: the text is valid JSON. Every hook payload +# reaching hook::read_file_path has passed hook::buffer_stdin's jq validation, +# and every telemetry data object is built with jq. The helpers still verify +# the structure they walk (terminated strings, well-formed tokens, properly +# nested brackets, one root, no raw control bytes inside strings), but they +# are not a full JSON parser and do not claim to reject every malformed text. +# +# Why no scanning: on this repo's Windows hosts bash regex matching and the +# `%%`/`##` pattern operators cost about a microsecond per character, so a +# 60 KB Write payload would cost more than the jq process they replace. The +# only string operations used on the whole payload are literal-substring +# replacement, `[[ == *x* ]]` containment, IFS word splitting and offset +# slicing, all of which run at C speed. + +# hook::_json_split +# Split at every unescaped double quote into the global array +# _HOOK_JSON_PARTS: even indices hold the structural text between strings, odd +# indices hold string bodies. Before splitting, every `\\` and `\"` pair is +# replaced by `@@` (same length), so a quote that survives is a real string +# delimiter and a body's ORIGINAL bytes are ${text:offset:length}. Returns 1 +# when the text is too large, its quotes do not pair up, or it holds more +# strings than the callers' loops are meant to walk. +_HOOK_JSON_PARTS=() +hook::_json_split() { + local __hu_s="$1" __hu_t __hu_q __hu_n + ((${#__hu_s} <= 65536)) || return 1 + __hu_t=${__hu_s//"\\\\"/@@} + __hu_t=${__hu_t//"\\\""/@@} + __hu_q=${__hu_t//\"/} + __hu_n=$((${#__hu_t} - ${#__hu_q})) + ((__hu_n % 2 == 0)) || return 1 + ((__hu_n <= 800)) || return 1 + local __hu_glob=0 + [[ $- == *f* ]] || __hu_glob=1 + set -f + local IFS='"' + # shellcheck disable=SC2206 # splitting on IFS='"' is the whole point + _HOOK_JSON_PARTS=($__hu_t) + ((__hu_glob)) && set +f + ((${#_HOOK_JSON_PARTS[@]} % 2 == 1)) || return 1 + return 0 +} + +# hook::_json_skeleton +# Split (hook::_json_split), record each string body's byte offset in +# _HOOK_JSON_OFF, and build _HOOK_JSON_SK: the structural text with every +# string replaced by a numbered placeholder `"#"` and all JSON +# whitespace removed. The skeleton is small (no string bodies), so regex and +# per-token work on it is cheap. Returns 1 unless the text is exactly one JSON +# value by the JSON grammar (well-formed tokens, whitespace only between them, +# objects as key:value lists, arrays as value lists, brackets nested and +# closed once) whose strings carry no raw control byte, which jq rejects. +_HOOK_JSON_SK="" +_HOOK_JSON_OFF=() +hook::_json_skeleton() { + local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top + hook::_json_split "$__hu_s" || return 1 + __hu_n=${#_HOOK_JSON_PARTS[@]} + _HOOK_JSON_OFF=() + for ((__hu_i = 0; __hu_i < __hu_n; __hu_i++)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + if ((__hu_i % 2 == 0)); then + __hu_sk+=$__hu_part + else + _HOOK_JSON_OFF[__hu_i]=$__hu_off + [[ "$__hu_part" == *[[:cntrl:]]* ]] && return 1 + __hu_sk+="\"#$__hu_i\"" + fi + __hu_off=$((__hu_off + ${#__hu_part} + 1)) + done + # Tokenize the skeleton and run it through the JSON grammar. Each token is + # consumed by one anchored regex on the (small) remainder; whitespace is + # allowed only between tokens, so `tr ue` is two bad tokens, not `true`. + local __hu_re=$'^[ \t\n\r]*(\\{|\\}|\\[|\\]|,|:|"#[0-9]+"|-?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][-+]?[0-9]+)?|true|false|null)' + local __hu_ws=$'^[ \t\n\r]*$' + __hu_rest=$__hu_sk + while [[ -n "$__hu_rest" ]] && ! [[ "$__hu_rest" =~ $__hu_ws ]]; do + [[ "$__hu_rest" =~ $__hu_re ]] || return 1 + __hu_tok=${BASH_REMATCH[1]} + __hu_rest=${__hu_rest:${#BASH_REMATCH[0]}} + __hu_top=${__hu_stack:${#__hu_stack}-1:1} + case "$__hu_expect" in + value | value_or_end) + case "$__hu_tok" in + '{') + __hu_stack+='{' + __hu_expect=key_or_end + continue + ;; + '[') + __hu_stack+='[' + __hu_expect=value_or_end + continue + ;; + ']') + [[ "$__hu_expect" == value_or_end ]] || return 1 + ;; + '}' | ',' | ':') return 1 ;; + *) + # A scalar. Inside a container the next token is , or the close; + # at the top level nothing may follow. + if [[ -n "$__hu_stack" ]]; then __hu_expect=comma_or_end; else __hu_expect=end; fi + continue + ;; + esac + ;; + key_or_end | key) + case "$__hu_tok" in + \"#*) + __hu_expect='colon' + continue + ;; + '}') [[ "$__hu_expect" == key_or_end ]] || return 1 ;; + *) return 1 ;; + esac + ;; + colon) + [[ "$__hu_tok" == ':' ]] || return 1 + __hu_expect=value + continue + ;; + comma_or_end) + case "$__hu_tok" in + ',') + if [[ "$__hu_top" == '{' ]]; then __hu_expect=key; else __hu_expect=value; fi + continue + ;; + '}') [[ "$__hu_top" == '{' ]] || return 1 ;; + ']') [[ "$__hu_top" == '[' ]] || return 1 ;; + *) return 1 ;; + esac + ;; + *) return 1 ;; # `end`: a token after the root value closed + esac + # Reaching here means a container just closed. + [[ -n "$__hu_stack" ]] || return 1 + __hu_stack=${__hu_stack:0:${#__hu_stack}-1} + if [[ -n "$__hu_stack" ]]; then __hu_expect=comma_or_end; else __hu_expect=end; fi + done + [[ "$__hu_expect" == end ]] || return 1 + _HOOK_JSON_SK=${__hu_sk//[$' \t\n\r']/} + return 0 +} + +# hook::json_unescape_to +# Decode a JSON string body (the bytes between the quotes) into . Handles +# \" \\ \/ \b \f \n \r \t and \uXXXX for U+0001 to U+007F. Returns 1 on any +# other escape (a \u outside that range, U+0000 which bash cannot hold, or an +# invalid escape), leaving the caller to fall back to jq. +hook::json_unescape_to() { + local __hu_raw="$2" __hu_out="" __hu_pre __hu_c __hu_hex __hu_oct __hu_ch + while [[ "$__hu_raw" == *\\* ]]; do + __hu_pre=${__hu_raw%%\\*} + __hu_out+=$__hu_pre + __hu_raw=${__hu_raw:${#__hu_pre}+1} + __hu_c=${__hu_raw:0:1} + __hu_raw=${__hu_raw:1} + case "$__hu_c" in + '"') __hu_out+='"' ;; + "\\") __hu_out+="\\" ;; + '/') __hu_out+='/' ;; + b) __hu_out+=$'\b' ;; # portability-ok: bash ANSI-C backspace byte, not a GNU grep word boundary + f) __hu_out+=$'\f' ;; + n) __hu_out+=$'\n' ;; + r) __hu_out+=$'\r' ;; + t) __hu_out+=$'\t' ;; + u) + __hu_hex=${__hu_raw:0:4} + [[ "$__hu_hex" =~ ^00[0-7][0-9a-fA-F]$ ]] || return 1 + [[ "$__hu_hex" == 0000 ]] && return 1 + __hu_raw=${__hu_raw:4} + printf -v __hu_oct '%03o' "$((16#$__hu_hex))" + printf -v __hu_ch '%b' "\\0$__hu_oct" + __hu_out+=$__hu_ch + ;; + *) return 1 ;; + esac + done + __hu_out+=$__hu_raw + printf -v "$1" '%s' "$__hu_out" +} + +# hook::_fast_file_path_to +# The builtin answer to `jq -r '(.tool_input.file_path // empty) | +# gsub("\r";"")'` followed by the `$(...)` capture. Returns +# 0 proven: holds exactly what jq would have printed +# 1 proven absent: jq would have printed nothing (the guard skips) +# 2 not proven: run jq +# Proof, on top of hook::_json_skeleton's structural checks: the root is an +# object; exactly one string in the whole payload decodes to `tool_input` and +# exactly one to `file_path` (so neither a duplicate key nor a same-named key +# in another object can change jq's answer); `tool_input` is a key of the root +# (depth 1, key position) whose value is a flat object (no nested object or +# array, else jq); and inside it `file_path` is a key with a plain string +# value. A payload where `file_path` names a value or sits in another object +# has no `.tool_input.file_path`, which jq reports as nothing. Key strings are +# compared AFTER decoding their escapes, so a key spelled with \u escapes is +# still recognized; a body longer than any escaped spelling of either name is +# skipped without decoding. +hook::_fast_file_path_to() { + local __hu_s="$2" __hu_i __hu_n __hu_part __hu_body __hu_ti=-1 __hu_fp=-1 __hu_m __hu_raw __hu_pre __hu_re __hu_file + hook::_json_skeleton "$__hu_s" || return 2 + [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 + __hu_n=${#_HOOK_JSON_PARTS[@]} + for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + ((${#__hu_part} <= 60)) || continue + __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} + if [[ "$__hu_body" == *\\* ]]; then + hook::json_unescape_to __hu_body "$__hu_body" || continue + fi + case "$__hu_body" in + tool_input) + ((__hu_ti < 0)) || return 2 + __hu_ti=$__hu_i + ;; + file_path) + ((__hu_fp < 0)) || return 2 + __hu_fp=$__hu_i + ;; + *) ;; + esac + done + ((__hu_fp >= 0)) || return 1 + ((__hu_ti >= 0)) || return 1 + __hu_re="(^|[{,])\"#$__hu_ti\":\\{([^][{}]*)\\}" + if [[ "$_HOOK_JSON_SK" =~ $__hu_re ]]; then + __hu_body=${BASH_REMATCH[2]} + __hu_pre=${_HOOK_JSON_SK%%\"#"$__hu_ti"\"*} + # Depth of the key: opening brackets minus closing ones before it must be + # exactly one, i.e. a direct member of the root object. + local __hu_o1=${__hu_pre//\{/} __hu_o2=${__hu_pre//\[/} __hu_c1=${__hu_pre//\}/} __hu_c2=${__hu_pre//\]/} + local __hu_depth=$(((${#__hu_pre} - ${#__hu_o1}) + (${#__hu_pre} - ${#__hu_o2}) - (${#__hu_pre} - ${#__hu_c1}) - (${#__hu_pre} - ${#__hu_c2}))) + ((__hu_depth == 1)) || return 2 + else + return 2 + fi + __hu_re="(^|,)\"#$__hu_fp\":\"#([0-9]+)\"(,|$)" + if [[ "$__hu_body" =~ $__hu_re ]]; then + __hu_m=${BASH_REMATCH[2]} + elif [[ "$__hu_body" == *\"#$__hu_fp\"* ]]; then + return 2 + else + return 1 + fi + __hu_raw=${__hu_s:${_HOOK_JSON_OFF[__hu_m]}:${#_HOOK_JSON_PARTS[__hu_m]}} + if [[ "$__hu_raw" == *\\* ]]; then + hook::json_unescape_to __hu_file "$__hu_raw" || return 2 + else + __hu_file=$__hu_raw + fi + __hu_file=${__hu_file//$'\r'/} + while [[ "$__hu_file" == *$'\n' ]]; do + __hu_file=${__hu_file%$'\n'} + done + printf -v "$1" '%s' "$__hu_file" + return 0 +} + +# hook::dirname_to : dirname with builtins for the resolver's +# answer. Strips the last segment; a bare name lives in `.`, a root-level file +# in `/`. The path comes from realpath, so the trailing-slash and doubled-slash +# spellings dirname also collapses never occur here. +hook::dirname_to() { + local __hu_d="${2%/*}" + [[ "$__hu_d" == "$2" ]] && __hu_d=. + [[ -n "$__hu_d" ]] || __hu_d=/ + printf -v "$1" '%s' "$__hu_d" +} + +# Print the arguments joined by NUL bytes, no trailing NUL: the byte stream +# hook::read_file_path read from stdin, rebuilt for the jq fallback. +hook::_print_nul_joined() { + local __hu_first=1 __hu_c + for __hu_c in "$@"; do + ((__hu_first)) || printf '\0' + __hu_first=0 + printf '%s' "$__hu_c" + done +} + +# hook::json_compact_to +# The bytes `jq -c .` prints for , when that can be proven with +# builtins: JSON whitespace between tokens removed, everything else verbatim. +# jq's own output, compact or pretty, satisfies the proof, which is what every +# telemetry data builder in this marketplace produces (jq -c, jq -n, or a +# compact literal). Returns 1 when the text is not a single object, or a +# string carries something jq would re-encode (a \u or \/ escape, an escape +# outside the JSON set, a raw control byte), or the structure fails +# hook::_json_skeleton; the caller then runs jq. +hook::json_compact_to() { + local __hu_s="$2" __hu_out="" __hu_i __hu_part __hu_n + hook::_json_skeleton "$__hu_s" || return 1 + [[ "$_HOOK_JSON_SK" == \{*\} ]] || return 1 + # Numbers: only short integer literals are passed through. A fraction, an + # exponent or an integer beyond 15 digits is rendered differently by + # different jq releases (1.6 canonicalizes, 1.7+ preserves the literal), so + # the bytes cannot be proven; jq decides. In the skeleton every string is a + # `"#N"` placeholder and letters occur only in true/false/null, so a digit + # followed by `.`, `e` or `E` can only be part of a number. + local __hu_num='[0-9][.eE]|[0-9]{16}' + [[ "$_HOOK_JSON_SK" =~ $__hu_num ]] && return 1 + __hu_n=${#_HOOK_JSON_PARTS[@]} + for ((__hu_i = 0; __hu_i < __hu_n; __hu_i++)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + if ((__hu_i % 2 == 0)); then + __hu_out+=${__hu_part//[$' \t\n\r']/} + else + if [[ "$__hu_part" == *\\* ]]; then + [[ "$__hu_part" == *'\u'* || "$__hu_part" == *'\/'* ]] && return 1 + [[ "$__hu_part" == *\\[!bfnrt]* ]] && return 1 + fi + __hu_out+="\"${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}}\"" + fi + done + printf -v "$1" '%s' "$__hu_out" +} + +# hook::json_escape_jq_to / hook::json_escape_jq +# Escape exactly as jq serializes a string value (without the +# quotes): backslash and double quote, the five short forms \b \f \n \r \t, +# every other byte from 0x01 to 0x1f and 0x7f as lowercase \u00xx, and +# everything else, including non-ASCII, verbatim. This is the escaper the +# telemetry envelope needs for byte parity with the jq envelope it replaced; +# hook::json_escape above is a different tool (it DROPS the residual control +# bytes, which is right for a notice and wrong for parity) and is unchanged. +hook::json_escape_jq_to() { + local __hu_s="$2" __hu_i __hu_c __hu_oct __hu_hex + __hu_s="${__hu_s//\\/\\\\}" + __hu_s="${__hu_s//\"/\\\"}" + if [[ "$__hu_s" == *[[:cntrl:]]* ]]; then + __hu_s="${__hu_s//$'\n'/\\n}" + __hu_s="${__hu_s//$'\r'/\\r}" + __hu_s="${__hu_s//$'\t'/\\t}" + __hu_s="${__hu_s//$'\b'/\\b}" # portability-ok: bash ANSI-C backspace byte and the JSON \b escape, not a GNU grep word boundary + __hu_s="${__hu_s//$'\f'/\\f}" + for __hu_i in 1 2 3 4 5 6 7 11 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 127; do + printf -v __hu_oct '%03o' "$__hu_i" + printf -v __hu_c '%b' "\\0$__hu_oct" + [[ "$__hu_s" == *"$__hu_c"* ]] || continue + printf -v __hu_hex '%04x' "$__hu_i" + __hu_s="${__hu_s//"$__hu_c"/\\u$__hu_hex}" + done + fi + printf -v "$1" '%s' "$__hu_s" +} + +hook::json_escape_jq() { + local __hu_e + hook::json_escape_jq_to __hu_e "$1" + printf '%s' "$__hu_e" +} + # Parse file_path from PostToolUse JSON on stdin; validate existence and (when # CLAUDE_PROJECT_DIR is set) project membership. Both sides of the membership # comparison are canonicalized (symlinks resolved) first, so neither an # escaping symlink nor a project root reached via a symlinked path (e.g. # macOS /tmp) skews the verdict. Outputs the path on success. Returns 1 to skip. # FILE=$(hook::read_file_path) || exit 0 +# +# jq-free on the common shape. The payload is read into the shell and +# hook::_fast_file_path_to takes `.tool_input.file_path` with builtins when it +# can PROVE the answer jq would give (see that helper for what "prove" means); +# every other payload goes to the unchanged jq filter. The path comparison then +# resolves the file, the project root and the temp roots with one realpath +# process instead of one each (hook::_physical_prime), and remembers the +# directories for the process. Same verdict, same emitted path, fewer +# processes: on Windows Git Bash each spawn costs tens of milliseconds and this +# guard runs on every Write and Edit. hook::read_file_path() { - local file - file=$(jq -r '(.tool_input.file_path // empty) | gsub("\r";"")' 2>/dev/null) + local -a chunks=() + local chunk file="" mode=2 + # Builtin read to NUL or EOF. A NUL splits the payload into several chunks; + # the fast path only takes a single-chunk (NUL-free) payload, and the jq + # fallback is fed the chunks NUL-joined, so it sees the bytes jq used to + # read straight from stdin. + while :; do + chunk="" + if IFS= read -r -d '' chunk; then + chunks+=("$chunk") + continue + fi + chunks+=("$chunk") + break + done + if ((${#chunks[@]} == 1)); then + mode=0 + hook::_fast_file_path_to file "${chunks[0]}" || mode=$? + fi + case "$mode" in + 0) ;; # proven: `file` holds jq's answer + 1) return 1 ;; # proven absent + *) + file=$(hook::_print_nul_joined "${chunks[@]}" | jq -r '(.tool_input.file_path // empty) | gsub("\r";"")' 2>/dev/null) + ;; + esac [[ -n "$file" ]] || return 1 [[ -f "$file" ]] || return 1 if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then - local norm_file norm_project - norm_file=$(hook::normalize_path "$(hook::physical_path "$file")") - norm_project=$(hook::normalize_path "$(hook::physical_path "${CLAUDE_PROJECT_DIR}")") + local norm_file norm_project phys_file phys_project file_resolved=0 project_resolved=0 + hook::_temp_root_candidates + hook::_physical_prime "$file" "${CLAUDE_PROJECT_DIR}" ${_HOOK_TEMP_CANDS[@]+"${_HOOK_TEMP_CANDS[@]}"} + hook::_physical_cached_to phys_file "$file" && file_resolved=1 + hook::_phys_cache_forget "$file" + hook::_physical_cached_to phys_project "${CLAUDE_PROJECT_DIR}" && project_resolved=1 + hook::normalize_path_to norm_file "$phys_file" + hook::normalize_path_to norm_project "$phys_project" norm_project="${norm_project%/}" # Anchor on a path-segment boundary: accept the project root itself or a # child under it, but not a sibling whose name merely shares the prefix @@ -558,19 +1126,28 @@ hook::read_file_path() { # built by `mktemp -d`, which is how this repo's own hook suites run), so # the branch must not fire. Only a temp-tree file reached from a project # root OUTSIDE the temp tree is scratch. - if hook::under_temp_root "$norm_file" && ! hook::under_temp_root "$norm_project"; then - return 1 + local file_in_temp=0 + _HOOK_UTR_TARGET_PHYSICAL=$file_resolved + hook::under_temp_root "$norm_file" && file_in_temp=1 + _HOOK_UTR_TARGET_PHYSICAL=0 + if ((file_in_temp)); then + local project_in_temp=0 + _HOOK_UTR_TARGET_PHYSICAL=$project_resolved + hook::under_temp_root "$norm_project" && project_in_temp=1 + _HOOK_UTR_TARGET_PHYSICAL=0 + ((project_in_temp)) || return 1 fi elif command -v git >/dev/null 2>&1; then # When CLAUDE_PROJECT_DIR is unset, scope to git-working-tree membership so # scratch files outside any repository are not mutated by formatter hooks # (#1091 / #972). - local file_physical - file_physical=$(hook::physical_path "$file") + local file_physical file_dir + hook::physical_path_to file_physical "$file" || : if [[ -L "$file" && "$file_physical" == "$file" ]]; then return 1 fi - if ! hook::in_git_working_tree "$(dirname "$file_physical")"; then + hook::dirname_to file_dir "$file_physical" + if ! hook::in_git_working_tree "$file_dir"; then return 1 fi else @@ -1228,7 +1805,19 @@ hook::telemetry_enabled() { # Fire-and-forget: sink is dispatched in the background; the hook never waits # on it and its failure never affects the hook's own exit code or stdout. # Opt-in guard: HOOK_TELEMETRY_SINK unset or empty → return 0 immediately. -# Fail-open: jq absent → return 0 immediately. +# +# The envelope is assembled with shell builtins (hook::json_escape_jq_to for +# the string fields, hook::json_compact_to for the caller's data object) as +# ONE compact line, `jq -c` style: the same document, field order and +# escapes the jq filter below produces, with the data object compacted +# exactly as jq compacts its own output. That path needs no jq, no temp file +# and no subprocess. When the data object cannot be proven jq-idempotent (see +# hook::json_compact_to), the jq path runs, now with -c so both paths write +# the same bytes, and there jq's absence is still fail-open: return 0, no +# envelope. Before this the envelope was jq's default pretty-printed form +# (several lines, and CRLF line endings with a Windows jq); the sink contract +# was always "one JSON document on stdin", and a compact line is also valid +# JSONL for a sink that appends raw envelopes. # # Usage: # hook::emit_telemetry [repo_root] @@ -1251,7 +1840,6 @@ hook::telemetry_enabled() { # NEVER writes to fd1 (the hook's stdout / additionalContext channel). hook::emit_telemetry() { [[ -n "${HOOK_TELEMETRY_SINK:-}" ]] || return 0 - command -v jq >/dev/null 2>&1 || return 0 local hook_id="$1" local hook_event="$2" @@ -1272,35 +1860,59 @@ hook::emit_telemetry() { local e_s="${now%[.,]*}" e_f="${now#*[.,]}" local duration_ms=$(((e_s * 1000000 + 10#$e_f - s_s * 1000000 - 10#$s_f) / 1000)) - # True UTC timestamp (TZ= prefix overrides LC_ALL / local TZ; the Z is not a lie). - local timestamp - timestamp=$(TZ=UTC printf '%(%Y-%m-%dT%H:%M:%SZ)T' -1) - - # Build the envelope. The `data` object is written to a temp file, not passed - # as --argjson, so payloads larger than the Windows 32767-character command-line - # cap are not dropped before jq runs (#1595). /dev/stdin is not used: jq's - # --slurpfile there is not portable on Windows. Redirect jq stderr to - # /dev/null; output goes to a local variable — never to fd1. - local envelope data_file - data_file=$(mktemp) || return 0 - printf '%s' "$data_json" >"$data_file" || { - rm -f "$data_file" - return 0 - } - envelope=$(jq -n \ - --arg schema_version "1.0" \ - --arg timestamp "$timestamp" \ - --arg hook "$hook_id" \ - --arg hook_event "$hook_event" \ - --arg status "$status" \ - --argjson duration_ms "$duration_ms" \ - --slurpfile data "$data_file" \ - '{schema_version:$schema_version,timestamp:$timestamp,hook:$hook,hook_event:$hook_event,status:$status,duration_ms:$duration_ms,data:($data[0] // {})}' \ - 2>/dev/null) || { + # True UTC timestamp (TZ= prefix overrides LC_ALL / local TZ; the Z is not a + # lie). The %()T printf format is Bash 4.2+, and printf -v keeps it in the + # shell (a `$(...)` capture is a fork); an older bash, or a printf that + # binds nothing, falls back to date -u. + local timestamp="" + TZ=UTC printf -v timestamp '%(%Y-%m-%dT%H:%M:%SZ)T' -1 2>/dev/null || timestamp="" + [[ -n "$timestamp" ]] || timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null) || timestamp="" + + # Trailing whitespace after the object (a newline a caller left on the + # value) does not change what jq parses, so it does not block the builtin + # path either. + local envelope="" data="" data_trim="$data_json" + while [[ "$data_trim" == *[$' \t\r\n'] ]]; do + data_trim="${data_trim%?}" + done + if [[ "$data_trim" == \{*\} ]] && hook::json_compact_to data "$data_trim"; then + # Builtin envelope: the same document the jq filter below produces, field + # for field, with jq's string escaping and jq's compact data. + local e_timestamp e_hook e_event e_status + hook::json_escape_jq_to e_timestamp "$timestamp" + hook::json_escape_jq_to e_hook "$hook_id" + hook::json_escape_jq_to e_event "$hook_event" + hook::json_escape_jq_to e_status "$status" + envelope='{"schema_version":"1.0","timestamp":"'"$e_timestamp"'","hook":"'"$e_hook"'","hook_event":"'"$e_event"'","status":"'"$e_status"'","duration_ms":'"$duration_ms"',"data":'"$data"'}' + else + # jq path, for a data object the builtin compactor cannot prove. Fail-open: + # jq absent → return 0. The `data` object is written to a temp file, not + # passed as --argjson, so payloads larger than the Windows 32767-character + # command-line cap are not dropped before jq runs (#1595). /dev/stdin is + # not used: jq's --slurpfile there is not portable on Windows. Redirect jq + # stderr to /dev/null; output goes to a local variable — never to fd1. + command -v jq >/dev/null 2>&1 || return 0 + local data_file + data_file=$(mktemp) || return 0 + printf '%s' "$data_json" >"$data_file" || { + rm -f "$data_file" + return 0 + } + envelope=$(jq -nc \ + --arg schema_version "1.0" \ + --arg timestamp "$timestamp" \ + --arg hook "$hook_id" \ + --arg hook_event "$hook_event" \ + --arg status "$status" \ + --argjson duration_ms "$duration_ms" \ + --slurpfile data "$data_file" \ + '{schema_version:$schema_version,timestamp:$timestamp,hook:$hook,hook_event:$hook_event,status:$status,duration_ms:$duration_ms,data:($data[0] // {})}' \ + 2>/dev/null) || { + rm -f "$data_file" + return 0 + } rm -f "$data_file" - return 0 - } - rm -f "$data_file" + fi # Resolve the sink path. A relative HOOK_TELEMETRY_SINK is joined onto the # consuming repo root (portable, tracked wiring); absolute is used as-is. A diff --git a/plugins/autonomy/.claude-plugin/plugin.json b/plugins/autonomy/.claude-plugin/plugin.json index 9585b14342..d7527ea385 100644 --- a/plugins/autonomy/.claude-plugin/plugin.json +++ b/plugins/autonomy/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "autonomy", - "version": "0.22.21", + "version": "0.22.22", "description": "Governed autonomous agent operation: role-topology, binding-seam, wiring-vs-advisor, telemetry, return-accounting, trigger-dispatch, per-work-class guardrail-matrix, standing-routine-catalog, and design-only runner-charter contracts for climbing the AI-adoption ladder, plus a guided-setup skill that discovers an adopting org's state, writes its schema-versioned binding, wires standards-pinned OTLP emission with a zero-cost file-artifact default, wires human-attested return capture at the task boundary, wires signal adapters with one governed dispatch entrypoint, binds the five-class guardrail matrix to an org's isolation substrates with an in-boundary live-validation probe before recording each fail-closed binding, and stands up standing-routine-catalog classes as scheduled temporal signal adapters behind the one governed queue with free scheduling defaults wired as reviewable changes and each routine's work-class mapping homed on the security surface.", "author": { "name": "Melodic Software", diff --git a/plugins/autonomy/CHANGELOG.md b/plugins/autonomy/CHANGELOG.md index ea35498bdb..bf1902f670 100644 --- a/plugins/autonomy/CHANGELOG.md +++ b/plugins/autonomy/CHANGELOG.md @@ -3,6 +3,22 @@ All notable changes to the `autonomy` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.22.22] + +### Changed + +- **Vendored `hook-utils.sh` builds the telemetry envelope and reads `file_path` + with shell builtins.** `hook::emit_telemetry` no longer spawns two jq + processes, a mktemp and an rm per run: the envelope is assembled in the shell + as one compact line (the same document jq produced, now `jq -c` shaped), with + jq kept only as the fallback for a data object the builtin compactor cannot + prove. `hook::read_file_path` takes `.tool_input.file_path` without jq on the + well-formed payload shape and resolves the file, project root and temp roots + with one batched `realpath` instead of one process each. Same verdicts, same + emitted path, same sink record; phase 4b of the hook-performance program + (#3623). The copy is bumped because `scripts/sync-hook-utils.sh` keeps every + carrying plugin byte-identical. + ## [0.22.21] ### Changed diff --git a/plugins/autonomy/hooks/hook-utils.sh b/plugins/autonomy/hooks/hook-utils.sh index 9818a6a73d..18e4dd5745 100644 --- a/plugins/autonomy/hooks/hook-utils.sh +++ b/plugins/autonomy/hooks/hook-utils.sh @@ -390,19 +390,34 @@ hook::require_jq_blocking() { # collapse with `/c/repo` and the membership guard would admit a sibling # outside CLAUDE_PROJECT_DIR. The result is used ONLY for comparison; the # emitted path is always the caller's original. -hook::normalize_path() { - local p="${1//\\//}" +# +# Two spellings of every path helper below: `hook::` prints the answer +# (the public, subshell-friendly form every caller already uses) and +# `hook::_to ` stores it in the caller's variable instead. +# The `_to` form is what the hot path uses: a `$(...)` capture is a fork, and +# on Windows Git Bash a fork costs milliseconds, so the file_path guard that +# ran a dozen of them per hook now runs none it does not need. The `_to` +# helpers keep their locals under a `__hu_` prefix so the caller's variable +# name cannot collide with them. +hook::normalize_path_to() { + local __hu_p="${2//\\//}" case "${OSTYPE:-}" in msys* | cygwin* | win32) - if [[ "$p" =~ ^/([a-zA-Z])/ || "$p" =~ ^([a-zA-Z]):/ ]]; then - local rest="${p:2}" - printf '%s' "${BASH_REMATCH[1]^}:${rest,,}" - return + if [[ "$__hu_p" =~ ^/([a-zA-Z])/ || "$__hu_p" =~ ^([a-zA-Z]):/ ]]; then + local __hu_rest="${__hu_p:2}" + printf -v "$1" '%s' "${BASH_REMATCH[1]^}:${__hu_rest,,}" + return 0 fi ;; *) ;; # POSIX hosts: case-sensitive FS, no drive fold — pass through below esac - printf '%s' "$p" + printf -v "$1" '%s' "$__hu_p" +} + +hook::normalize_path() { + local __hu_n + hook::normalize_path_to __hu_n "$1" + printf '%s' "$__hu_n" } # Expand Windows 8.3 short-name components (KYLESE~1 → KyleSexton) on @@ -424,25 +439,31 @@ hook::normalize_path() { # class: cygpath ships with Git Bash (the documented Windows bash), so its # absence or failure keeps the resolver's answer unchanged — degrading to the # pre-expansion comparison, same doctrine as the resolver fallback below. -hook::expand_8dot3() { - local p="$1" +hook::expand_8dot3_to() { + local __hu_p="$2" case "${OSTYPE:-}" in msys* | cygwin* | win32) ;; *) - printf '%s' "$p" - return + printf -v "$1" '%s' "$__hu_p" + return 0 ;; esac - if [[ "$p" == *~* ]] && command -v cygpath >/dev/null 2>&1; then - local plain long - if plain=$(cygpath -m -- "$p" 2>/dev/null) && - long=$(cygpath -l -m -- "$p" 2>/dev/null) && - [[ -n "$long" && "$long" != "$plain" ]]; then - printf '%s' "$long" - return + if [[ "$__hu_p" == *~* ]] && command -v cygpath >/dev/null 2>&1; then + local __hu_plain __hu_long + if __hu_plain=$(cygpath -m -- "$__hu_p" 2>/dev/null) && + __hu_long=$(cygpath -l -m -- "$__hu_p" 2>/dev/null) && + [[ -n "$__hu_long" && "$__hu_long" != "$__hu_plain" ]]; then + printf -v "$1" '%s' "$__hu_long" + return 0 fi fi - printf '%s' "$p" + printf -v "$1" '%s' "$__hu_p" +} + +hook::expand_8dot3() { + local __hu_e + hook::expand_8dot3_to __hu_e "$1" + printf '%s' "$__hu_e" } # Canonicalize to a physical path — symlinks resolved, Windows 8.3 short names @@ -462,20 +483,148 @@ hook::expand_8dot3() { # expansion applies only on the resolver's success path: consumers that fail # closed on an unresolved signature must not see a form-converted path instead. # shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED -hook::physical_path() { - local resolved +hook::physical_path_to() { + local __hu_r HOOK_PHYSICAL_PATH_UNRESOLVED=0 - if resolved=$(realpath -- "$1" 2>/dev/null) || resolved=$(readlink -f -- "$1" 2>/dev/null); then - if [[ -n "$resolved" ]]; then - hook::expand_8dot3 "$resolved" + if __hu_r=$(realpath -- "$2" 2>/dev/null) || __hu_r=$(readlink -f -- "$2" 2>/dev/null); then + if [[ -n "$__hu_r" ]]; then + hook::expand_8dot3_to "$1" "$__hu_r" return 0 fi fi HOOK_PHYSICAL_PATH_UNRESOLVED=1 - printf '%s' "$1" + printf -v "$1" '%s' "$2" + return 1 +} + +# shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED +hook::physical_path() { + local __hu_v __hu_rc=0 + hook::physical_path_to __hu_v "$1" || __hu_rc=$? + printf '%s' "$__hu_v" + return "$__hu_rc" +} + +# --- Per-process physical-path cache -------------------------------------- +# The membership guard resolves the same few directories on every call: the +# project root and this host's temp roots. Their physical form does not change +# within one hook process, so each spelling is resolved once and remembered in +# three parallel indexed arrays (keys, values, resolver status). Plain arrays, +# not an associative array, so the cache runs on Bash 3.2. Only directories are +# meant to live here for a process lifetime; hook::read_file_path forgets the +# edited file's entry as soon as it has read it, so a later call in the same +# process sees the file as it is then. +_HOOK_PHYS_KEYS=() +_HOOK_PHYS_VALS=() +_HOOK_PHYS_RCS=() +_HOOK_PHYS_I=-1 + +# hook::_phys_cache_index : sets _HOOK_PHYS_I to the slot holding , +# returns 1 when it is not cached. A forgotten slot has an empty key, which no +# real path can match. +hook::_phys_cache_index() { + local __hu_i + [[ -n "$1" ]] || return 1 + for ((__hu_i = 0; __hu_i < ${#_HOOK_PHYS_KEYS[@]}; __hu_i++)); do + if [[ "${_HOOK_PHYS_KEYS[__hu_i]}" == "$1" ]]; then + _HOOK_PHYS_I=$__hu_i + return 0 + fi + done return 1 } +hook::_phys_cache_forget() { + hook::_phys_cache_index "$1" && _HOOK_PHYS_KEYS[_HOOK_PHYS_I]="" + return 0 +} + +# hook::_physical_cached_to : hook::physical_path_to through the +# cache. Same value, same return status, same HOOK_PHYSICAL_PATH_UNRESOLVED as +# the uncached call; a miss resolves the one path and stores it. +# shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED +hook::_physical_cached_to() { + if hook::_phys_cache_index "$2"; then + printf -v "$1" '%s' "${_HOOK_PHYS_VALS[_HOOK_PHYS_I]}" + HOOK_PHYSICAL_PATH_UNRESOLVED=${_HOOK_PHYS_RCS[_HOOK_PHYS_I]} + return "${_HOOK_PHYS_RCS[_HOOK_PHYS_I]}" + fi + local __hu_v __hu_rc=0 + hook::physical_path_to __hu_v "$2" || __hu_rc=$? + if [[ -n "$2" ]]; then + _HOOK_PHYS_KEYS+=("$2") + _HOOK_PHYS_VALS+=("$__hu_v") + _HOOK_PHYS_RCS+=("$__hu_rc") + fi + printf -v "$1" '%s' "$__hu_v" + return "$__hu_rc" +} + +# hook::_physical_prime ...: resolve every uncached path with ONE +# realpath process and store the answers. realpath resolves each argument on +# its own and prints one line per argument in argument order, so the batch +# answer for a path is the same string the per-path call returns; the 8.3 +# expansion is applied per answer exactly as hook::physical_path_to does. Any +# doubt (a path carrying a newline, a non-zero exit, a line count that does not +# match) stores nothing and leaves the per-path resolver to answer lazily, so +# the batch can only ever save work, never change an answer. readlink -f hosts +# (no realpath) skip the batch for the same reason. +hook::_physical_prime() { + local -a __hu_todo=() + local __hu_p __hu_out __hu_i __hu_seen="" __hu_v + for __hu_p in "$@"; do + [[ -n "$__hu_p" ]] || continue + [[ "$__hu_p" == *$'\n'* ]] && return 0 + case "$__hu_seen" in + *"|$__hu_p|"*) continue ;; + *) ;; # first sighting + esac + __hu_seen="$__hu_seen|$__hu_p|" + hook::_phys_cache_index "$__hu_p" && continue + __hu_todo+=("$__hu_p") + done + ((${#__hu_todo[@]} > 1)) || return 0 + command -v realpath >/dev/null 2>&1 || return 0 + # portability-ok: realpath with several operands is GNU and BSD alike; a host whose realpath rejects it fails the exit-status check and falls back per path + __hu_out=$(realpath -- "${__hu_todo[@]}" 2>/dev/null) || return 0 + local -a __hu_lines=() + local __hu_glob=0 + [[ $- == *f* ]] || __hu_glob=1 + set -f + local IFS=$'\n' + # shellcheck disable=SC2206 # splitting realpath's one-line-per-operand output is the intent + __hu_lines=($__hu_out) + ((__hu_glob)) && set +f + ((${#__hu_lines[@]} == ${#__hu_todo[@]})) || return 0 + for ((__hu_i = 0; __hu_i < ${#__hu_todo[@]}; __hu_i++)); do + [[ -n "${__hu_lines[__hu_i]}" ]] || return 0 + hook::expand_8dot3_to __hu_v "${__hu_lines[__hu_i]}" + _HOOK_PHYS_KEYS+=("${__hu_todo[__hu_i]}") + _HOOK_PHYS_VALS+=("$__hu_v") + _HOOK_PHYS_RCS+=(0) + done + return 0 +} + +# The temp-root candidates hook::under_temp_root compares against: the +# environment's own answer (TMPDIR/TMP/TEMP) plus the POSIX defaults, never a +# hardcoded platform assumption. Existing directories only, each spelling +# once, in _HOOK_TEMP_CANDS. +_HOOK_TEMP_CANDS=() +hook::_temp_root_candidates() { + local __hu_cand __hu_seen="" + _HOOK_TEMP_CANDS=() + for __hu_cand in "${TMPDIR:-}" "${TMP:-}" "${TEMP:-}" /tmp /var/tmp; do + [[ -n "$__hu_cand" && -d "$__hu_cand" ]] || continue + case "$__hu_seen" in + *"|$__hu_cand|"*) continue ;; + *) ;; # first sighting of this candidate + esac + __hu_seen="$__hu_seen|$__hu_cand|" + _HOOK_TEMP_CANDS+=("$__hu_cand") + done +} + # True when sits inside one of this host's temp trees. # Both arguments and candidates go through the same canonicalize+normalize # pipeline as the membership comparison, because the same directory has several @@ -483,21 +632,40 @@ hook::physical_path() { # of the identical directory, and `realpath` resolves the Windows form to a # drive path while leaving `/tmp` as `/tmp`. Neither form alone matches a # `file_path` that could arrive in either, so every candidate is compared and a -# match on any one is a match. Duplicates are resolved once. +# match on any one is a match. Duplicates are resolved once, and each +# candidate's physical form is remembered for the process (see the cache +# above), so a second call costs no resolver process. # -# Candidates are the environment's own answer (TMPDIR/TMP/TEMP) plus the POSIX -# defaults, never a hardcoded platform assumption. +# Raw-spelling shortcut: when the caller vouches that is a +# PHYSICAL path (_HOOK_UTR_TARGET_PHYSICAL=1, set only by hook::read_file_path +# after the resolver succeeded), a candidate whose raw normalized spelling is +# already a prefix of the target is a match without resolving it. A physical +# path contains no symlink component, so a candidate that spells one of its +# prefixes names a chain of real directories whose physical form is that same +# prefix; the resolver would answer the same. A candidate that does not match +# raw is still resolved, so a symlinked temp root (macOS /tmp) is found the +# way it always was. Without the vouch the shortcut is off and every candidate +# is resolved, exactly as before. Note that hook::read_file_path primes every +# candidate into the cache with one batched realpath before calling here, so +# on that path the shortcut skips a lookup, not a process; the batch is what +# saves the processes. A direct caller without the batch saves the resolver. # hook::under_temp_root "$norm_path" && ... +_HOOK_UTR_TARGET_PHYSICAL=0 hook::under_temp_root() { - local target="$1" cand norm seen="" - for cand in "${TMPDIR:-}" "${TMP:-}" "${TEMP:-}" /tmp /var/tmp; do - [[ -n "$cand" && -d "$cand" ]] || continue - case "$seen" in - *"|$cand|"*) continue ;; - *) ;; # first sighting of this candidate — resolve it below - esac - seen="$seen|$cand|" - norm=$(hook::normalize_path "$(hook::physical_path "$cand")") + local target="$1" cand norm phys + hook::_temp_root_candidates + for cand in ${_HOOK_TEMP_CANDS[@]+"${_HOOK_TEMP_CANDS[@]}"}; do + if ((_HOOK_UTR_TARGET_PHYSICAL)); then + hook::normalize_path_to norm "$cand" + if [[ "$norm" != / ]]; then + norm="${norm%/}" + if [[ -n "$norm" && ("$target" == "$norm" || "$target" == "$norm"/*) ]]; then + return 0 + fi + fi + fi + hook::_physical_cached_to phys "$cand" || : + hook::normalize_path_to norm "$phys" # The filesystem root as a temp candidate contains every absolute path; # trimming its only slash would empty the candidate and discard it. [[ "$norm" == / ]] && return 0 @@ -523,21 +691,421 @@ hook::in_git_working_tree() { ) >/dev/null 2>&1 } +# --- Builtin JSON helpers ---------------------------------------------------- +# hook::read_file_path and hook::emit_telemetry used to spend a jq process each +# on every hook run. On Windows Git Bash one spawn costs tens of milliseconds, +# so the helpers here handle the well-formed shapes those two functions see in +# practice with shell builtins and hand anything they cannot PROVE to the +# unchanged jq path. The contract is "the bytes jq would produce, or fall +# back", never "close enough". +# +# What the builtin paths rely on: the text is valid JSON. Every hook payload +# reaching hook::read_file_path has passed hook::buffer_stdin's jq validation, +# and every telemetry data object is built with jq. The helpers still verify +# the structure they walk (terminated strings, well-formed tokens, properly +# nested brackets, one root, no raw control bytes inside strings), but they +# are not a full JSON parser and do not claim to reject every malformed text. +# +# Why no scanning: on this repo's Windows hosts bash regex matching and the +# `%%`/`##` pattern operators cost about a microsecond per character, so a +# 60 KB Write payload would cost more than the jq process they replace. The +# only string operations used on the whole payload are literal-substring +# replacement, `[[ == *x* ]]` containment, IFS word splitting and offset +# slicing, all of which run at C speed. + +# hook::_json_split +# Split at every unescaped double quote into the global array +# _HOOK_JSON_PARTS: even indices hold the structural text between strings, odd +# indices hold string bodies. Before splitting, every `\\` and `\"` pair is +# replaced by `@@` (same length), so a quote that survives is a real string +# delimiter and a body's ORIGINAL bytes are ${text:offset:length}. Returns 1 +# when the text is too large, its quotes do not pair up, or it holds more +# strings than the callers' loops are meant to walk. +_HOOK_JSON_PARTS=() +hook::_json_split() { + local __hu_s="$1" __hu_t __hu_q __hu_n + ((${#__hu_s} <= 65536)) || return 1 + __hu_t=${__hu_s//"\\\\"/@@} + __hu_t=${__hu_t//"\\\""/@@} + __hu_q=${__hu_t//\"/} + __hu_n=$((${#__hu_t} - ${#__hu_q})) + ((__hu_n % 2 == 0)) || return 1 + ((__hu_n <= 800)) || return 1 + local __hu_glob=0 + [[ $- == *f* ]] || __hu_glob=1 + set -f + local IFS='"' + # shellcheck disable=SC2206 # splitting on IFS='"' is the whole point + _HOOK_JSON_PARTS=($__hu_t) + ((__hu_glob)) && set +f + ((${#_HOOK_JSON_PARTS[@]} % 2 == 1)) || return 1 + return 0 +} + +# hook::_json_skeleton +# Split (hook::_json_split), record each string body's byte offset in +# _HOOK_JSON_OFF, and build _HOOK_JSON_SK: the structural text with every +# string replaced by a numbered placeholder `"#"` and all JSON +# whitespace removed. The skeleton is small (no string bodies), so regex and +# per-token work on it is cheap. Returns 1 unless the text is exactly one JSON +# value by the JSON grammar (well-formed tokens, whitespace only between them, +# objects as key:value lists, arrays as value lists, brackets nested and +# closed once) whose strings carry no raw control byte, which jq rejects. +_HOOK_JSON_SK="" +_HOOK_JSON_OFF=() +hook::_json_skeleton() { + local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top + hook::_json_split "$__hu_s" || return 1 + __hu_n=${#_HOOK_JSON_PARTS[@]} + _HOOK_JSON_OFF=() + for ((__hu_i = 0; __hu_i < __hu_n; __hu_i++)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + if ((__hu_i % 2 == 0)); then + __hu_sk+=$__hu_part + else + _HOOK_JSON_OFF[__hu_i]=$__hu_off + [[ "$__hu_part" == *[[:cntrl:]]* ]] && return 1 + __hu_sk+="\"#$__hu_i\"" + fi + __hu_off=$((__hu_off + ${#__hu_part} + 1)) + done + # Tokenize the skeleton and run it through the JSON grammar. Each token is + # consumed by one anchored regex on the (small) remainder; whitespace is + # allowed only between tokens, so `tr ue` is two bad tokens, not `true`. + local __hu_re=$'^[ \t\n\r]*(\\{|\\}|\\[|\\]|,|:|"#[0-9]+"|-?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][-+]?[0-9]+)?|true|false|null)' + local __hu_ws=$'^[ \t\n\r]*$' + __hu_rest=$__hu_sk + while [[ -n "$__hu_rest" ]] && ! [[ "$__hu_rest" =~ $__hu_ws ]]; do + [[ "$__hu_rest" =~ $__hu_re ]] || return 1 + __hu_tok=${BASH_REMATCH[1]} + __hu_rest=${__hu_rest:${#BASH_REMATCH[0]}} + __hu_top=${__hu_stack:${#__hu_stack}-1:1} + case "$__hu_expect" in + value | value_or_end) + case "$__hu_tok" in + '{') + __hu_stack+='{' + __hu_expect=key_or_end + continue + ;; + '[') + __hu_stack+='[' + __hu_expect=value_or_end + continue + ;; + ']') + [[ "$__hu_expect" == value_or_end ]] || return 1 + ;; + '}' | ',' | ':') return 1 ;; + *) + # A scalar. Inside a container the next token is , or the close; + # at the top level nothing may follow. + if [[ -n "$__hu_stack" ]]; then __hu_expect=comma_or_end; else __hu_expect=end; fi + continue + ;; + esac + ;; + key_or_end | key) + case "$__hu_tok" in + \"#*) + __hu_expect='colon' + continue + ;; + '}') [[ "$__hu_expect" == key_or_end ]] || return 1 ;; + *) return 1 ;; + esac + ;; + colon) + [[ "$__hu_tok" == ':' ]] || return 1 + __hu_expect=value + continue + ;; + comma_or_end) + case "$__hu_tok" in + ',') + if [[ "$__hu_top" == '{' ]]; then __hu_expect=key; else __hu_expect=value; fi + continue + ;; + '}') [[ "$__hu_top" == '{' ]] || return 1 ;; + ']') [[ "$__hu_top" == '[' ]] || return 1 ;; + *) return 1 ;; + esac + ;; + *) return 1 ;; # `end`: a token after the root value closed + esac + # Reaching here means a container just closed. + [[ -n "$__hu_stack" ]] || return 1 + __hu_stack=${__hu_stack:0:${#__hu_stack}-1} + if [[ -n "$__hu_stack" ]]; then __hu_expect=comma_or_end; else __hu_expect=end; fi + done + [[ "$__hu_expect" == end ]] || return 1 + _HOOK_JSON_SK=${__hu_sk//[$' \t\n\r']/} + return 0 +} + +# hook::json_unescape_to +# Decode a JSON string body (the bytes between the quotes) into . Handles +# \" \\ \/ \b \f \n \r \t and \uXXXX for U+0001 to U+007F. Returns 1 on any +# other escape (a \u outside that range, U+0000 which bash cannot hold, or an +# invalid escape), leaving the caller to fall back to jq. +hook::json_unescape_to() { + local __hu_raw="$2" __hu_out="" __hu_pre __hu_c __hu_hex __hu_oct __hu_ch + while [[ "$__hu_raw" == *\\* ]]; do + __hu_pre=${__hu_raw%%\\*} + __hu_out+=$__hu_pre + __hu_raw=${__hu_raw:${#__hu_pre}+1} + __hu_c=${__hu_raw:0:1} + __hu_raw=${__hu_raw:1} + case "$__hu_c" in + '"') __hu_out+='"' ;; + "\\") __hu_out+="\\" ;; + '/') __hu_out+='/' ;; + b) __hu_out+=$'\b' ;; # portability-ok: bash ANSI-C backspace byte, not a GNU grep word boundary + f) __hu_out+=$'\f' ;; + n) __hu_out+=$'\n' ;; + r) __hu_out+=$'\r' ;; + t) __hu_out+=$'\t' ;; + u) + __hu_hex=${__hu_raw:0:4} + [[ "$__hu_hex" =~ ^00[0-7][0-9a-fA-F]$ ]] || return 1 + [[ "$__hu_hex" == 0000 ]] && return 1 + __hu_raw=${__hu_raw:4} + printf -v __hu_oct '%03o' "$((16#$__hu_hex))" + printf -v __hu_ch '%b' "\\0$__hu_oct" + __hu_out+=$__hu_ch + ;; + *) return 1 ;; + esac + done + __hu_out+=$__hu_raw + printf -v "$1" '%s' "$__hu_out" +} + +# hook::_fast_file_path_to +# The builtin answer to `jq -r '(.tool_input.file_path // empty) | +# gsub("\r";"")'` followed by the `$(...)` capture. Returns +# 0 proven: holds exactly what jq would have printed +# 1 proven absent: jq would have printed nothing (the guard skips) +# 2 not proven: run jq +# Proof, on top of hook::_json_skeleton's structural checks: the root is an +# object; exactly one string in the whole payload decodes to `tool_input` and +# exactly one to `file_path` (so neither a duplicate key nor a same-named key +# in another object can change jq's answer); `tool_input` is a key of the root +# (depth 1, key position) whose value is a flat object (no nested object or +# array, else jq); and inside it `file_path` is a key with a plain string +# value. A payload where `file_path` names a value or sits in another object +# has no `.tool_input.file_path`, which jq reports as nothing. Key strings are +# compared AFTER decoding their escapes, so a key spelled with \u escapes is +# still recognized; a body longer than any escaped spelling of either name is +# skipped without decoding. +hook::_fast_file_path_to() { + local __hu_s="$2" __hu_i __hu_n __hu_part __hu_body __hu_ti=-1 __hu_fp=-1 __hu_m __hu_raw __hu_pre __hu_re __hu_file + hook::_json_skeleton "$__hu_s" || return 2 + [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 + __hu_n=${#_HOOK_JSON_PARTS[@]} + for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + ((${#__hu_part} <= 60)) || continue + __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} + if [[ "$__hu_body" == *\\* ]]; then + hook::json_unescape_to __hu_body "$__hu_body" || continue + fi + case "$__hu_body" in + tool_input) + ((__hu_ti < 0)) || return 2 + __hu_ti=$__hu_i + ;; + file_path) + ((__hu_fp < 0)) || return 2 + __hu_fp=$__hu_i + ;; + *) ;; + esac + done + ((__hu_fp >= 0)) || return 1 + ((__hu_ti >= 0)) || return 1 + __hu_re="(^|[{,])\"#$__hu_ti\":\\{([^][{}]*)\\}" + if [[ "$_HOOK_JSON_SK" =~ $__hu_re ]]; then + __hu_body=${BASH_REMATCH[2]} + __hu_pre=${_HOOK_JSON_SK%%\"#"$__hu_ti"\"*} + # Depth of the key: opening brackets minus closing ones before it must be + # exactly one, i.e. a direct member of the root object. + local __hu_o1=${__hu_pre//\{/} __hu_o2=${__hu_pre//\[/} __hu_c1=${__hu_pre//\}/} __hu_c2=${__hu_pre//\]/} + local __hu_depth=$(((${#__hu_pre} - ${#__hu_o1}) + (${#__hu_pre} - ${#__hu_o2}) - (${#__hu_pre} - ${#__hu_c1}) - (${#__hu_pre} - ${#__hu_c2}))) + ((__hu_depth == 1)) || return 2 + else + return 2 + fi + __hu_re="(^|,)\"#$__hu_fp\":\"#([0-9]+)\"(,|$)" + if [[ "$__hu_body" =~ $__hu_re ]]; then + __hu_m=${BASH_REMATCH[2]} + elif [[ "$__hu_body" == *\"#$__hu_fp\"* ]]; then + return 2 + else + return 1 + fi + __hu_raw=${__hu_s:${_HOOK_JSON_OFF[__hu_m]}:${#_HOOK_JSON_PARTS[__hu_m]}} + if [[ "$__hu_raw" == *\\* ]]; then + hook::json_unescape_to __hu_file "$__hu_raw" || return 2 + else + __hu_file=$__hu_raw + fi + __hu_file=${__hu_file//$'\r'/} + while [[ "$__hu_file" == *$'\n' ]]; do + __hu_file=${__hu_file%$'\n'} + done + printf -v "$1" '%s' "$__hu_file" + return 0 +} + +# hook::dirname_to : dirname with builtins for the resolver's +# answer. Strips the last segment; a bare name lives in `.`, a root-level file +# in `/`. The path comes from realpath, so the trailing-slash and doubled-slash +# spellings dirname also collapses never occur here. +hook::dirname_to() { + local __hu_d="${2%/*}" + [[ "$__hu_d" == "$2" ]] && __hu_d=. + [[ -n "$__hu_d" ]] || __hu_d=/ + printf -v "$1" '%s' "$__hu_d" +} + +# Print the arguments joined by NUL bytes, no trailing NUL: the byte stream +# hook::read_file_path read from stdin, rebuilt for the jq fallback. +hook::_print_nul_joined() { + local __hu_first=1 __hu_c + for __hu_c in "$@"; do + ((__hu_first)) || printf '\0' + __hu_first=0 + printf '%s' "$__hu_c" + done +} + +# hook::json_compact_to +# The bytes `jq -c .` prints for , when that can be proven with +# builtins: JSON whitespace between tokens removed, everything else verbatim. +# jq's own output, compact or pretty, satisfies the proof, which is what every +# telemetry data builder in this marketplace produces (jq -c, jq -n, or a +# compact literal). Returns 1 when the text is not a single object, or a +# string carries something jq would re-encode (a \u or \/ escape, an escape +# outside the JSON set, a raw control byte), or the structure fails +# hook::_json_skeleton; the caller then runs jq. +hook::json_compact_to() { + local __hu_s="$2" __hu_out="" __hu_i __hu_part __hu_n + hook::_json_skeleton "$__hu_s" || return 1 + [[ "$_HOOK_JSON_SK" == \{*\} ]] || return 1 + # Numbers: only short integer literals are passed through. A fraction, an + # exponent or an integer beyond 15 digits is rendered differently by + # different jq releases (1.6 canonicalizes, 1.7+ preserves the literal), so + # the bytes cannot be proven; jq decides. In the skeleton every string is a + # `"#N"` placeholder and letters occur only in true/false/null, so a digit + # followed by `.`, `e` or `E` can only be part of a number. + local __hu_num='[0-9][.eE]|[0-9]{16}' + [[ "$_HOOK_JSON_SK" =~ $__hu_num ]] && return 1 + __hu_n=${#_HOOK_JSON_PARTS[@]} + for ((__hu_i = 0; __hu_i < __hu_n; __hu_i++)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + if ((__hu_i % 2 == 0)); then + __hu_out+=${__hu_part//[$' \t\n\r']/} + else + if [[ "$__hu_part" == *\\* ]]; then + [[ "$__hu_part" == *'\u'* || "$__hu_part" == *'\/'* ]] && return 1 + [[ "$__hu_part" == *\\[!bfnrt]* ]] && return 1 + fi + __hu_out+="\"${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}}\"" + fi + done + printf -v "$1" '%s' "$__hu_out" +} + +# hook::json_escape_jq_to / hook::json_escape_jq +# Escape exactly as jq serializes a string value (without the +# quotes): backslash and double quote, the five short forms \b \f \n \r \t, +# every other byte from 0x01 to 0x1f and 0x7f as lowercase \u00xx, and +# everything else, including non-ASCII, verbatim. This is the escaper the +# telemetry envelope needs for byte parity with the jq envelope it replaced; +# hook::json_escape above is a different tool (it DROPS the residual control +# bytes, which is right for a notice and wrong for parity) and is unchanged. +hook::json_escape_jq_to() { + local __hu_s="$2" __hu_i __hu_c __hu_oct __hu_hex + __hu_s="${__hu_s//\\/\\\\}" + __hu_s="${__hu_s//\"/\\\"}" + if [[ "$__hu_s" == *[[:cntrl:]]* ]]; then + __hu_s="${__hu_s//$'\n'/\\n}" + __hu_s="${__hu_s//$'\r'/\\r}" + __hu_s="${__hu_s//$'\t'/\\t}" + __hu_s="${__hu_s//$'\b'/\\b}" # portability-ok: bash ANSI-C backspace byte and the JSON \b escape, not a GNU grep word boundary + __hu_s="${__hu_s//$'\f'/\\f}" + for __hu_i in 1 2 3 4 5 6 7 11 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 127; do + printf -v __hu_oct '%03o' "$__hu_i" + printf -v __hu_c '%b' "\\0$__hu_oct" + [[ "$__hu_s" == *"$__hu_c"* ]] || continue + printf -v __hu_hex '%04x' "$__hu_i" + __hu_s="${__hu_s//"$__hu_c"/\\u$__hu_hex}" + done + fi + printf -v "$1" '%s' "$__hu_s" +} + +hook::json_escape_jq() { + local __hu_e + hook::json_escape_jq_to __hu_e "$1" + printf '%s' "$__hu_e" +} + # Parse file_path from PostToolUse JSON on stdin; validate existence and (when # CLAUDE_PROJECT_DIR is set) project membership. Both sides of the membership # comparison are canonicalized (symlinks resolved) first, so neither an # escaping symlink nor a project root reached via a symlinked path (e.g. # macOS /tmp) skews the verdict. Outputs the path on success. Returns 1 to skip. # FILE=$(hook::read_file_path) || exit 0 +# +# jq-free on the common shape. The payload is read into the shell and +# hook::_fast_file_path_to takes `.tool_input.file_path` with builtins when it +# can PROVE the answer jq would give (see that helper for what "prove" means); +# every other payload goes to the unchanged jq filter. The path comparison then +# resolves the file, the project root and the temp roots with one realpath +# process instead of one each (hook::_physical_prime), and remembers the +# directories for the process. Same verdict, same emitted path, fewer +# processes: on Windows Git Bash each spawn costs tens of milliseconds and this +# guard runs on every Write and Edit. hook::read_file_path() { - local file - file=$(jq -r '(.tool_input.file_path // empty) | gsub("\r";"")' 2>/dev/null) + local -a chunks=() + local chunk file="" mode=2 + # Builtin read to NUL or EOF. A NUL splits the payload into several chunks; + # the fast path only takes a single-chunk (NUL-free) payload, and the jq + # fallback is fed the chunks NUL-joined, so it sees the bytes jq used to + # read straight from stdin. + while :; do + chunk="" + if IFS= read -r -d '' chunk; then + chunks+=("$chunk") + continue + fi + chunks+=("$chunk") + break + done + if ((${#chunks[@]} == 1)); then + mode=0 + hook::_fast_file_path_to file "${chunks[0]}" || mode=$? + fi + case "$mode" in + 0) ;; # proven: `file` holds jq's answer + 1) return 1 ;; # proven absent + *) + file=$(hook::_print_nul_joined "${chunks[@]}" | jq -r '(.tool_input.file_path // empty) | gsub("\r";"")' 2>/dev/null) + ;; + esac [[ -n "$file" ]] || return 1 [[ -f "$file" ]] || return 1 if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then - local norm_file norm_project - norm_file=$(hook::normalize_path "$(hook::physical_path "$file")") - norm_project=$(hook::normalize_path "$(hook::physical_path "${CLAUDE_PROJECT_DIR}")") + local norm_file norm_project phys_file phys_project file_resolved=0 project_resolved=0 + hook::_temp_root_candidates + hook::_physical_prime "$file" "${CLAUDE_PROJECT_DIR}" ${_HOOK_TEMP_CANDS[@]+"${_HOOK_TEMP_CANDS[@]}"} + hook::_physical_cached_to phys_file "$file" && file_resolved=1 + hook::_phys_cache_forget "$file" + hook::_physical_cached_to phys_project "${CLAUDE_PROJECT_DIR}" && project_resolved=1 + hook::normalize_path_to norm_file "$phys_file" + hook::normalize_path_to norm_project "$phys_project" norm_project="${norm_project%/}" # Anchor on a path-segment boundary: accept the project root itself or a # child under it, but not a sibling whose name merely shares the prefix @@ -558,19 +1126,28 @@ hook::read_file_path() { # built by `mktemp -d`, which is how this repo's own hook suites run), so # the branch must not fire. Only a temp-tree file reached from a project # root OUTSIDE the temp tree is scratch. - if hook::under_temp_root "$norm_file" && ! hook::under_temp_root "$norm_project"; then - return 1 + local file_in_temp=0 + _HOOK_UTR_TARGET_PHYSICAL=$file_resolved + hook::under_temp_root "$norm_file" && file_in_temp=1 + _HOOK_UTR_TARGET_PHYSICAL=0 + if ((file_in_temp)); then + local project_in_temp=0 + _HOOK_UTR_TARGET_PHYSICAL=$project_resolved + hook::under_temp_root "$norm_project" && project_in_temp=1 + _HOOK_UTR_TARGET_PHYSICAL=0 + ((project_in_temp)) || return 1 fi elif command -v git >/dev/null 2>&1; then # When CLAUDE_PROJECT_DIR is unset, scope to git-working-tree membership so # scratch files outside any repository are not mutated by formatter hooks # (#1091 / #972). - local file_physical - file_physical=$(hook::physical_path "$file") + local file_physical file_dir + hook::physical_path_to file_physical "$file" || : if [[ -L "$file" && "$file_physical" == "$file" ]]; then return 1 fi - if ! hook::in_git_working_tree "$(dirname "$file_physical")"; then + hook::dirname_to file_dir "$file_physical" + if ! hook::in_git_working_tree "$file_dir"; then return 1 fi else @@ -1228,7 +1805,19 @@ hook::telemetry_enabled() { # Fire-and-forget: sink is dispatched in the background; the hook never waits # on it and its failure never affects the hook's own exit code or stdout. # Opt-in guard: HOOK_TELEMETRY_SINK unset or empty → return 0 immediately. -# Fail-open: jq absent → return 0 immediately. +# +# The envelope is assembled with shell builtins (hook::json_escape_jq_to for +# the string fields, hook::json_compact_to for the caller's data object) as +# ONE compact line, `jq -c` style: the same document, field order and +# escapes the jq filter below produces, with the data object compacted +# exactly as jq compacts its own output. That path needs no jq, no temp file +# and no subprocess. When the data object cannot be proven jq-idempotent (see +# hook::json_compact_to), the jq path runs, now with -c so both paths write +# the same bytes, and there jq's absence is still fail-open: return 0, no +# envelope. Before this the envelope was jq's default pretty-printed form +# (several lines, and CRLF line endings with a Windows jq); the sink contract +# was always "one JSON document on stdin", and a compact line is also valid +# JSONL for a sink that appends raw envelopes. # # Usage: # hook::emit_telemetry [repo_root] @@ -1251,7 +1840,6 @@ hook::telemetry_enabled() { # NEVER writes to fd1 (the hook's stdout / additionalContext channel). hook::emit_telemetry() { [[ -n "${HOOK_TELEMETRY_SINK:-}" ]] || return 0 - command -v jq >/dev/null 2>&1 || return 0 local hook_id="$1" local hook_event="$2" @@ -1272,35 +1860,59 @@ hook::emit_telemetry() { local e_s="${now%[.,]*}" e_f="${now#*[.,]}" local duration_ms=$(((e_s * 1000000 + 10#$e_f - s_s * 1000000 - 10#$s_f) / 1000)) - # True UTC timestamp (TZ= prefix overrides LC_ALL / local TZ; the Z is not a lie). - local timestamp - timestamp=$(TZ=UTC printf '%(%Y-%m-%dT%H:%M:%SZ)T' -1) - - # Build the envelope. The `data` object is written to a temp file, not passed - # as --argjson, so payloads larger than the Windows 32767-character command-line - # cap are not dropped before jq runs (#1595). /dev/stdin is not used: jq's - # --slurpfile there is not portable on Windows. Redirect jq stderr to - # /dev/null; output goes to a local variable — never to fd1. - local envelope data_file - data_file=$(mktemp) || return 0 - printf '%s' "$data_json" >"$data_file" || { - rm -f "$data_file" - return 0 - } - envelope=$(jq -n \ - --arg schema_version "1.0" \ - --arg timestamp "$timestamp" \ - --arg hook "$hook_id" \ - --arg hook_event "$hook_event" \ - --arg status "$status" \ - --argjson duration_ms "$duration_ms" \ - --slurpfile data "$data_file" \ - '{schema_version:$schema_version,timestamp:$timestamp,hook:$hook,hook_event:$hook_event,status:$status,duration_ms:$duration_ms,data:($data[0] // {})}' \ - 2>/dev/null) || { + # True UTC timestamp (TZ= prefix overrides LC_ALL / local TZ; the Z is not a + # lie). The %()T printf format is Bash 4.2+, and printf -v keeps it in the + # shell (a `$(...)` capture is a fork); an older bash, or a printf that + # binds nothing, falls back to date -u. + local timestamp="" + TZ=UTC printf -v timestamp '%(%Y-%m-%dT%H:%M:%SZ)T' -1 2>/dev/null || timestamp="" + [[ -n "$timestamp" ]] || timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null) || timestamp="" + + # Trailing whitespace after the object (a newline a caller left on the + # value) does not change what jq parses, so it does not block the builtin + # path either. + local envelope="" data="" data_trim="$data_json" + while [[ "$data_trim" == *[$' \t\r\n'] ]]; do + data_trim="${data_trim%?}" + done + if [[ "$data_trim" == \{*\} ]] && hook::json_compact_to data "$data_trim"; then + # Builtin envelope: the same document the jq filter below produces, field + # for field, with jq's string escaping and jq's compact data. + local e_timestamp e_hook e_event e_status + hook::json_escape_jq_to e_timestamp "$timestamp" + hook::json_escape_jq_to e_hook "$hook_id" + hook::json_escape_jq_to e_event "$hook_event" + hook::json_escape_jq_to e_status "$status" + envelope='{"schema_version":"1.0","timestamp":"'"$e_timestamp"'","hook":"'"$e_hook"'","hook_event":"'"$e_event"'","status":"'"$e_status"'","duration_ms":'"$duration_ms"',"data":'"$data"'}' + else + # jq path, for a data object the builtin compactor cannot prove. Fail-open: + # jq absent → return 0. The `data` object is written to a temp file, not + # passed as --argjson, so payloads larger than the Windows 32767-character + # command-line cap are not dropped before jq runs (#1595). /dev/stdin is + # not used: jq's --slurpfile there is not portable on Windows. Redirect jq + # stderr to /dev/null; output goes to a local variable — never to fd1. + command -v jq >/dev/null 2>&1 || return 0 + local data_file + data_file=$(mktemp) || return 0 + printf '%s' "$data_json" >"$data_file" || { + rm -f "$data_file" + return 0 + } + envelope=$(jq -nc \ + --arg schema_version "1.0" \ + --arg timestamp "$timestamp" \ + --arg hook "$hook_id" \ + --arg hook_event "$hook_event" \ + --arg status "$status" \ + --argjson duration_ms "$duration_ms" \ + --slurpfile data "$data_file" \ + '{schema_version:$schema_version,timestamp:$timestamp,hook:$hook,hook_event:$hook_event,status:$status,duration_ms:$duration_ms,data:($data[0] // {})}' \ + 2>/dev/null) || { + rm -f "$data_file" + return 0 + } rm -f "$data_file" - return 0 - } - rm -f "$data_file" + fi # Resolve the sink path. A relative HOOK_TELEMETRY_SINK is joined onto the # consuming repo root (portable, tracked wiring); absolute is used as-is. A diff --git a/plugins/bash-format/.claude-plugin/plugin.json b/plugins/bash-format/.claude-plugin/plugin.json index 0fd4cb6230..a39e0898c2 100644 --- a/plugins/bash-format/.claude-plugin/plugin.json +++ b/plugins/bash-format/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "bash-format", - "version": "0.7.30", + "version": "0.7.31", "description": "Auto-format and lint shell scripts on edit via shfmt + ShellCheck, using the consuming repo's own .editorconfig and .shellcheckrc.", "author": { "name": "Melodic Software", diff --git a/plugins/bash-format/CHANGELOG.md b/plugins/bash-format/CHANGELOG.md index 6d080e8425..f3338f8d3c 100644 --- a/plugins/bash-format/CHANGELOG.md +++ b/plugins/bash-format/CHANGELOG.md @@ -3,6 +3,22 @@ All notable changes to the `bash-format` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.7.31] + +### Changed + +- **Vendored `hook-utils.sh` builds the telemetry envelope and reads `file_path` + with shell builtins.** `hook::emit_telemetry` no longer spawns two jq + processes, a mktemp and an rm per run: the envelope is assembled in the shell + as one compact line (the same document jq produced, now `jq -c` shaped), with + jq kept only as the fallback for a data object the builtin compactor cannot + prove. `hook::read_file_path` takes `.tool_input.file_path` without jq on the + well-formed payload shape and resolves the file, project root and temp roots + with one batched `realpath` instead of one process each. Same verdicts, same + emitted path, same sink record; phase 4b of the hook-performance program + (#3623). The copy is bumped because `scripts/sync-hook-utils.sh` keeps every + carrying plugin byte-identical. + ## [0.7.30] ### Changed diff --git a/plugins/bash-format/hooks/hook-utils.sh b/plugins/bash-format/hooks/hook-utils.sh index 9818a6a73d..18e4dd5745 100644 --- a/plugins/bash-format/hooks/hook-utils.sh +++ b/plugins/bash-format/hooks/hook-utils.sh @@ -390,19 +390,34 @@ hook::require_jq_blocking() { # collapse with `/c/repo` and the membership guard would admit a sibling # outside CLAUDE_PROJECT_DIR. The result is used ONLY for comparison; the # emitted path is always the caller's original. -hook::normalize_path() { - local p="${1//\\//}" +# +# Two spellings of every path helper below: `hook::` prints the answer +# (the public, subshell-friendly form every caller already uses) and +# `hook::_to ` stores it in the caller's variable instead. +# The `_to` form is what the hot path uses: a `$(...)` capture is a fork, and +# on Windows Git Bash a fork costs milliseconds, so the file_path guard that +# ran a dozen of them per hook now runs none it does not need. The `_to` +# helpers keep their locals under a `__hu_` prefix so the caller's variable +# name cannot collide with them. +hook::normalize_path_to() { + local __hu_p="${2//\\//}" case "${OSTYPE:-}" in msys* | cygwin* | win32) - if [[ "$p" =~ ^/([a-zA-Z])/ || "$p" =~ ^([a-zA-Z]):/ ]]; then - local rest="${p:2}" - printf '%s' "${BASH_REMATCH[1]^}:${rest,,}" - return + if [[ "$__hu_p" =~ ^/([a-zA-Z])/ || "$__hu_p" =~ ^([a-zA-Z]):/ ]]; then + local __hu_rest="${__hu_p:2}" + printf -v "$1" '%s' "${BASH_REMATCH[1]^}:${__hu_rest,,}" + return 0 fi ;; *) ;; # POSIX hosts: case-sensitive FS, no drive fold — pass through below esac - printf '%s' "$p" + printf -v "$1" '%s' "$__hu_p" +} + +hook::normalize_path() { + local __hu_n + hook::normalize_path_to __hu_n "$1" + printf '%s' "$__hu_n" } # Expand Windows 8.3 short-name components (KYLESE~1 → KyleSexton) on @@ -424,25 +439,31 @@ hook::normalize_path() { # class: cygpath ships with Git Bash (the documented Windows bash), so its # absence or failure keeps the resolver's answer unchanged — degrading to the # pre-expansion comparison, same doctrine as the resolver fallback below. -hook::expand_8dot3() { - local p="$1" +hook::expand_8dot3_to() { + local __hu_p="$2" case "${OSTYPE:-}" in msys* | cygwin* | win32) ;; *) - printf '%s' "$p" - return + printf -v "$1" '%s' "$__hu_p" + return 0 ;; esac - if [[ "$p" == *~* ]] && command -v cygpath >/dev/null 2>&1; then - local plain long - if plain=$(cygpath -m -- "$p" 2>/dev/null) && - long=$(cygpath -l -m -- "$p" 2>/dev/null) && - [[ -n "$long" && "$long" != "$plain" ]]; then - printf '%s' "$long" - return + if [[ "$__hu_p" == *~* ]] && command -v cygpath >/dev/null 2>&1; then + local __hu_plain __hu_long + if __hu_plain=$(cygpath -m -- "$__hu_p" 2>/dev/null) && + __hu_long=$(cygpath -l -m -- "$__hu_p" 2>/dev/null) && + [[ -n "$__hu_long" && "$__hu_long" != "$__hu_plain" ]]; then + printf -v "$1" '%s' "$__hu_long" + return 0 fi fi - printf '%s' "$p" + printf -v "$1" '%s' "$__hu_p" +} + +hook::expand_8dot3() { + local __hu_e + hook::expand_8dot3_to __hu_e "$1" + printf '%s' "$__hu_e" } # Canonicalize to a physical path — symlinks resolved, Windows 8.3 short names @@ -462,20 +483,148 @@ hook::expand_8dot3() { # expansion applies only on the resolver's success path: consumers that fail # closed on an unresolved signature must not see a form-converted path instead. # shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED -hook::physical_path() { - local resolved +hook::physical_path_to() { + local __hu_r HOOK_PHYSICAL_PATH_UNRESOLVED=0 - if resolved=$(realpath -- "$1" 2>/dev/null) || resolved=$(readlink -f -- "$1" 2>/dev/null); then - if [[ -n "$resolved" ]]; then - hook::expand_8dot3 "$resolved" + if __hu_r=$(realpath -- "$2" 2>/dev/null) || __hu_r=$(readlink -f -- "$2" 2>/dev/null); then + if [[ -n "$__hu_r" ]]; then + hook::expand_8dot3_to "$1" "$__hu_r" return 0 fi fi HOOK_PHYSICAL_PATH_UNRESOLVED=1 - printf '%s' "$1" + printf -v "$1" '%s' "$2" + return 1 +} + +# shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED +hook::physical_path() { + local __hu_v __hu_rc=0 + hook::physical_path_to __hu_v "$1" || __hu_rc=$? + printf '%s' "$__hu_v" + return "$__hu_rc" +} + +# --- Per-process physical-path cache -------------------------------------- +# The membership guard resolves the same few directories on every call: the +# project root and this host's temp roots. Their physical form does not change +# within one hook process, so each spelling is resolved once and remembered in +# three parallel indexed arrays (keys, values, resolver status). Plain arrays, +# not an associative array, so the cache runs on Bash 3.2. Only directories are +# meant to live here for a process lifetime; hook::read_file_path forgets the +# edited file's entry as soon as it has read it, so a later call in the same +# process sees the file as it is then. +_HOOK_PHYS_KEYS=() +_HOOK_PHYS_VALS=() +_HOOK_PHYS_RCS=() +_HOOK_PHYS_I=-1 + +# hook::_phys_cache_index : sets _HOOK_PHYS_I to the slot holding , +# returns 1 when it is not cached. A forgotten slot has an empty key, which no +# real path can match. +hook::_phys_cache_index() { + local __hu_i + [[ -n "$1" ]] || return 1 + for ((__hu_i = 0; __hu_i < ${#_HOOK_PHYS_KEYS[@]}; __hu_i++)); do + if [[ "${_HOOK_PHYS_KEYS[__hu_i]}" == "$1" ]]; then + _HOOK_PHYS_I=$__hu_i + return 0 + fi + done return 1 } +hook::_phys_cache_forget() { + hook::_phys_cache_index "$1" && _HOOK_PHYS_KEYS[_HOOK_PHYS_I]="" + return 0 +} + +# hook::_physical_cached_to : hook::physical_path_to through the +# cache. Same value, same return status, same HOOK_PHYSICAL_PATH_UNRESOLVED as +# the uncached call; a miss resolves the one path and stores it. +# shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED +hook::_physical_cached_to() { + if hook::_phys_cache_index "$2"; then + printf -v "$1" '%s' "${_HOOK_PHYS_VALS[_HOOK_PHYS_I]}" + HOOK_PHYSICAL_PATH_UNRESOLVED=${_HOOK_PHYS_RCS[_HOOK_PHYS_I]} + return "${_HOOK_PHYS_RCS[_HOOK_PHYS_I]}" + fi + local __hu_v __hu_rc=0 + hook::physical_path_to __hu_v "$2" || __hu_rc=$? + if [[ -n "$2" ]]; then + _HOOK_PHYS_KEYS+=("$2") + _HOOK_PHYS_VALS+=("$__hu_v") + _HOOK_PHYS_RCS+=("$__hu_rc") + fi + printf -v "$1" '%s' "$__hu_v" + return "$__hu_rc" +} + +# hook::_physical_prime ...: resolve every uncached path with ONE +# realpath process and store the answers. realpath resolves each argument on +# its own and prints one line per argument in argument order, so the batch +# answer for a path is the same string the per-path call returns; the 8.3 +# expansion is applied per answer exactly as hook::physical_path_to does. Any +# doubt (a path carrying a newline, a non-zero exit, a line count that does not +# match) stores nothing and leaves the per-path resolver to answer lazily, so +# the batch can only ever save work, never change an answer. readlink -f hosts +# (no realpath) skip the batch for the same reason. +hook::_physical_prime() { + local -a __hu_todo=() + local __hu_p __hu_out __hu_i __hu_seen="" __hu_v + for __hu_p in "$@"; do + [[ -n "$__hu_p" ]] || continue + [[ "$__hu_p" == *$'\n'* ]] && return 0 + case "$__hu_seen" in + *"|$__hu_p|"*) continue ;; + *) ;; # first sighting + esac + __hu_seen="$__hu_seen|$__hu_p|" + hook::_phys_cache_index "$__hu_p" && continue + __hu_todo+=("$__hu_p") + done + ((${#__hu_todo[@]} > 1)) || return 0 + command -v realpath >/dev/null 2>&1 || return 0 + # portability-ok: realpath with several operands is GNU and BSD alike; a host whose realpath rejects it fails the exit-status check and falls back per path + __hu_out=$(realpath -- "${__hu_todo[@]}" 2>/dev/null) || return 0 + local -a __hu_lines=() + local __hu_glob=0 + [[ $- == *f* ]] || __hu_glob=1 + set -f + local IFS=$'\n' + # shellcheck disable=SC2206 # splitting realpath's one-line-per-operand output is the intent + __hu_lines=($__hu_out) + ((__hu_glob)) && set +f + ((${#__hu_lines[@]} == ${#__hu_todo[@]})) || return 0 + for ((__hu_i = 0; __hu_i < ${#__hu_todo[@]}; __hu_i++)); do + [[ -n "${__hu_lines[__hu_i]}" ]] || return 0 + hook::expand_8dot3_to __hu_v "${__hu_lines[__hu_i]}" + _HOOK_PHYS_KEYS+=("${__hu_todo[__hu_i]}") + _HOOK_PHYS_VALS+=("$__hu_v") + _HOOK_PHYS_RCS+=(0) + done + return 0 +} + +# The temp-root candidates hook::under_temp_root compares against: the +# environment's own answer (TMPDIR/TMP/TEMP) plus the POSIX defaults, never a +# hardcoded platform assumption. Existing directories only, each spelling +# once, in _HOOK_TEMP_CANDS. +_HOOK_TEMP_CANDS=() +hook::_temp_root_candidates() { + local __hu_cand __hu_seen="" + _HOOK_TEMP_CANDS=() + for __hu_cand in "${TMPDIR:-}" "${TMP:-}" "${TEMP:-}" /tmp /var/tmp; do + [[ -n "$__hu_cand" && -d "$__hu_cand" ]] || continue + case "$__hu_seen" in + *"|$__hu_cand|"*) continue ;; + *) ;; # first sighting of this candidate + esac + __hu_seen="$__hu_seen|$__hu_cand|" + _HOOK_TEMP_CANDS+=("$__hu_cand") + done +} + # True when sits inside one of this host's temp trees. # Both arguments and candidates go through the same canonicalize+normalize # pipeline as the membership comparison, because the same directory has several @@ -483,21 +632,40 @@ hook::physical_path() { # of the identical directory, and `realpath` resolves the Windows form to a # drive path while leaving `/tmp` as `/tmp`. Neither form alone matches a # `file_path` that could arrive in either, so every candidate is compared and a -# match on any one is a match. Duplicates are resolved once. +# match on any one is a match. Duplicates are resolved once, and each +# candidate's physical form is remembered for the process (see the cache +# above), so a second call costs no resolver process. # -# Candidates are the environment's own answer (TMPDIR/TMP/TEMP) plus the POSIX -# defaults, never a hardcoded platform assumption. +# Raw-spelling shortcut: when the caller vouches that is a +# PHYSICAL path (_HOOK_UTR_TARGET_PHYSICAL=1, set only by hook::read_file_path +# after the resolver succeeded), a candidate whose raw normalized spelling is +# already a prefix of the target is a match without resolving it. A physical +# path contains no symlink component, so a candidate that spells one of its +# prefixes names a chain of real directories whose physical form is that same +# prefix; the resolver would answer the same. A candidate that does not match +# raw is still resolved, so a symlinked temp root (macOS /tmp) is found the +# way it always was. Without the vouch the shortcut is off and every candidate +# is resolved, exactly as before. Note that hook::read_file_path primes every +# candidate into the cache with one batched realpath before calling here, so +# on that path the shortcut skips a lookup, not a process; the batch is what +# saves the processes. A direct caller without the batch saves the resolver. # hook::under_temp_root "$norm_path" && ... +_HOOK_UTR_TARGET_PHYSICAL=0 hook::under_temp_root() { - local target="$1" cand norm seen="" - for cand in "${TMPDIR:-}" "${TMP:-}" "${TEMP:-}" /tmp /var/tmp; do - [[ -n "$cand" && -d "$cand" ]] || continue - case "$seen" in - *"|$cand|"*) continue ;; - *) ;; # first sighting of this candidate — resolve it below - esac - seen="$seen|$cand|" - norm=$(hook::normalize_path "$(hook::physical_path "$cand")") + local target="$1" cand norm phys + hook::_temp_root_candidates + for cand in ${_HOOK_TEMP_CANDS[@]+"${_HOOK_TEMP_CANDS[@]}"}; do + if ((_HOOK_UTR_TARGET_PHYSICAL)); then + hook::normalize_path_to norm "$cand" + if [[ "$norm" != / ]]; then + norm="${norm%/}" + if [[ -n "$norm" && ("$target" == "$norm" || "$target" == "$norm"/*) ]]; then + return 0 + fi + fi + fi + hook::_physical_cached_to phys "$cand" || : + hook::normalize_path_to norm "$phys" # The filesystem root as a temp candidate contains every absolute path; # trimming its only slash would empty the candidate and discard it. [[ "$norm" == / ]] && return 0 @@ -523,21 +691,421 @@ hook::in_git_working_tree() { ) >/dev/null 2>&1 } +# --- Builtin JSON helpers ---------------------------------------------------- +# hook::read_file_path and hook::emit_telemetry used to spend a jq process each +# on every hook run. On Windows Git Bash one spawn costs tens of milliseconds, +# so the helpers here handle the well-formed shapes those two functions see in +# practice with shell builtins and hand anything they cannot PROVE to the +# unchanged jq path. The contract is "the bytes jq would produce, or fall +# back", never "close enough". +# +# What the builtin paths rely on: the text is valid JSON. Every hook payload +# reaching hook::read_file_path has passed hook::buffer_stdin's jq validation, +# and every telemetry data object is built with jq. The helpers still verify +# the structure they walk (terminated strings, well-formed tokens, properly +# nested brackets, one root, no raw control bytes inside strings), but they +# are not a full JSON parser and do not claim to reject every malformed text. +# +# Why no scanning: on this repo's Windows hosts bash regex matching and the +# `%%`/`##` pattern operators cost about a microsecond per character, so a +# 60 KB Write payload would cost more than the jq process they replace. The +# only string operations used on the whole payload are literal-substring +# replacement, `[[ == *x* ]]` containment, IFS word splitting and offset +# slicing, all of which run at C speed. + +# hook::_json_split +# Split at every unescaped double quote into the global array +# _HOOK_JSON_PARTS: even indices hold the structural text between strings, odd +# indices hold string bodies. Before splitting, every `\\` and `\"` pair is +# replaced by `@@` (same length), so a quote that survives is a real string +# delimiter and a body's ORIGINAL bytes are ${text:offset:length}. Returns 1 +# when the text is too large, its quotes do not pair up, or it holds more +# strings than the callers' loops are meant to walk. +_HOOK_JSON_PARTS=() +hook::_json_split() { + local __hu_s="$1" __hu_t __hu_q __hu_n + ((${#__hu_s} <= 65536)) || return 1 + __hu_t=${__hu_s//"\\\\"/@@} + __hu_t=${__hu_t//"\\\""/@@} + __hu_q=${__hu_t//\"/} + __hu_n=$((${#__hu_t} - ${#__hu_q})) + ((__hu_n % 2 == 0)) || return 1 + ((__hu_n <= 800)) || return 1 + local __hu_glob=0 + [[ $- == *f* ]] || __hu_glob=1 + set -f + local IFS='"' + # shellcheck disable=SC2206 # splitting on IFS='"' is the whole point + _HOOK_JSON_PARTS=($__hu_t) + ((__hu_glob)) && set +f + ((${#_HOOK_JSON_PARTS[@]} % 2 == 1)) || return 1 + return 0 +} + +# hook::_json_skeleton +# Split (hook::_json_split), record each string body's byte offset in +# _HOOK_JSON_OFF, and build _HOOK_JSON_SK: the structural text with every +# string replaced by a numbered placeholder `"#"` and all JSON +# whitespace removed. The skeleton is small (no string bodies), so regex and +# per-token work on it is cheap. Returns 1 unless the text is exactly one JSON +# value by the JSON grammar (well-formed tokens, whitespace only between them, +# objects as key:value lists, arrays as value lists, brackets nested and +# closed once) whose strings carry no raw control byte, which jq rejects. +_HOOK_JSON_SK="" +_HOOK_JSON_OFF=() +hook::_json_skeleton() { + local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top + hook::_json_split "$__hu_s" || return 1 + __hu_n=${#_HOOK_JSON_PARTS[@]} + _HOOK_JSON_OFF=() + for ((__hu_i = 0; __hu_i < __hu_n; __hu_i++)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + if ((__hu_i % 2 == 0)); then + __hu_sk+=$__hu_part + else + _HOOK_JSON_OFF[__hu_i]=$__hu_off + [[ "$__hu_part" == *[[:cntrl:]]* ]] && return 1 + __hu_sk+="\"#$__hu_i\"" + fi + __hu_off=$((__hu_off + ${#__hu_part} + 1)) + done + # Tokenize the skeleton and run it through the JSON grammar. Each token is + # consumed by one anchored regex on the (small) remainder; whitespace is + # allowed only between tokens, so `tr ue` is two bad tokens, not `true`. + local __hu_re=$'^[ \t\n\r]*(\\{|\\}|\\[|\\]|,|:|"#[0-9]+"|-?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][-+]?[0-9]+)?|true|false|null)' + local __hu_ws=$'^[ \t\n\r]*$' + __hu_rest=$__hu_sk + while [[ -n "$__hu_rest" ]] && ! [[ "$__hu_rest" =~ $__hu_ws ]]; do + [[ "$__hu_rest" =~ $__hu_re ]] || return 1 + __hu_tok=${BASH_REMATCH[1]} + __hu_rest=${__hu_rest:${#BASH_REMATCH[0]}} + __hu_top=${__hu_stack:${#__hu_stack}-1:1} + case "$__hu_expect" in + value | value_or_end) + case "$__hu_tok" in + '{') + __hu_stack+='{' + __hu_expect=key_or_end + continue + ;; + '[') + __hu_stack+='[' + __hu_expect=value_or_end + continue + ;; + ']') + [[ "$__hu_expect" == value_or_end ]] || return 1 + ;; + '}' | ',' | ':') return 1 ;; + *) + # A scalar. Inside a container the next token is , or the close; + # at the top level nothing may follow. + if [[ -n "$__hu_stack" ]]; then __hu_expect=comma_or_end; else __hu_expect=end; fi + continue + ;; + esac + ;; + key_or_end | key) + case "$__hu_tok" in + \"#*) + __hu_expect='colon' + continue + ;; + '}') [[ "$__hu_expect" == key_or_end ]] || return 1 ;; + *) return 1 ;; + esac + ;; + colon) + [[ "$__hu_tok" == ':' ]] || return 1 + __hu_expect=value + continue + ;; + comma_or_end) + case "$__hu_tok" in + ',') + if [[ "$__hu_top" == '{' ]]; then __hu_expect=key; else __hu_expect=value; fi + continue + ;; + '}') [[ "$__hu_top" == '{' ]] || return 1 ;; + ']') [[ "$__hu_top" == '[' ]] || return 1 ;; + *) return 1 ;; + esac + ;; + *) return 1 ;; # `end`: a token after the root value closed + esac + # Reaching here means a container just closed. + [[ -n "$__hu_stack" ]] || return 1 + __hu_stack=${__hu_stack:0:${#__hu_stack}-1} + if [[ -n "$__hu_stack" ]]; then __hu_expect=comma_or_end; else __hu_expect=end; fi + done + [[ "$__hu_expect" == end ]] || return 1 + _HOOK_JSON_SK=${__hu_sk//[$' \t\n\r']/} + return 0 +} + +# hook::json_unescape_to +# Decode a JSON string body (the bytes between the quotes) into . Handles +# \" \\ \/ \b \f \n \r \t and \uXXXX for U+0001 to U+007F. Returns 1 on any +# other escape (a \u outside that range, U+0000 which bash cannot hold, or an +# invalid escape), leaving the caller to fall back to jq. +hook::json_unescape_to() { + local __hu_raw="$2" __hu_out="" __hu_pre __hu_c __hu_hex __hu_oct __hu_ch + while [[ "$__hu_raw" == *\\* ]]; do + __hu_pre=${__hu_raw%%\\*} + __hu_out+=$__hu_pre + __hu_raw=${__hu_raw:${#__hu_pre}+1} + __hu_c=${__hu_raw:0:1} + __hu_raw=${__hu_raw:1} + case "$__hu_c" in + '"') __hu_out+='"' ;; + "\\") __hu_out+="\\" ;; + '/') __hu_out+='/' ;; + b) __hu_out+=$'\b' ;; # portability-ok: bash ANSI-C backspace byte, not a GNU grep word boundary + f) __hu_out+=$'\f' ;; + n) __hu_out+=$'\n' ;; + r) __hu_out+=$'\r' ;; + t) __hu_out+=$'\t' ;; + u) + __hu_hex=${__hu_raw:0:4} + [[ "$__hu_hex" =~ ^00[0-7][0-9a-fA-F]$ ]] || return 1 + [[ "$__hu_hex" == 0000 ]] && return 1 + __hu_raw=${__hu_raw:4} + printf -v __hu_oct '%03o' "$((16#$__hu_hex))" + printf -v __hu_ch '%b' "\\0$__hu_oct" + __hu_out+=$__hu_ch + ;; + *) return 1 ;; + esac + done + __hu_out+=$__hu_raw + printf -v "$1" '%s' "$__hu_out" +} + +# hook::_fast_file_path_to +# The builtin answer to `jq -r '(.tool_input.file_path // empty) | +# gsub("\r";"")'` followed by the `$(...)` capture. Returns +# 0 proven: holds exactly what jq would have printed +# 1 proven absent: jq would have printed nothing (the guard skips) +# 2 not proven: run jq +# Proof, on top of hook::_json_skeleton's structural checks: the root is an +# object; exactly one string in the whole payload decodes to `tool_input` and +# exactly one to `file_path` (so neither a duplicate key nor a same-named key +# in another object can change jq's answer); `tool_input` is a key of the root +# (depth 1, key position) whose value is a flat object (no nested object or +# array, else jq); and inside it `file_path` is a key with a plain string +# value. A payload where `file_path` names a value or sits in another object +# has no `.tool_input.file_path`, which jq reports as nothing. Key strings are +# compared AFTER decoding their escapes, so a key spelled with \u escapes is +# still recognized; a body longer than any escaped spelling of either name is +# skipped without decoding. +hook::_fast_file_path_to() { + local __hu_s="$2" __hu_i __hu_n __hu_part __hu_body __hu_ti=-1 __hu_fp=-1 __hu_m __hu_raw __hu_pre __hu_re __hu_file + hook::_json_skeleton "$__hu_s" || return 2 + [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 + __hu_n=${#_HOOK_JSON_PARTS[@]} + for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + ((${#__hu_part} <= 60)) || continue + __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} + if [[ "$__hu_body" == *\\* ]]; then + hook::json_unescape_to __hu_body "$__hu_body" || continue + fi + case "$__hu_body" in + tool_input) + ((__hu_ti < 0)) || return 2 + __hu_ti=$__hu_i + ;; + file_path) + ((__hu_fp < 0)) || return 2 + __hu_fp=$__hu_i + ;; + *) ;; + esac + done + ((__hu_fp >= 0)) || return 1 + ((__hu_ti >= 0)) || return 1 + __hu_re="(^|[{,])\"#$__hu_ti\":\\{([^][{}]*)\\}" + if [[ "$_HOOK_JSON_SK" =~ $__hu_re ]]; then + __hu_body=${BASH_REMATCH[2]} + __hu_pre=${_HOOK_JSON_SK%%\"#"$__hu_ti"\"*} + # Depth of the key: opening brackets minus closing ones before it must be + # exactly one, i.e. a direct member of the root object. + local __hu_o1=${__hu_pre//\{/} __hu_o2=${__hu_pre//\[/} __hu_c1=${__hu_pre//\}/} __hu_c2=${__hu_pre//\]/} + local __hu_depth=$(((${#__hu_pre} - ${#__hu_o1}) + (${#__hu_pre} - ${#__hu_o2}) - (${#__hu_pre} - ${#__hu_c1}) - (${#__hu_pre} - ${#__hu_c2}))) + ((__hu_depth == 1)) || return 2 + else + return 2 + fi + __hu_re="(^|,)\"#$__hu_fp\":\"#([0-9]+)\"(,|$)" + if [[ "$__hu_body" =~ $__hu_re ]]; then + __hu_m=${BASH_REMATCH[2]} + elif [[ "$__hu_body" == *\"#$__hu_fp\"* ]]; then + return 2 + else + return 1 + fi + __hu_raw=${__hu_s:${_HOOK_JSON_OFF[__hu_m]}:${#_HOOK_JSON_PARTS[__hu_m]}} + if [[ "$__hu_raw" == *\\* ]]; then + hook::json_unescape_to __hu_file "$__hu_raw" || return 2 + else + __hu_file=$__hu_raw + fi + __hu_file=${__hu_file//$'\r'/} + while [[ "$__hu_file" == *$'\n' ]]; do + __hu_file=${__hu_file%$'\n'} + done + printf -v "$1" '%s' "$__hu_file" + return 0 +} + +# hook::dirname_to : dirname with builtins for the resolver's +# answer. Strips the last segment; a bare name lives in `.`, a root-level file +# in `/`. The path comes from realpath, so the trailing-slash and doubled-slash +# spellings dirname also collapses never occur here. +hook::dirname_to() { + local __hu_d="${2%/*}" + [[ "$__hu_d" == "$2" ]] && __hu_d=. + [[ -n "$__hu_d" ]] || __hu_d=/ + printf -v "$1" '%s' "$__hu_d" +} + +# Print the arguments joined by NUL bytes, no trailing NUL: the byte stream +# hook::read_file_path read from stdin, rebuilt for the jq fallback. +hook::_print_nul_joined() { + local __hu_first=1 __hu_c + for __hu_c in "$@"; do + ((__hu_first)) || printf '\0' + __hu_first=0 + printf '%s' "$__hu_c" + done +} + +# hook::json_compact_to +# The bytes `jq -c .` prints for , when that can be proven with +# builtins: JSON whitespace between tokens removed, everything else verbatim. +# jq's own output, compact or pretty, satisfies the proof, which is what every +# telemetry data builder in this marketplace produces (jq -c, jq -n, or a +# compact literal). Returns 1 when the text is not a single object, or a +# string carries something jq would re-encode (a \u or \/ escape, an escape +# outside the JSON set, a raw control byte), or the structure fails +# hook::_json_skeleton; the caller then runs jq. +hook::json_compact_to() { + local __hu_s="$2" __hu_out="" __hu_i __hu_part __hu_n + hook::_json_skeleton "$__hu_s" || return 1 + [[ "$_HOOK_JSON_SK" == \{*\} ]] || return 1 + # Numbers: only short integer literals are passed through. A fraction, an + # exponent or an integer beyond 15 digits is rendered differently by + # different jq releases (1.6 canonicalizes, 1.7+ preserves the literal), so + # the bytes cannot be proven; jq decides. In the skeleton every string is a + # `"#N"` placeholder and letters occur only in true/false/null, so a digit + # followed by `.`, `e` or `E` can only be part of a number. + local __hu_num='[0-9][.eE]|[0-9]{16}' + [[ "$_HOOK_JSON_SK" =~ $__hu_num ]] && return 1 + __hu_n=${#_HOOK_JSON_PARTS[@]} + for ((__hu_i = 0; __hu_i < __hu_n; __hu_i++)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + if ((__hu_i % 2 == 0)); then + __hu_out+=${__hu_part//[$' \t\n\r']/} + else + if [[ "$__hu_part" == *\\* ]]; then + [[ "$__hu_part" == *'\u'* || "$__hu_part" == *'\/'* ]] && return 1 + [[ "$__hu_part" == *\\[!bfnrt]* ]] && return 1 + fi + __hu_out+="\"${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}}\"" + fi + done + printf -v "$1" '%s' "$__hu_out" +} + +# hook::json_escape_jq_to / hook::json_escape_jq +# Escape exactly as jq serializes a string value (without the +# quotes): backslash and double quote, the five short forms \b \f \n \r \t, +# every other byte from 0x01 to 0x1f and 0x7f as lowercase \u00xx, and +# everything else, including non-ASCII, verbatim. This is the escaper the +# telemetry envelope needs for byte parity with the jq envelope it replaced; +# hook::json_escape above is a different tool (it DROPS the residual control +# bytes, which is right for a notice and wrong for parity) and is unchanged. +hook::json_escape_jq_to() { + local __hu_s="$2" __hu_i __hu_c __hu_oct __hu_hex + __hu_s="${__hu_s//\\/\\\\}" + __hu_s="${__hu_s//\"/\\\"}" + if [[ "$__hu_s" == *[[:cntrl:]]* ]]; then + __hu_s="${__hu_s//$'\n'/\\n}" + __hu_s="${__hu_s//$'\r'/\\r}" + __hu_s="${__hu_s//$'\t'/\\t}" + __hu_s="${__hu_s//$'\b'/\\b}" # portability-ok: bash ANSI-C backspace byte and the JSON \b escape, not a GNU grep word boundary + __hu_s="${__hu_s//$'\f'/\\f}" + for __hu_i in 1 2 3 4 5 6 7 11 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 127; do + printf -v __hu_oct '%03o' "$__hu_i" + printf -v __hu_c '%b' "\\0$__hu_oct" + [[ "$__hu_s" == *"$__hu_c"* ]] || continue + printf -v __hu_hex '%04x' "$__hu_i" + __hu_s="${__hu_s//"$__hu_c"/\\u$__hu_hex}" + done + fi + printf -v "$1" '%s' "$__hu_s" +} + +hook::json_escape_jq() { + local __hu_e + hook::json_escape_jq_to __hu_e "$1" + printf '%s' "$__hu_e" +} + # Parse file_path from PostToolUse JSON on stdin; validate existence and (when # CLAUDE_PROJECT_DIR is set) project membership. Both sides of the membership # comparison are canonicalized (symlinks resolved) first, so neither an # escaping symlink nor a project root reached via a symlinked path (e.g. # macOS /tmp) skews the verdict. Outputs the path on success. Returns 1 to skip. # FILE=$(hook::read_file_path) || exit 0 +# +# jq-free on the common shape. The payload is read into the shell and +# hook::_fast_file_path_to takes `.tool_input.file_path` with builtins when it +# can PROVE the answer jq would give (see that helper for what "prove" means); +# every other payload goes to the unchanged jq filter. The path comparison then +# resolves the file, the project root and the temp roots with one realpath +# process instead of one each (hook::_physical_prime), and remembers the +# directories for the process. Same verdict, same emitted path, fewer +# processes: on Windows Git Bash each spawn costs tens of milliseconds and this +# guard runs on every Write and Edit. hook::read_file_path() { - local file - file=$(jq -r '(.tool_input.file_path // empty) | gsub("\r";"")' 2>/dev/null) + local -a chunks=() + local chunk file="" mode=2 + # Builtin read to NUL or EOF. A NUL splits the payload into several chunks; + # the fast path only takes a single-chunk (NUL-free) payload, and the jq + # fallback is fed the chunks NUL-joined, so it sees the bytes jq used to + # read straight from stdin. + while :; do + chunk="" + if IFS= read -r -d '' chunk; then + chunks+=("$chunk") + continue + fi + chunks+=("$chunk") + break + done + if ((${#chunks[@]} == 1)); then + mode=0 + hook::_fast_file_path_to file "${chunks[0]}" || mode=$? + fi + case "$mode" in + 0) ;; # proven: `file` holds jq's answer + 1) return 1 ;; # proven absent + *) + file=$(hook::_print_nul_joined "${chunks[@]}" | jq -r '(.tool_input.file_path // empty) | gsub("\r";"")' 2>/dev/null) + ;; + esac [[ -n "$file" ]] || return 1 [[ -f "$file" ]] || return 1 if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then - local norm_file norm_project - norm_file=$(hook::normalize_path "$(hook::physical_path "$file")") - norm_project=$(hook::normalize_path "$(hook::physical_path "${CLAUDE_PROJECT_DIR}")") + local norm_file norm_project phys_file phys_project file_resolved=0 project_resolved=0 + hook::_temp_root_candidates + hook::_physical_prime "$file" "${CLAUDE_PROJECT_DIR}" ${_HOOK_TEMP_CANDS[@]+"${_HOOK_TEMP_CANDS[@]}"} + hook::_physical_cached_to phys_file "$file" && file_resolved=1 + hook::_phys_cache_forget "$file" + hook::_physical_cached_to phys_project "${CLAUDE_PROJECT_DIR}" && project_resolved=1 + hook::normalize_path_to norm_file "$phys_file" + hook::normalize_path_to norm_project "$phys_project" norm_project="${norm_project%/}" # Anchor on a path-segment boundary: accept the project root itself or a # child under it, but not a sibling whose name merely shares the prefix @@ -558,19 +1126,28 @@ hook::read_file_path() { # built by `mktemp -d`, which is how this repo's own hook suites run), so # the branch must not fire. Only a temp-tree file reached from a project # root OUTSIDE the temp tree is scratch. - if hook::under_temp_root "$norm_file" && ! hook::under_temp_root "$norm_project"; then - return 1 + local file_in_temp=0 + _HOOK_UTR_TARGET_PHYSICAL=$file_resolved + hook::under_temp_root "$norm_file" && file_in_temp=1 + _HOOK_UTR_TARGET_PHYSICAL=0 + if ((file_in_temp)); then + local project_in_temp=0 + _HOOK_UTR_TARGET_PHYSICAL=$project_resolved + hook::under_temp_root "$norm_project" && project_in_temp=1 + _HOOK_UTR_TARGET_PHYSICAL=0 + ((project_in_temp)) || return 1 fi elif command -v git >/dev/null 2>&1; then # When CLAUDE_PROJECT_DIR is unset, scope to git-working-tree membership so # scratch files outside any repository are not mutated by formatter hooks # (#1091 / #972). - local file_physical - file_physical=$(hook::physical_path "$file") + local file_physical file_dir + hook::physical_path_to file_physical "$file" || : if [[ -L "$file" && "$file_physical" == "$file" ]]; then return 1 fi - if ! hook::in_git_working_tree "$(dirname "$file_physical")"; then + hook::dirname_to file_dir "$file_physical" + if ! hook::in_git_working_tree "$file_dir"; then return 1 fi else @@ -1228,7 +1805,19 @@ hook::telemetry_enabled() { # Fire-and-forget: sink is dispatched in the background; the hook never waits # on it and its failure never affects the hook's own exit code or stdout. # Opt-in guard: HOOK_TELEMETRY_SINK unset or empty → return 0 immediately. -# Fail-open: jq absent → return 0 immediately. +# +# The envelope is assembled with shell builtins (hook::json_escape_jq_to for +# the string fields, hook::json_compact_to for the caller's data object) as +# ONE compact line, `jq -c` style: the same document, field order and +# escapes the jq filter below produces, with the data object compacted +# exactly as jq compacts its own output. That path needs no jq, no temp file +# and no subprocess. When the data object cannot be proven jq-idempotent (see +# hook::json_compact_to), the jq path runs, now with -c so both paths write +# the same bytes, and there jq's absence is still fail-open: return 0, no +# envelope. Before this the envelope was jq's default pretty-printed form +# (several lines, and CRLF line endings with a Windows jq); the sink contract +# was always "one JSON document on stdin", and a compact line is also valid +# JSONL for a sink that appends raw envelopes. # # Usage: # hook::emit_telemetry [repo_root] @@ -1251,7 +1840,6 @@ hook::telemetry_enabled() { # NEVER writes to fd1 (the hook's stdout / additionalContext channel). hook::emit_telemetry() { [[ -n "${HOOK_TELEMETRY_SINK:-}" ]] || return 0 - command -v jq >/dev/null 2>&1 || return 0 local hook_id="$1" local hook_event="$2" @@ -1272,35 +1860,59 @@ hook::emit_telemetry() { local e_s="${now%[.,]*}" e_f="${now#*[.,]}" local duration_ms=$(((e_s * 1000000 + 10#$e_f - s_s * 1000000 - 10#$s_f) / 1000)) - # True UTC timestamp (TZ= prefix overrides LC_ALL / local TZ; the Z is not a lie). - local timestamp - timestamp=$(TZ=UTC printf '%(%Y-%m-%dT%H:%M:%SZ)T' -1) - - # Build the envelope. The `data` object is written to a temp file, not passed - # as --argjson, so payloads larger than the Windows 32767-character command-line - # cap are not dropped before jq runs (#1595). /dev/stdin is not used: jq's - # --slurpfile there is not portable on Windows. Redirect jq stderr to - # /dev/null; output goes to a local variable — never to fd1. - local envelope data_file - data_file=$(mktemp) || return 0 - printf '%s' "$data_json" >"$data_file" || { - rm -f "$data_file" - return 0 - } - envelope=$(jq -n \ - --arg schema_version "1.0" \ - --arg timestamp "$timestamp" \ - --arg hook "$hook_id" \ - --arg hook_event "$hook_event" \ - --arg status "$status" \ - --argjson duration_ms "$duration_ms" \ - --slurpfile data "$data_file" \ - '{schema_version:$schema_version,timestamp:$timestamp,hook:$hook,hook_event:$hook_event,status:$status,duration_ms:$duration_ms,data:($data[0] // {})}' \ - 2>/dev/null) || { + # True UTC timestamp (TZ= prefix overrides LC_ALL / local TZ; the Z is not a + # lie). The %()T printf format is Bash 4.2+, and printf -v keeps it in the + # shell (a `$(...)` capture is a fork); an older bash, or a printf that + # binds nothing, falls back to date -u. + local timestamp="" + TZ=UTC printf -v timestamp '%(%Y-%m-%dT%H:%M:%SZ)T' -1 2>/dev/null || timestamp="" + [[ -n "$timestamp" ]] || timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null) || timestamp="" + + # Trailing whitespace after the object (a newline a caller left on the + # value) does not change what jq parses, so it does not block the builtin + # path either. + local envelope="" data="" data_trim="$data_json" + while [[ "$data_trim" == *[$' \t\r\n'] ]]; do + data_trim="${data_trim%?}" + done + if [[ "$data_trim" == \{*\} ]] && hook::json_compact_to data "$data_trim"; then + # Builtin envelope: the same document the jq filter below produces, field + # for field, with jq's string escaping and jq's compact data. + local e_timestamp e_hook e_event e_status + hook::json_escape_jq_to e_timestamp "$timestamp" + hook::json_escape_jq_to e_hook "$hook_id" + hook::json_escape_jq_to e_event "$hook_event" + hook::json_escape_jq_to e_status "$status" + envelope='{"schema_version":"1.0","timestamp":"'"$e_timestamp"'","hook":"'"$e_hook"'","hook_event":"'"$e_event"'","status":"'"$e_status"'","duration_ms":'"$duration_ms"',"data":'"$data"'}' + else + # jq path, for a data object the builtin compactor cannot prove. Fail-open: + # jq absent → return 0. The `data` object is written to a temp file, not + # passed as --argjson, so payloads larger than the Windows 32767-character + # command-line cap are not dropped before jq runs (#1595). /dev/stdin is + # not used: jq's --slurpfile there is not portable on Windows. Redirect jq + # stderr to /dev/null; output goes to a local variable — never to fd1. + command -v jq >/dev/null 2>&1 || return 0 + local data_file + data_file=$(mktemp) || return 0 + printf '%s' "$data_json" >"$data_file" || { + rm -f "$data_file" + return 0 + } + envelope=$(jq -nc \ + --arg schema_version "1.0" \ + --arg timestamp "$timestamp" \ + --arg hook "$hook_id" \ + --arg hook_event "$hook_event" \ + --arg status "$status" \ + --argjson duration_ms "$duration_ms" \ + --slurpfile data "$data_file" \ + '{schema_version:$schema_version,timestamp:$timestamp,hook:$hook,hook_event:$hook_event,status:$status,duration_ms:$duration_ms,data:($data[0] // {})}' \ + 2>/dev/null) || { + rm -f "$data_file" + return 0 + } rm -f "$data_file" - return 0 - } - rm -f "$data_file" + fi # Resolve the sink path. A relative HOOK_TELEMETRY_SINK is joined onto the # consuming repo root (portable, tracked wiring); absolute is used as-is. A diff --git a/plugins/biome-format/.claude-plugin/plugin.json b/plugins/biome-format/.claude-plugin/plugin.json index b9120dfe0e..169c995a1e 100644 --- a/plugins/biome-format/.claude-plugin/plugin.json +++ b/plugins/biome-format/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "biome-format", - "version": "0.6.29", + "version": "0.6.30", "description": "Auto-format and lint JS/TS/JSX/JSON on edit via Biome, only when a biome.json governs the repo \u2014 using the consuming repo's own Biome config.", "author": { "name": "Melodic Software", diff --git a/plugins/biome-format/CHANGELOG.md b/plugins/biome-format/CHANGELOG.md index 27312b808f..9c211d5be6 100644 --- a/plugins/biome-format/CHANGELOG.md +++ b/plugins/biome-format/CHANGELOG.md @@ -3,6 +3,22 @@ All notable changes to the `biome-format` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.6.30] + +### Changed + +- **Vendored `hook-utils.sh` builds the telemetry envelope and reads `file_path` + with shell builtins.** `hook::emit_telemetry` no longer spawns two jq + processes, a mktemp and an rm per run: the envelope is assembled in the shell + as one compact line (the same document jq produced, now `jq -c` shaped), with + jq kept only as the fallback for a data object the builtin compactor cannot + prove. `hook::read_file_path` takes `.tool_input.file_path` without jq on the + well-formed payload shape and resolves the file, project root and temp roots + with one batched `realpath` instead of one process each. Same verdicts, same + emitted path, same sink record; phase 4b of the hook-performance program + (#3623). The copy is bumped because `scripts/sync-hook-utils.sh` keeps every + carrying plugin byte-identical. + ## [0.6.29] ### Changed diff --git a/plugins/biome-format/hooks/hook-utils.sh b/plugins/biome-format/hooks/hook-utils.sh index 9818a6a73d..18e4dd5745 100644 --- a/plugins/biome-format/hooks/hook-utils.sh +++ b/plugins/biome-format/hooks/hook-utils.sh @@ -390,19 +390,34 @@ hook::require_jq_blocking() { # collapse with `/c/repo` and the membership guard would admit a sibling # outside CLAUDE_PROJECT_DIR. The result is used ONLY for comparison; the # emitted path is always the caller's original. -hook::normalize_path() { - local p="${1//\\//}" +# +# Two spellings of every path helper below: `hook::` prints the answer +# (the public, subshell-friendly form every caller already uses) and +# `hook::_to ` stores it in the caller's variable instead. +# The `_to` form is what the hot path uses: a `$(...)` capture is a fork, and +# on Windows Git Bash a fork costs milliseconds, so the file_path guard that +# ran a dozen of them per hook now runs none it does not need. The `_to` +# helpers keep their locals under a `__hu_` prefix so the caller's variable +# name cannot collide with them. +hook::normalize_path_to() { + local __hu_p="${2//\\//}" case "${OSTYPE:-}" in msys* | cygwin* | win32) - if [[ "$p" =~ ^/([a-zA-Z])/ || "$p" =~ ^([a-zA-Z]):/ ]]; then - local rest="${p:2}" - printf '%s' "${BASH_REMATCH[1]^}:${rest,,}" - return + if [[ "$__hu_p" =~ ^/([a-zA-Z])/ || "$__hu_p" =~ ^([a-zA-Z]):/ ]]; then + local __hu_rest="${__hu_p:2}" + printf -v "$1" '%s' "${BASH_REMATCH[1]^}:${__hu_rest,,}" + return 0 fi ;; *) ;; # POSIX hosts: case-sensitive FS, no drive fold — pass through below esac - printf '%s' "$p" + printf -v "$1" '%s' "$__hu_p" +} + +hook::normalize_path() { + local __hu_n + hook::normalize_path_to __hu_n "$1" + printf '%s' "$__hu_n" } # Expand Windows 8.3 short-name components (KYLESE~1 → KyleSexton) on @@ -424,25 +439,31 @@ hook::normalize_path() { # class: cygpath ships with Git Bash (the documented Windows bash), so its # absence or failure keeps the resolver's answer unchanged — degrading to the # pre-expansion comparison, same doctrine as the resolver fallback below. -hook::expand_8dot3() { - local p="$1" +hook::expand_8dot3_to() { + local __hu_p="$2" case "${OSTYPE:-}" in msys* | cygwin* | win32) ;; *) - printf '%s' "$p" - return + printf -v "$1" '%s' "$__hu_p" + return 0 ;; esac - if [[ "$p" == *~* ]] && command -v cygpath >/dev/null 2>&1; then - local plain long - if plain=$(cygpath -m -- "$p" 2>/dev/null) && - long=$(cygpath -l -m -- "$p" 2>/dev/null) && - [[ -n "$long" && "$long" != "$plain" ]]; then - printf '%s' "$long" - return + if [[ "$__hu_p" == *~* ]] && command -v cygpath >/dev/null 2>&1; then + local __hu_plain __hu_long + if __hu_plain=$(cygpath -m -- "$__hu_p" 2>/dev/null) && + __hu_long=$(cygpath -l -m -- "$__hu_p" 2>/dev/null) && + [[ -n "$__hu_long" && "$__hu_long" != "$__hu_plain" ]]; then + printf -v "$1" '%s' "$__hu_long" + return 0 fi fi - printf '%s' "$p" + printf -v "$1" '%s' "$__hu_p" +} + +hook::expand_8dot3() { + local __hu_e + hook::expand_8dot3_to __hu_e "$1" + printf '%s' "$__hu_e" } # Canonicalize to a physical path — symlinks resolved, Windows 8.3 short names @@ -462,20 +483,148 @@ hook::expand_8dot3() { # expansion applies only on the resolver's success path: consumers that fail # closed on an unresolved signature must not see a form-converted path instead. # shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED -hook::physical_path() { - local resolved +hook::physical_path_to() { + local __hu_r HOOK_PHYSICAL_PATH_UNRESOLVED=0 - if resolved=$(realpath -- "$1" 2>/dev/null) || resolved=$(readlink -f -- "$1" 2>/dev/null); then - if [[ -n "$resolved" ]]; then - hook::expand_8dot3 "$resolved" + if __hu_r=$(realpath -- "$2" 2>/dev/null) || __hu_r=$(readlink -f -- "$2" 2>/dev/null); then + if [[ -n "$__hu_r" ]]; then + hook::expand_8dot3_to "$1" "$__hu_r" return 0 fi fi HOOK_PHYSICAL_PATH_UNRESOLVED=1 - printf '%s' "$1" + printf -v "$1" '%s' "$2" + return 1 +} + +# shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED +hook::physical_path() { + local __hu_v __hu_rc=0 + hook::physical_path_to __hu_v "$1" || __hu_rc=$? + printf '%s' "$__hu_v" + return "$__hu_rc" +} + +# --- Per-process physical-path cache -------------------------------------- +# The membership guard resolves the same few directories on every call: the +# project root and this host's temp roots. Their physical form does not change +# within one hook process, so each spelling is resolved once and remembered in +# three parallel indexed arrays (keys, values, resolver status). Plain arrays, +# not an associative array, so the cache runs on Bash 3.2. Only directories are +# meant to live here for a process lifetime; hook::read_file_path forgets the +# edited file's entry as soon as it has read it, so a later call in the same +# process sees the file as it is then. +_HOOK_PHYS_KEYS=() +_HOOK_PHYS_VALS=() +_HOOK_PHYS_RCS=() +_HOOK_PHYS_I=-1 + +# hook::_phys_cache_index : sets _HOOK_PHYS_I to the slot holding , +# returns 1 when it is not cached. A forgotten slot has an empty key, which no +# real path can match. +hook::_phys_cache_index() { + local __hu_i + [[ -n "$1" ]] || return 1 + for ((__hu_i = 0; __hu_i < ${#_HOOK_PHYS_KEYS[@]}; __hu_i++)); do + if [[ "${_HOOK_PHYS_KEYS[__hu_i]}" == "$1" ]]; then + _HOOK_PHYS_I=$__hu_i + return 0 + fi + done return 1 } +hook::_phys_cache_forget() { + hook::_phys_cache_index "$1" && _HOOK_PHYS_KEYS[_HOOK_PHYS_I]="" + return 0 +} + +# hook::_physical_cached_to : hook::physical_path_to through the +# cache. Same value, same return status, same HOOK_PHYSICAL_PATH_UNRESOLVED as +# the uncached call; a miss resolves the one path and stores it. +# shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED +hook::_physical_cached_to() { + if hook::_phys_cache_index "$2"; then + printf -v "$1" '%s' "${_HOOK_PHYS_VALS[_HOOK_PHYS_I]}" + HOOK_PHYSICAL_PATH_UNRESOLVED=${_HOOK_PHYS_RCS[_HOOK_PHYS_I]} + return "${_HOOK_PHYS_RCS[_HOOK_PHYS_I]}" + fi + local __hu_v __hu_rc=0 + hook::physical_path_to __hu_v "$2" || __hu_rc=$? + if [[ -n "$2" ]]; then + _HOOK_PHYS_KEYS+=("$2") + _HOOK_PHYS_VALS+=("$__hu_v") + _HOOK_PHYS_RCS+=("$__hu_rc") + fi + printf -v "$1" '%s' "$__hu_v" + return "$__hu_rc" +} + +# hook::_physical_prime ...: resolve every uncached path with ONE +# realpath process and store the answers. realpath resolves each argument on +# its own and prints one line per argument in argument order, so the batch +# answer for a path is the same string the per-path call returns; the 8.3 +# expansion is applied per answer exactly as hook::physical_path_to does. Any +# doubt (a path carrying a newline, a non-zero exit, a line count that does not +# match) stores nothing and leaves the per-path resolver to answer lazily, so +# the batch can only ever save work, never change an answer. readlink -f hosts +# (no realpath) skip the batch for the same reason. +hook::_physical_prime() { + local -a __hu_todo=() + local __hu_p __hu_out __hu_i __hu_seen="" __hu_v + for __hu_p in "$@"; do + [[ -n "$__hu_p" ]] || continue + [[ "$__hu_p" == *$'\n'* ]] && return 0 + case "$__hu_seen" in + *"|$__hu_p|"*) continue ;; + *) ;; # first sighting + esac + __hu_seen="$__hu_seen|$__hu_p|" + hook::_phys_cache_index "$__hu_p" && continue + __hu_todo+=("$__hu_p") + done + ((${#__hu_todo[@]} > 1)) || return 0 + command -v realpath >/dev/null 2>&1 || return 0 + # portability-ok: realpath with several operands is GNU and BSD alike; a host whose realpath rejects it fails the exit-status check and falls back per path + __hu_out=$(realpath -- "${__hu_todo[@]}" 2>/dev/null) || return 0 + local -a __hu_lines=() + local __hu_glob=0 + [[ $- == *f* ]] || __hu_glob=1 + set -f + local IFS=$'\n' + # shellcheck disable=SC2206 # splitting realpath's one-line-per-operand output is the intent + __hu_lines=($__hu_out) + ((__hu_glob)) && set +f + ((${#__hu_lines[@]} == ${#__hu_todo[@]})) || return 0 + for ((__hu_i = 0; __hu_i < ${#__hu_todo[@]}; __hu_i++)); do + [[ -n "${__hu_lines[__hu_i]}" ]] || return 0 + hook::expand_8dot3_to __hu_v "${__hu_lines[__hu_i]}" + _HOOK_PHYS_KEYS+=("${__hu_todo[__hu_i]}") + _HOOK_PHYS_VALS+=("$__hu_v") + _HOOK_PHYS_RCS+=(0) + done + return 0 +} + +# The temp-root candidates hook::under_temp_root compares against: the +# environment's own answer (TMPDIR/TMP/TEMP) plus the POSIX defaults, never a +# hardcoded platform assumption. Existing directories only, each spelling +# once, in _HOOK_TEMP_CANDS. +_HOOK_TEMP_CANDS=() +hook::_temp_root_candidates() { + local __hu_cand __hu_seen="" + _HOOK_TEMP_CANDS=() + for __hu_cand in "${TMPDIR:-}" "${TMP:-}" "${TEMP:-}" /tmp /var/tmp; do + [[ -n "$__hu_cand" && -d "$__hu_cand" ]] || continue + case "$__hu_seen" in + *"|$__hu_cand|"*) continue ;; + *) ;; # first sighting of this candidate + esac + __hu_seen="$__hu_seen|$__hu_cand|" + _HOOK_TEMP_CANDS+=("$__hu_cand") + done +} + # True when sits inside one of this host's temp trees. # Both arguments and candidates go through the same canonicalize+normalize # pipeline as the membership comparison, because the same directory has several @@ -483,21 +632,40 @@ hook::physical_path() { # of the identical directory, and `realpath` resolves the Windows form to a # drive path while leaving `/tmp` as `/tmp`. Neither form alone matches a # `file_path` that could arrive in either, so every candidate is compared and a -# match on any one is a match. Duplicates are resolved once. +# match on any one is a match. Duplicates are resolved once, and each +# candidate's physical form is remembered for the process (see the cache +# above), so a second call costs no resolver process. # -# Candidates are the environment's own answer (TMPDIR/TMP/TEMP) plus the POSIX -# defaults, never a hardcoded platform assumption. +# Raw-spelling shortcut: when the caller vouches that is a +# PHYSICAL path (_HOOK_UTR_TARGET_PHYSICAL=1, set only by hook::read_file_path +# after the resolver succeeded), a candidate whose raw normalized spelling is +# already a prefix of the target is a match without resolving it. A physical +# path contains no symlink component, so a candidate that spells one of its +# prefixes names a chain of real directories whose physical form is that same +# prefix; the resolver would answer the same. A candidate that does not match +# raw is still resolved, so a symlinked temp root (macOS /tmp) is found the +# way it always was. Without the vouch the shortcut is off and every candidate +# is resolved, exactly as before. Note that hook::read_file_path primes every +# candidate into the cache with one batched realpath before calling here, so +# on that path the shortcut skips a lookup, not a process; the batch is what +# saves the processes. A direct caller without the batch saves the resolver. # hook::under_temp_root "$norm_path" && ... +_HOOK_UTR_TARGET_PHYSICAL=0 hook::under_temp_root() { - local target="$1" cand norm seen="" - for cand in "${TMPDIR:-}" "${TMP:-}" "${TEMP:-}" /tmp /var/tmp; do - [[ -n "$cand" && -d "$cand" ]] || continue - case "$seen" in - *"|$cand|"*) continue ;; - *) ;; # first sighting of this candidate — resolve it below - esac - seen="$seen|$cand|" - norm=$(hook::normalize_path "$(hook::physical_path "$cand")") + local target="$1" cand norm phys + hook::_temp_root_candidates + for cand in ${_HOOK_TEMP_CANDS[@]+"${_HOOK_TEMP_CANDS[@]}"}; do + if ((_HOOK_UTR_TARGET_PHYSICAL)); then + hook::normalize_path_to norm "$cand" + if [[ "$norm" != / ]]; then + norm="${norm%/}" + if [[ -n "$norm" && ("$target" == "$norm" || "$target" == "$norm"/*) ]]; then + return 0 + fi + fi + fi + hook::_physical_cached_to phys "$cand" || : + hook::normalize_path_to norm "$phys" # The filesystem root as a temp candidate contains every absolute path; # trimming its only slash would empty the candidate and discard it. [[ "$norm" == / ]] && return 0 @@ -523,21 +691,421 @@ hook::in_git_working_tree() { ) >/dev/null 2>&1 } +# --- Builtin JSON helpers ---------------------------------------------------- +# hook::read_file_path and hook::emit_telemetry used to spend a jq process each +# on every hook run. On Windows Git Bash one spawn costs tens of milliseconds, +# so the helpers here handle the well-formed shapes those two functions see in +# practice with shell builtins and hand anything they cannot PROVE to the +# unchanged jq path. The contract is "the bytes jq would produce, or fall +# back", never "close enough". +# +# What the builtin paths rely on: the text is valid JSON. Every hook payload +# reaching hook::read_file_path has passed hook::buffer_stdin's jq validation, +# and every telemetry data object is built with jq. The helpers still verify +# the structure they walk (terminated strings, well-formed tokens, properly +# nested brackets, one root, no raw control bytes inside strings), but they +# are not a full JSON parser and do not claim to reject every malformed text. +# +# Why no scanning: on this repo's Windows hosts bash regex matching and the +# `%%`/`##` pattern operators cost about a microsecond per character, so a +# 60 KB Write payload would cost more than the jq process they replace. The +# only string operations used on the whole payload are literal-substring +# replacement, `[[ == *x* ]]` containment, IFS word splitting and offset +# slicing, all of which run at C speed. + +# hook::_json_split +# Split at every unescaped double quote into the global array +# _HOOK_JSON_PARTS: even indices hold the structural text between strings, odd +# indices hold string bodies. Before splitting, every `\\` and `\"` pair is +# replaced by `@@` (same length), so a quote that survives is a real string +# delimiter and a body's ORIGINAL bytes are ${text:offset:length}. Returns 1 +# when the text is too large, its quotes do not pair up, or it holds more +# strings than the callers' loops are meant to walk. +_HOOK_JSON_PARTS=() +hook::_json_split() { + local __hu_s="$1" __hu_t __hu_q __hu_n + ((${#__hu_s} <= 65536)) || return 1 + __hu_t=${__hu_s//"\\\\"/@@} + __hu_t=${__hu_t//"\\\""/@@} + __hu_q=${__hu_t//\"/} + __hu_n=$((${#__hu_t} - ${#__hu_q})) + ((__hu_n % 2 == 0)) || return 1 + ((__hu_n <= 800)) || return 1 + local __hu_glob=0 + [[ $- == *f* ]] || __hu_glob=1 + set -f + local IFS='"' + # shellcheck disable=SC2206 # splitting on IFS='"' is the whole point + _HOOK_JSON_PARTS=($__hu_t) + ((__hu_glob)) && set +f + ((${#_HOOK_JSON_PARTS[@]} % 2 == 1)) || return 1 + return 0 +} + +# hook::_json_skeleton +# Split (hook::_json_split), record each string body's byte offset in +# _HOOK_JSON_OFF, and build _HOOK_JSON_SK: the structural text with every +# string replaced by a numbered placeholder `"#"` and all JSON +# whitespace removed. The skeleton is small (no string bodies), so regex and +# per-token work on it is cheap. Returns 1 unless the text is exactly one JSON +# value by the JSON grammar (well-formed tokens, whitespace only between them, +# objects as key:value lists, arrays as value lists, brackets nested and +# closed once) whose strings carry no raw control byte, which jq rejects. +_HOOK_JSON_SK="" +_HOOK_JSON_OFF=() +hook::_json_skeleton() { + local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top + hook::_json_split "$__hu_s" || return 1 + __hu_n=${#_HOOK_JSON_PARTS[@]} + _HOOK_JSON_OFF=() + for ((__hu_i = 0; __hu_i < __hu_n; __hu_i++)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + if ((__hu_i % 2 == 0)); then + __hu_sk+=$__hu_part + else + _HOOK_JSON_OFF[__hu_i]=$__hu_off + [[ "$__hu_part" == *[[:cntrl:]]* ]] && return 1 + __hu_sk+="\"#$__hu_i\"" + fi + __hu_off=$((__hu_off + ${#__hu_part} + 1)) + done + # Tokenize the skeleton and run it through the JSON grammar. Each token is + # consumed by one anchored regex on the (small) remainder; whitespace is + # allowed only between tokens, so `tr ue` is two bad tokens, not `true`. + local __hu_re=$'^[ \t\n\r]*(\\{|\\}|\\[|\\]|,|:|"#[0-9]+"|-?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][-+]?[0-9]+)?|true|false|null)' + local __hu_ws=$'^[ \t\n\r]*$' + __hu_rest=$__hu_sk + while [[ -n "$__hu_rest" ]] && ! [[ "$__hu_rest" =~ $__hu_ws ]]; do + [[ "$__hu_rest" =~ $__hu_re ]] || return 1 + __hu_tok=${BASH_REMATCH[1]} + __hu_rest=${__hu_rest:${#BASH_REMATCH[0]}} + __hu_top=${__hu_stack:${#__hu_stack}-1:1} + case "$__hu_expect" in + value | value_or_end) + case "$__hu_tok" in + '{') + __hu_stack+='{' + __hu_expect=key_or_end + continue + ;; + '[') + __hu_stack+='[' + __hu_expect=value_or_end + continue + ;; + ']') + [[ "$__hu_expect" == value_or_end ]] || return 1 + ;; + '}' | ',' | ':') return 1 ;; + *) + # A scalar. Inside a container the next token is , or the close; + # at the top level nothing may follow. + if [[ -n "$__hu_stack" ]]; then __hu_expect=comma_or_end; else __hu_expect=end; fi + continue + ;; + esac + ;; + key_or_end | key) + case "$__hu_tok" in + \"#*) + __hu_expect='colon' + continue + ;; + '}') [[ "$__hu_expect" == key_or_end ]] || return 1 ;; + *) return 1 ;; + esac + ;; + colon) + [[ "$__hu_tok" == ':' ]] || return 1 + __hu_expect=value + continue + ;; + comma_or_end) + case "$__hu_tok" in + ',') + if [[ "$__hu_top" == '{' ]]; then __hu_expect=key; else __hu_expect=value; fi + continue + ;; + '}') [[ "$__hu_top" == '{' ]] || return 1 ;; + ']') [[ "$__hu_top" == '[' ]] || return 1 ;; + *) return 1 ;; + esac + ;; + *) return 1 ;; # `end`: a token after the root value closed + esac + # Reaching here means a container just closed. + [[ -n "$__hu_stack" ]] || return 1 + __hu_stack=${__hu_stack:0:${#__hu_stack}-1} + if [[ -n "$__hu_stack" ]]; then __hu_expect=comma_or_end; else __hu_expect=end; fi + done + [[ "$__hu_expect" == end ]] || return 1 + _HOOK_JSON_SK=${__hu_sk//[$' \t\n\r']/} + return 0 +} + +# hook::json_unescape_to +# Decode a JSON string body (the bytes between the quotes) into . Handles +# \" \\ \/ \b \f \n \r \t and \uXXXX for U+0001 to U+007F. Returns 1 on any +# other escape (a \u outside that range, U+0000 which bash cannot hold, or an +# invalid escape), leaving the caller to fall back to jq. +hook::json_unescape_to() { + local __hu_raw="$2" __hu_out="" __hu_pre __hu_c __hu_hex __hu_oct __hu_ch + while [[ "$__hu_raw" == *\\* ]]; do + __hu_pre=${__hu_raw%%\\*} + __hu_out+=$__hu_pre + __hu_raw=${__hu_raw:${#__hu_pre}+1} + __hu_c=${__hu_raw:0:1} + __hu_raw=${__hu_raw:1} + case "$__hu_c" in + '"') __hu_out+='"' ;; + "\\") __hu_out+="\\" ;; + '/') __hu_out+='/' ;; + b) __hu_out+=$'\b' ;; # portability-ok: bash ANSI-C backspace byte, not a GNU grep word boundary + f) __hu_out+=$'\f' ;; + n) __hu_out+=$'\n' ;; + r) __hu_out+=$'\r' ;; + t) __hu_out+=$'\t' ;; + u) + __hu_hex=${__hu_raw:0:4} + [[ "$__hu_hex" =~ ^00[0-7][0-9a-fA-F]$ ]] || return 1 + [[ "$__hu_hex" == 0000 ]] && return 1 + __hu_raw=${__hu_raw:4} + printf -v __hu_oct '%03o' "$((16#$__hu_hex))" + printf -v __hu_ch '%b' "\\0$__hu_oct" + __hu_out+=$__hu_ch + ;; + *) return 1 ;; + esac + done + __hu_out+=$__hu_raw + printf -v "$1" '%s' "$__hu_out" +} + +# hook::_fast_file_path_to +# The builtin answer to `jq -r '(.tool_input.file_path // empty) | +# gsub("\r";"")'` followed by the `$(...)` capture. Returns +# 0 proven: holds exactly what jq would have printed +# 1 proven absent: jq would have printed nothing (the guard skips) +# 2 not proven: run jq +# Proof, on top of hook::_json_skeleton's structural checks: the root is an +# object; exactly one string in the whole payload decodes to `tool_input` and +# exactly one to `file_path` (so neither a duplicate key nor a same-named key +# in another object can change jq's answer); `tool_input` is a key of the root +# (depth 1, key position) whose value is a flat object (no nested object or +# array, else jq); and inside it `file_path` is a key with a plain string +# value. A payload where `file_path` names a value or sits in another object +# has no `.tool_input.file_path`, which jq reports as nothing. Key strings are +# compared AFTER decoding their escapes, so a key spelled with \u escapes is +# still recognized; a body longer than any escaped spelling of either name is +# skipped without decoding. +hook::_fast_file_path_to() { + local __hu_s="$2" __hu_i __hu_n __hu_part __hu_body __hu_ti=-1 __hu_fp=-1 __hu_m __hu_raw __hu_pre __hu_re __hu_file + hook::_json_skeleton "$__hu_s" || return 2 + [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 + __hu_n=${#_HOOK_JSON_PARTS[@]} + for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + ((${#__hu_part} <= 60)) || continue + __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} + if [[ "$__hu_body" == *\\* ]]; then + hook::json_unescape_to __hu_body "$__hu_body" || continue + fi + case "$__hu_body" in + tool_input) + ((__hu_ti < 0)) || return 2 + __hu_ti=$__hu_i + ;; + file_path) + ((__hu_fp < 0)) || return 2 + __hu_fp=$__hu_i + ;; + *) ;; + esac + done + ((__hu_fp >= 0)) || return 1 + ((__hu_ti >= 0)) || return 1 + __hu_re="(^|[{,])\"#$__hu_ti\":\\{([^][{}]*)\\}" + if [[ "$_HOOK_JSON_SK" =~ $__hu_re ]]; then + __hu_body=${BASH_REMATCH[2]} + __hu_pre=${_HOOK_JSON_SK%%\"#"$__hu_ti"\"*} + # Depth of the key: opening brackets minus closing ones before it must be + # exactly one, i.e. a direct member of the root object. + local __hu_o1=${__hu_pre//\{/} __hu_o2=${__hu_pre//\[/} __hu_c1=${__hu_pre//\}/} __hu_c2=${__hu_pre//\]/} + local __hu_depth=$(((${#__hu_pre} - ${#__hu_o1}) + (${#__hu_pre} - ${#__hu_o2}) - (${#__hu_pre} - ${#__hu_c1}) - (${#__hu_pre} - ${#__hu_c2}))) + ((__hu_depth == 1)) || return 2 + else + return 2 + fi + __hu_re="(^|,)\"#$__hu_fp\":\"#([0-9]+)\"(,|$)" + if [[ "$__hu_body" =~ $__hu_re ]]; then + __hu_m=${BASH_REMATCH[2]} + elif [[ "$__hu_body" == *\"#$__hu_fp\"* ]]; then + return 2 + else + return 1 + fi + __hu_raw=${__hu_s:${_HOOK_JSON_OFF[__hu_m]}:${#_HOOK_JSON_PARTS[__hu_m]}} + if [[ "$__hu_raw" == *\\* ]]; then + hook::json_unescape_to __hu_file "$__hu_raw" || return 2 + else + __hu_file=$__hu_raw + fi + __hu_file=${__hu_file//$'\r'/} + while [[ "$__hu_file" == *$'\n' ]]; do + __hu_file=${__hu_file%$'\n'} + done + printf -v "$1" '%s' "$__hu_file" + return 0 +} + +# hook::dirname_to : dirname with builtins for the resolver's +# answer. Strips the last segment; a bare name lives in `.`, a root-level file +# in `/`. The path comes from realpath, so the trailing-slash and doubled-slash +# spellings dirname also collapses never occur here. +hook::dirname_to() { + local __hu_d="${2%/*}" + [[ "$__hu_d" == "$2" ]] && __hu_d=. + [[ -n "$__hu_d" ]] || __hu_d=/ + printf -v "$1" '%s' "$__hu_d" +} + +# Print the arguments joined by NUL bytes, no trailing NUL: the byte stream +# hook::read_file_path read from stdin, rebuilt for the jq fallback. +hook::_print_nul_joined() { + local __hu_first=1 __hu_c + for __hu_c in "$@"; do + ((__hu_first)) || printf '\0' + __hu_first=0 + printf '%s' "$__hu_c" + done +} + +# hook::json_compact_to +# The bytes `jq -c .` prints for , when that can be proven with +# builtins: JSON whitespace between tokens removed, everything else verbatim. +# jq's own output, compact or pretty, satisfies the proof, which is what every +# telemetry data builder in this marketplace produces (jq -c, jq -n, or a +# compact literal). Returns 1 when the text is not a single object, or a +# string carries something jq would re-encode (a \u or \/ escape, an escape +# outside the JSON set, a raw control byte), or the structure fails +# hook::_json_skeleton; the caller then runs jq. +hook::json_compact_to() { + local __hu_s="$2" __hu_out="" __hu_i __hu_part __hu_n + hook::_json_skeleton "$__hu_s" || return 1 + [[ "$_HOOK_JSON_SK" == \{*\} ]] || return 1 + # Numbers: only short integer literals are passed through. A fraction, an + # exponent or an integer beyond 15 digits is rendered differently by + # different jq releases (1.6 canonicalizes, 1.7+ preserves the literal), so + # the bytes cannot be proven; jq decides. In the skeleton every string is a + # `"#N"` placeholder and letters occur only in true/false/null, so a digit + # followed by `.`, `e` or `E` can only be part of a number. + local __hu_num='[0-9][.eE]|[0-9]{16}' + [[ "$_HOOK_JSON_SK" =~ $__hu_num ]] && return 1 + __hu_n=${#_HOOK_JSON_PARTS[@]} + for ((__hu_i = 0; __hu_i < __hu_n; __hu_i++)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + if ((__hu_i % 2 == 0)); then + __hu_out+=${__hu_part//[$' \t\n\r']/} + else + if [[ "$__hu_part" == *\\* ]]; then + [[ "$__hu_part" == *'\u'* || "$__hu_part" == *'\/'* ]] && return 1 + [[ "$__hu_part" == *\\[!bfnrt]* ]] && return 1 + fi + __hu_out+="\"${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}}\"" + fi + done + printf -v "$1" '%s' "$__hu_out" +} + +# hook::json_escape_jq_to / hook::json_escape_jq +# Escape exactly as jq serializes a string value (without the +# quotes): backslash and double quote, the five short forms \b \f \n \r \t, +# every other byte from 0x01 to 0x1f and 0x7f as lowercase \u00xx, and +# everything else, including non-ASCII, verbatim. This is the escaper the +# telemetry envelope needs for byte parity with the jq envelope it replaced; +# hook::json_escape above is a different tool (it DROPS the residual control +# bytes, which is right for a notice and wrong for parity) and is unchanged. +hook::json_escape_jq_to() { + local __hu_s="$2" __hu_i __hu_c __hu_oct __hu_hex + __hu_s="${__hu_s//\\/\\\\}" + __hu_s="${__hu_s//\"/\\\"}" + if [[ "$__hu_s" == *[[:cntrl:]]* ]]; then + __hu_s="${__hu_s//$'\n'/\\n}" + __hu_s="${__hu_s//$'\r'/\\r}" + __hu_s="${__hu_s//$'\t'/\\t}" + __hu_s="${__hu_s//$'\b'/\\b}" # portability-ok: bash ANSI-C backspace byte and the JSON \b escape, not a GNU grep word boundary + __hu_s="${__hu_s//$'\f'/\\f}" + for __hu_i in 1 2 3 4 5 6 7 11 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 127; do + printf -v __hu_oct '%03o' "$__hu_i" + printf -v __hu_c '%b' "\\0$__hu_oct" + [[ "$__hu_s" == *"$__hu_c"* ]] || continue + printf -v __hu_hex '%04x' "$__hu_i" + __hu_s="${__hu_s//"$__hu_c"/\\u$__hu_hex}" + done + fi + printf -v "$1" '%s' "$__hu_s" +} + +hook::json_escape_jq() { + local __hu_e + hook::json_escape_jq_to __hu_e "$1" + printf '%s' "$__hu_e" +} + # Parse file_path from PostToolUse JSON on stdin; validate existence and (when # CLAUDE_PROJECT_DIR is set) project membership. Both sides of the membership # comparison are canonicalized (symlinks resolved) first, so neither an # escaping symlink nor a project root reached via a symlinked path (e.g. # macOS /tmp) skews the verdict. Outputs the path on success. Returns 1 to skip. # FILE=$(hook::read_file_path) || exit 0 +# +# jq-free on the common shape. The payload is read into the shell and +# hook::_fast_file_path_to takes `.tool_input.file_path` with builtins when it +# can PROVE the answer jq would give (see that helper for what "prove" means); +# every other payload goes to the unchanged jq filter. The path comparison then +# resolves the file, the project root and the temp roots with one realpath +# process instead of one each (hook::_physical_prime), and remembers the +# directories for the process. Same verdict, same emitted path, fewer +# processes: on Windows Git Bash each spawn costs tens of milliseconds and this +# guard runs on every Write and Edit. hook::read_file_path() { - local file - file=$(jq -r '(.tool_input.file_path // empty) | gsub("\r";"")' 2>/dev/null) + local -a chunks=() + local chunk file="" mode=2 + # Builtin read to NUL or EOF. A NUL splits the payload into several chunks; + # the fast path only takes a single-chunk (NUL-free) payload, and the jq + # fallback is fed the chunks NUL-joined, so it sees the bytes jq used to + # read straight from stdin. + while :; do + chunk="" + if IFS= read -r -d '' chunk; then + chunks+=("$chunk") + continue + fi + chunks+=("$chunk") + break + done + if ((${#chunks[@]} == 1)); then + mode=0 + hook::_fast_file_path_to file "${chunks[0]}" || mode=$? + fi + case "$mode" in + 0) ;; # proven: `file` holds jq's answer + 1) return 1 ;; # proven absent + *) + file=$(hook::_print_nul_joined "${chunks[@]}" | jq -r '(.tool_input.file_path // empty) | gsub("\r";"")' 2>/dev/null) + ;; + esac [[ -n "$file" ]] || return 1 [[ -f "$file" ]] || return 1 if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then - local norm_file norm_project - norm_file=$(hook::normalize_path "$(hook::physical_path "$file")") - norm_project=$(hook::normalize_path "$(hook::physical_path "${CLAUDE_PROJECT_DIR}")") + local norm_file norm_project phys_file phys_project file_resolved=0 project_resolved=0 + hook::_temp_root_candidates + hook::_physical_prime "$file" "${CLAUDE_PROJECT_DIR}" ${_HOOK_TEMP_CANDS[@]+"${_HOOK_TEMP_CANDS[@]}"} + hook::_physical_cached_to phys_file "$file" && file_resolved=1 + hook::_phys_cache_forget "$file" + hook::_physical_cached_to phys_project "${CLAUDE_PROJECT_DIR}" && project_resolved=1 + hook::normalize_path_to norm_file "$phys_file" + hook::normalize_path_to norm_project "$phys_project" norm_project="${norm_project%/}" # Anchor on a path-segment boundary: accept the project root itself or a # child under it, but not a sibling whose name merely shares the prefix @@ -558,19 +1126,28 @@ hook::read_file_path() { # built by `mktemp -d`, which is how this repo's own hook suites run), so # the branch must not fire. Only a temp-tree file reached from a project # root OUTSIDE the temp tree is scratch. - if hook::under_temp_root "$norm_file" && ! hook::under_temp_root "$norm_project"; then - return 1 + local file_in_temp=0 + _HOOK_UTR_TARGET_PHYSICAL=$file_resolved + hook::under_temp_root "$norm_file" && file_in_temp=1 + _HOOK_UTR_TARGET_PHYSICAL=0 + if ((file_in_temp)); then + local project_in_temp=0 + _HOOK_UTR_TARGET_PHYSICAL=$project_resolved + hook::under_temp_root "$norm_project" && project_in_temp=1 + _HOOK_UTR_TARGET_PHYSICAL=0 + ((project_in_temp)) || return 1 fi elif command -v git >/dev/null 2>&1; then # When CLAUDE_PROJECT_DIR is unset, scope to git-working-tree membership so # scratch files outside any repository are not mutated by formatter hooks # (#1091 / #972). - local file_physical - file_physical=$(hook::physical_path "$file") + local file_physical file_dir + hook::physical_path_to file_physical "$file" || : if [[ -L "$file" && "$file_physical" == "$file" ]]; then return 1 fi - if ! hook::in_git_working_tree "$(dirname "$file_physical")"; then + hook::dirname_to file_dir "$file_physical" + if ! hook::in_git_working_tree "$file_dir"; then return 1 fi else @@ -1228,7 +1805,19 @@ hook::telemetry_enabled() { # Fire-and-forget: sink is dispatched in the background; the hook never waits # on it and its failure never affects the hook's own exit code or stdout. # Opt-in guard: HOOK_TELEMETRY_SINK unset or empty → return 0 immediately. -# Fail-open: jq absent → return 0 immediately. +# +# The envelope is assembled with shell builtins (hook::json_escape_jq_to for +# the string fields, hook::json_compact_to for the caller's data object) as +# ONE compact line, `jq -c` style: the same document, field order and +# escapes the jq filter below produces, with the data object compacted +# exactly as jq compacts its own output. That path needs no jq, no temp file +# and no subprocess. When the data object cannot be proven jq-idempotent (see +# hook::json_compact_to), the jq path runs, now with -c so both paths write +# the same bytes, and there jq's absence is still fail-open: return 0, no +# envelope. Before this the envelope was jq's default pretty-printed form +# (several lines, and CRLF line endings with a Windows jq); the sink contract +# was always "one JSON document on stdin", and a compact line is also valid +# JSONL for a sink that appends raw envelopes. # # Usage: # hook::emit_telemetry [repo_root] @@ -1251,7 +1840,6 @@ hook::telemetry_enabled() { # NEVER writes to fd1 (the hook's stdout / additionalContext channel). hook::emit_telemetry() { [[ -n "${HOOK_TELEMETRY_SINK:-}" ]] || return 0 - command -v jq >/dev/null 2>&1 || return 0 local hook_id="$1" local hook_event="$2" @@ -1272,35 +1860,59 @@ hook::emit_telemetry() { local e_s="${now%[.,]*}" e_f="${now#*[.,]}" local duration_ms=$(((e_s * 1000000 + 10#$e_f - s_s * 1000000 - 10#$s_f) / 1000)) - # True UTC timestamp (TZ= prefix overrides LC_ALL / local TZ; the Z is not a lie). - local timestamp - timestamp=$(TZ=UTC printf '%(%Y-%m-%dT%H:%M:%SZ)T' -1) - - # Build the envelope. The `data` object is written to a temp file, not passed - # as --argjson, so payloads larger than the Windows 32767-character command-line - # cap are not dropped before jq runs (#1595). /dev/stdin is not used: jq's - # --slurpfile there is not portable on Windows. Redirect jq stderr to - # /dev/null; output goes to a local variable — never to fd1. - local envelope data_file - data_file=$(mktemp) || return 0 - printf '%s' "$data_json" >"$data_file" || { - rm -f "$data_file" - return 0 - } - envelope=$(jq -n \ - --arg schema_version "1.0" \ - --arg timestamp "$timestamp" \ - --arg hook "$hook_id" \ - --arg hook_event "$hook_event" \ - --arg status "$status" \ - --argjson duration_ms "$duration_ms" \ - --slurpfile data "$data_file" \ - '{schema_version:$schema_version,timestamp:$timestamp,hook:$hook,hook_event:$hook_event,status:$status,duration_ms:$duration_ms,data:($data[0] // {})}' \ - 2>/dev/null) || { + # True UTC timestamp (TZ= prefix overrides LC_ALL / local TZ; the Z is not a + # lie). The %()T printf format is Bash 4.2+, and printf -v keeps it in the + # shell (a `$(...)` capture is a fork); an older bash, or a printf that + # binds nothing, falls back to date -u. + local timestamp="" + TZ=UTC printf -v timestamp '%(%Y-%m-%dT%H:%M:%SZ)T' -1 2>/dev/null || timestamp="" + [[ -n "$timestamp" ]] || timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null) || timestamp="" + + # Trailing whitespace after the object (a newline a caller left on the + # value) does not change what jq parses, so it does not block the builtin + # path either. + local envelope="" data="" data_trim="$data_json" + while [[ "$data_trim" == *[$' \t\r\n'] ]]; do + data_trim="${data_trim%?}" + done + if [[ "$data_trim" == \{*\} ]] && hook::json_compact_to data "$data_trim"; then + # Builtin envelope: the same document the jq filter below produces, field + # for field, with jq's string escaping and jq's compact data. + local e_timestamp e_hook e_event e_status + hook::json_escape_jq_to e_timestamp "$timestamp" + hook::json_escape_jq_to e_hook "$hook_id" + hook::json_escape_jq_to e_event "$hook_event" + hook::json_escape_jq_to e_status "$status" + envelope='{"schema_version":"1.0","timestamp":"'"$e_timestamp"'","hook":"'"$e_hook"'","hook_event":"'"$e_event"'","status":"'"$e_status"'","duration_ms":'"$duration_ms"',"data":'"$data"'}' + else + # jq path, for a data object the builtin compactor cannot prove. Fail-open: + # jq absent → return 0. The `data` object is written to a temp file, not + # passed as --argjson, so payloads larger than the Windows 32767-character + # command-line cap are not dropped before jq runs (#1595). /dev/stdin is + # not used: jq's --slurpfile there is not portable on Windows. Redirect jq + # stderr to /dev/null; output goes to a local variable — never to fd1. + command -v jq >/dev/null 2>&1 || return 0 + local data_file + data_file=$(mktemp) || return 0 + printf '%s' "$data_json" >"$data_file" || { + rm -f "$data_file" + return 0 + } + envelope=$(jq -nc \ + --arg schema_version "1.0" \ + --arg timestamp "$timestamp" \ + --arg hook "$hook_id" \ + --arg hook_event "$hook_event" \ + --arg status "$status" \ + --argjson duration_ms "$duration_ms" \ + --slurpfile data "$data_file" \ + '{schema_version:$schema_version,timestamp:$timestamp,hook:$hook,hook_event:$hook_event,status:$status,duration_ms:$duration_ms,data:($data[0] // {})}' \ + 2>/dev/null) || { + rm -f "$data_file" + return 0 + } rm -f "$data_file" - return 0 - } - rm -f "$data_file" + fi # Resolve the sink path. A relative HOOK_TELEMETRY_SINK is joined onto the # consuming repo root (portable, tracked wiring); absolute is used as-is. A diff --git a/plugins/claude-ops/.claude-plugin/plugin.json b/plugins/claude-ops/.claude-plugin/plugin.json index 76652bad51..34d1a28032 100644 --- a/plugins/claude-ops/.claude-plugin/plugin.json +++ b/plugins/claude-ops/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "claude-ops", - "version": "0.41.8", + "version": "0.41.9", "description": "Claude Code operations toolkit. Twelve skills: audit-skill-visibility (audit whether each installed skill is actually VISIBLE to the model, and diagnose why most of a fleet never gets used \u2014 a skill is invisible when its description is dropped by Claude Code's skill-listing context budget, which sheds descriptions lowest-score-first so an unused skill loses the keywords that would let it be matched, from skills genuinely not wanted, from skills the run cannot observe at all; computes whether the listing overflows from documented settings, and withholds every cold verdict the data cannot support rather than reporting absence of data as absence of use), inventory (read-only enumeration of the complete invocable surface \u2014 every built-in CLI command with aliases and hidden/gated status, every bundled skill, and every component of every installed plugin across all marketplaces; reads the shipped binary because upstream publishes no built-in command list, and carries an integrity verdict so a drifted build reports counts as floors rather than silently short totals), audit-install-state (read-only audit of the machine-scope ~/.claude installation directory and ~/.claude.json \u2014 full inventory split into an authored surface and rolled-up bulk trees, product-managed retention vs genuinely unmanaged state, filename-scheme resolution before any process-liveness check, and deliberate/mid-experiment detection; reports, never deletes), audit-performance (read-only slowness-diagnostic capture run at the moment the machine or a session feels slow: CLI version, retention-sweep health including the silent unparsable-settings pause, a timed census walk of the install tree as a sweep-cost proxy, active-session and plugin-fleet counts, a process census, and the fan-out layer, which covers a load-labelled no-op spawn baseline, every hook that will fire bucketed per-tool-call versus per-turn with its invocation shape, the configured statusline, subagent concurrency and spawn-depth ceilings against documented defaults, whether running sessions predate the settings file they are judged by, and orphan attribution by parent liveness rather than age; read against a bundled known-performance-issues reference that also records the causes tested and cleared; separates the four documented suspects of accumulated state, version regression, component bloat, and per-spawn fan-out cost, and routes remediation out; reports, never mutates, and never executes a discovered hook or statusline command), audit-native-overlap (map native Claude Code surfaces \u2014 built-in CLI commands, bundled skills, plugin-backed built-ins, session-provided skills \u2014 against the current repo's plugin skills and agents, so a custom component never silently duplicates what Claude Code itself ships; bare invocation is a read-only overlap report carrying the extraction's integrity floors and a shared-listing-budget exposure section, verdicts are human-gated in a committed store rendered into a generated registry whose every row carries an observable recheck trigger, and only an explicit apply step bakes presence-gated native references into descriptions and Boundary sections), observability (read locally captured telemetry \u2014 OTEL store, collector, hook-event JSONL, ccusage \u2014 with trend reports and store pruning), known-issues (search known Claude product GitHub bugs, check service health, maintain a persistent tracked-issue registry), changelog (ingest Claude Code changelog entries and integrate them into the current repo), plugins (bring a machine's plugin fleet current on demand \u2014 marketplace refresh, effective-scope updates including in-repo project/local installs, new-plugin install per policy, scope-divergence detection and explicit convergence), morning-brief (read-only gh-based operator morning view \u2014 queue-label counts, merge-ready PRs, parked decisions with their RECOMMENDED lines, and loop-lane telemetry freshness), lanes (start/restart/stop/status loop lanes as named background Claude Code sessions seeded from canonical prompt files, with per-lane model/effort, a repo-pull + marketplace-refresh launch step, and a consume-restarts action \u2014 an OS-schedulable reader that relaunches stopped lanes whose telemetry carries a restart_request), and a re-runnable setup action that settles where the known-issues registry lives. Plus a family of eight advisory *-audit hooks (API errors, config changes, instruction loads, permission denials, pre-compaction, skill usage, tool failures, and unsurfaced hook failures \u2014 the last also warns the user via systemMessage, since a hook that fails to launch enforces nothing and Claude Code surfaces the failure to nobody) that emit the shared hook-telemetry envelope, and a reference sink that maps envelopes into the hook-events.jsonl the observability skill reads.", "author": { "name": "Melodic Software", diff --git a/plugins/claude-ops/CHANGELOG.md b/plugins/claude-ops/CHANGELOG.md index 29f135eece..dfb4f5e41f 100644 --- a/plugins/claude-ops/CHANGELOG.md +++ b/plugins/claude-ops/CHANGELOG.md @@ -3,6 +3,22 @@ All notable changes to the `claude-ops` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.41.9] + +### Changed + +- **Vendored `hook-utils.sh` builds the telemetry envelope and reads `file_path` + with shell builtins.** `hook::emit_telemetry` no longer spawns two jq + processes, a mktemp and an rm per run: the envelope is assembled in the shell + as one compact line (the same document jq produced, now `jq -c` shaped), with + jq kept only as the fallback for a data object the builtin compactor cannot + prove. `hook::read_file_path` takes `.tool_input.file_path` without jq on the + well-formed payload shape and resolves the file, project root and temp roots + with one batched `realpath` instead of one process each. Same verdicts, same + emitted path, same sink record; phase 4b of the hook-performance program + (#3623). The copy is bumped because `scripts/sync-hook-utils.sh` keeps every + carrying plugin byte-identical. + ## [0.41.8] ### Changed diff --git a/plugins/claude-ops/hooks/hook-utils.sh b/plugins/claude-ops/hooks/hook-utils.sh index 9818a6a73d..18e4dd5745 100644 --- a/plugins/claude-ops/hooks/hook-utils.sh +++ b/plugins/claude-ops/hooks/hook-utils.sh @@ -390,19 +390,34 @@ hook::require_jq_blocking() { # collapse with `/c/repo` and the membership guard would admit a sibling # outside CLAUDE_PROJECT_DIR. The result is used ONLY for comparison; the # emitted path is always the caller's original. -hook::normalize_path() { - local p="${1//\\//}" +# +# Two spellings of every path helper below: `hook::` prints the answer +# (the public, subshell-friendly form every caller already uses) and +# `hook::_to ` stores it in the caller's variable instead. +# The `_to` form is what the hot path uses: a `$(...)` capture is a fork, and +# on Windows Git Bash a fork costs milliseconds, so the file_path guard that +# ran a dozen of them per hook now runs none it does not need. The `_to` +# helpers keep their locals under a `__hu_` prefix so the caller's variable +# name cannot collide with them. +hook::normalize_path_to() { + local __hu_p="${2//\\//}" case "${OSTYPE:-}" in msys* | cygwin* | win32) - if [[ "$p" =~ ^/([a-zA-Z])/ || "$p" =~ ^([a-zA-Z]):/ ]]; then - local rest="${p:2}" - printf '%s' "${BASH_REMATCH[1]^}:${rest,,}" - return + if [[ "$__hu_p" =~ ^/([a-zA-Z])/ || "$__hu_p" =~ ^([a-zA-Z]):/ ]]; then + local __hu_rest="${__hu_p:2}" + printf -v "$1" '%s' "${BASH_REMATCH[1]^}:${__hu_rest,,}" + return 0 fi ;; *) ;; # POSIX hosts: case-sensitive FS, no drive fold — pass through below esac - printf '%s' "$p" + printf -v "$1" '%s' "$__hu_p" +} + +hook::normalize_path() { + local __hu_n + hook::normalize_path_to __hu_n "$1" + printf '%s' "$__hu_n" } # Expand Windows 8.3 short-name components (KYLESE~1 → KyleSexton) on @@ -424,25 +439,31 @@ hook::normalize_path() { # class: cygpath ships with Git Bash (the documented Windows bash), so its # absence or failure keeps the resolver's answer unchanged — degrading to the # pre-expansion comparison, same doctrine as the resolver fallback below. -hook::expand_8dot3() { - local p="$1" +hook::expand_8dot3_to() { + local __hu_p="$2" case "${OSTYPE:-}" in msys* | cygwin* | win32) ;; *) - printf '%s' "$p" - return + printf -v "$1" '%s' "$__hu_p" + return 0 ;; esac - if [[ "$p" == *~* ]] && command -v cygpath >/dev/null 2>&1; then - local plain long - if plain=$(cygpath -m -- "$p" 2>/dev/null) && - long=$(cygpath -l -m -- "$p" 2>/dev/null) && - [[ -n "$long" && "$long" != "$plain" ]]; then - printf '%s' "$long" - return + if [[ "$__hu_p" == *~* ]] && command -v cygpath >/dev/null 2>&1; then + local __hu_plain __hu_long + if __hu_plain=$(cygpath -m -- "$__hu_p" 2>/dev/null) && + __hu_long=$(cygpath -l -m -- "$__hu_p" 2>/dev/null) && + [[ -n "$__hu_long" && "$__hu_long" != "$__hu_plain" ]]; then + printf -v "$1" '%s' "$__hu_long" + return 0 fi fi - printf '%s' "$p" + printf -v "$1" '%s' "$__hu_p" +} + +hook::expand_8dot3() { + local __hu_e + hook::expand_8dot3_to __hu_e "$1" + printf '%s' "$__hu_e" } # Canonicalize to a physical path — symlinks resolved, Windows 8.3 short names @@ -462,20 +483,148 @@ hook::expand_8dot3() { # expansion applies only on the resolver's success path: consumers that fail # closed on an unresolved signature must not see a form-converted path instead. # shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED -hook::physical_path() { - local resolved +hook::physical_path_to() { + local __hu_r HOOK_PHYSICAL_PATH_UNRESOLVED=0 - if resolved=$(realpath -- "$1" 2>/dev/null) || resolved=$(readlink -f -- "$1" 2>/dev/null); then - if [[ -n "$resolved" ]]; then - hook::expand_8dot3 "$resolved" + if __hu_r=$(realpath -- "$2" 2>/dev/null) || __hu_r=$(readlink -f -- "$2" 2>/dev/null); then + if [[ -n "$__hu_r" ]]; then + hook::expand_8dot3_to "$1" "$__hu_r" return 0 fi fi HOOK_PHYSICAL_PATH_UNRESOLVED=1 - printf '%s' "$1" + printf -v "$1" '%s' "$2" + return 1 +} + +# shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED +hook::physical_path() { + local __hu_v __hu_rc=0 + hook::physical_path_to __hu_v "$1" || __hu_rc=$? + printf '%s' "$__hu_v" + return "$__hu_rc" +} + +# --- Per-process physical-path cache -------------------------------------- +# The membership guard resolves the same few directories on every call: the +# project root and this host's temp roots. Their physical form does not change +# within one hook process, so each spelling is resolved once and remembered in +# three parallel indexed arrays (keys, values, resolver status). Plain arrays, +# not an associative array, so the cache runs on Bash 3.2. Only directories are +# meant to live here for a process lifetime; hook::read_file_path forgets the +# edited file's entry as soon as it has read it, so a later call in the same +# process sees the file as it is then. +_HOOK_PHYS_KEYS=() +_HOOK_PHYS_VALS=() +_HOOK_PHYS_RCS=() +_HOOK_PHYS_I=-1 + +# hook::_phys_cache_index : sets _HOOK_PHYS_I to the slot holding , +# returns 1 when it is not cached. A forgotten slot has an empty key, which no +# real path can match. +hook::_phys_cache_index() { + local __hu_i + [[ -n "$1" ]] || return 1 + for ((__hu_i = 0; __hu_i < ${#_HOOK_PHYS_KEYS[@]}; __hu_i++)); do + if [[ "${_HOOK_PHYS_KEYS[__hu_i]}" == "$1" ]]; then + _HOOK_PHYS_I=$__hu_i + return 0 + fi + done return 1 } +hook::_phys_cache_forget() { + hook::_phys_cache_index "$1" && _HOOK_PHYS_KEYS[_HOOK_PHYS_I]="" + return 0 +} + +# hook::_physical_cached_to : hook::physical_path_to through the +# cache. Same value, same return status, same HOOK_PHYSICAL_PATH_UNRESOLVED as +# the uncached call; a miss resolves the one path and stores it. +# shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED +hook::_physical_cached_to() { + if hook::_phys_cache_index "$2"; then + printf -v "$1" '%s' "${_HOOK_PHYS_VALS[_HOOK_PHYS_I]}" + HOOK_PHYSICAL_PATH_UNRESOLVED=${_HOOK_PHYS_RCS[_HOOK_PHYS_I]} + return "${_HOOK_PHYS_RCS[_HOOK_PHYS_I]}" + fi + local __hu_v __hu_rc=0 + hook::physical_path_to __hu_v "$2" || __hu_rc=$? + if [[ -n "$2" ]]; then + _HOOK_PHYS_KEYS+=("$2") + _HOOK_PHYS_VALS+=("$__hu_v") + _HOOK_PHYS_RCS+=("$__hu_rc") + fi + printf -v "$1" '%s' "$__hu_v" + return "$__hu_rc" +} + +# hook::_physical_prime ...: resolve every uncached path with ONE +# realpath process and store the answers. realpath resolves each argument on +# its own and prints one line per argument in argument order, so the batch +# answer for a path is the same string the per-path call returns; the 8.3 +# expansion is applied per answer exactly as hook::physical_path_to does. Any +# doubt (a path carrying a newline, a non-zero exit, a line count that does not +# match) stores nothing and leaves the per-path resolver to answer lazily, so +# the batch can only ever save work, never change an answer. readlink -f hosts +# (no realpath) skip the batch for the same reason. +hook::_physical_prime() { + local -a __hu_todo=() + local __hu_p __hu_out __hu_i __hu_seen="" __hu_v + for __hu_p in "$@"; do + [[ -n "$__hu_p" ]] || continue + [[ "$__hu_p" == *$'\n'* ]] && return 0 + case "$__hu_seen" in + *"|$__hu_p|"*) continue ;; + *) ;; # first sighting + esac + __hu_seen="$__hu_seen|$__hu_p|" + hook::_phys_cache_index "$__hu_p" && continue + __hu_todo+=("$__hu_p") + done + ((${#__hu_todo[@]} > 1)) || return 0 + command -v realpath >/dev/null 2>&1 || return 0 + # portability-ok: realpath with several operands is GNU and BSD alike; a host whose realpath rejects it fails the exit-status check and falls back per path + __hu_out=$(realpath -- "${__hu_todo[@]}" 2>/dev/null) || return 0 + local -a __hu_lines=() + local __hu_glob=0 + [[ $- == *f* ]] || __hu_glob=1 + set -f + local IFS=$'\n' + # shellcheck disable=SC2206 # splitting realpath's one-line-per-operand output is the intent + __hu_lines=($__hu_out) + ((__hu_glob)) && set +f + ((${#__hu_lines[@]} == ${#__hu_todo[@]})) || return 0 + for ((__hu_i = 0; __hu_i < ${#__hu_todo[@]}; __hu_i++)); do + [[ -n "${__hu_lines[__hu_i]}" ]] || return 0 + hook::expand_8dot3_to __hu_v "${__hu_lines[__hu_i]}" + _HOOK_PHYS_KEYS+=("${__hu_todo[__hu_i]}") + _HOOK_PHYS_VALS+=("$__hu_v") + _HOOK_PHYS_RCS+=(0) + done + return 0 +} + +# The temp-root candidates hook::under_temp_root compares against: the +# environment's own answer (TMPDIR/TMP/TEMP) plus the POSIX defaults, never a +# hardcoded platform assumption. Existing directories only, each spelling +# once, in _HOOK_TEMP_CANDS. +_HOOK_TEMP_CANDS=() +hook::_temp_root_candidates() { + local __hu_cand __hu_seen="" + _HOOK_TEMP_CANDS=() + for __hu_cand in "${TMPDIR:-}" "${TMP:-}" "${TEMP:-}" /tmp /var/tmp; do + [[ -n "$__hu_cand" && -d "$__hu_cand" ]] || continue + case "$__hu_seen" in + *"|$__hu_cand|"*) continue ;; + *) ;; # first sighting of this candidate + esac + __hu_seen="$__hu_seen|$__hu_cand|" + _HOOK_TEMP_CANDS+=("$__hu_cand") + done +} + # True when sits inside one of this host's temp trees. # Both arguments and candidates go through the same canonicalize+normalize # pipeline as the membership comparison, because the same directory has several @@ -483,21 +632,40 @@ hook::physical_path() { # of the identical directory, and `realpath` resolves the Windows form to a # drive path while leaving `/tmp` as `/tmp`. Neither form alone matches a # `file_path` that could arrive in either, so every candidate is compared and a -# match on any one is a match. Duplicates are resolved once. +# match on any one is a match. Duplicates are resolved once, and each +# candidate's physical form is remembered for the process (see the cache +# above), so a second call costs no resolver process. # -# Candidates are the environment's own answer (TMPDIR/TMP/TEMP) plus the POSIX -# defaults, never a hardcoded platform assumption. +# Raw-spelling shortcut: when the caller vouches that is a +# PHYSICAL path (_HOOK_UTR_TARGET_PHYSICAL=1, set only by hook::read_file_path +# after the resolver succeeded), a candidate whose raw normalized spelling is +# already a prefix of the target is a match without resolving it. A physical +# path contains no symlink component, so a candidate that spells one of its +# prefixes names a chain of real directories whose physical form is that same +# prefix; the resolver would answer the same. A candidate that does not match +# raw is still resolved, so a symlinked temp root (macOS /tmp) is found the +# way it always was. Without the vouch the shortcut is off and every candidate +# is resolved, exactly as before. Note that hook::read_file_path primes every +# candidate into the cache with one batched realpath before calling here, so +# on that path the shortcut skips a lookup, not a process; the batch is what +# saves the processes. A direct caller without the batch saves the resolver. # hook::under_temp_root "$norm_path" && ... +_HOOK_UTR_TARGET_PHYSICAL=0 hook::under_temp_root() { - local target="$1" cand norm seen="" - for cand in "${TMPDIR:-}" "${TMP:-}" "${TEMP:-}" /tmp /var/tmp; do - [[ -n "$cand" && -d "$cand" ]] || continue - case "$seen" in - *"|$cand|"*) continue ;; - *) ;; # first sighting of this candidate — resolve it below - esac - seen="$seen|$cand|" - norm=$(hook::normalize_path "$(hook::physical_path "$cand")") + local target="$1" cand norm phys + hook::_temp_root_candidates + for cand in ${_HOOK_TEMP_CANDS[@]+"${_HOOK_TEMP_CANDS[@]}"}; do + if ((_HOOK_UTR_TARGET_PHYSICAL)); then + hook::normalize_path_to norm "$cand" + if [[ "$norm" != / ]]; then + norm="${norm%/}" + if [[ -n "$norm" && ("$target" == "$norm" || "$target" == "$norm"/*) ]]; then + return 0 + fi + fi + fi + hook::_physical_cached_to phys "$cand" || : + hook::normalize_path_to norm "$phys" # The filesystem root as a temp candidate contains every absolute path; # trimming its only slash would empty the candidate and discard it. [[ "$norm" == / ]] && return 0 @@ -523,21 +691,421 @@ hook::in_git_working_tree() { ) >/dev/null 2>&1 } +# --- Builtin JSON helpers ---------------------------------------------------- +# hook::read_file_path and hook::emit_telemetry used to spend a jq process each +# on every hook run. On Windows Git Bash one spawn costs tens of milliseconds, +# so the helpers here handle the well-formed shapes those two functions see in +# practice with shell builtins and hand anything they cannot PROVE to the +# unchanged jq path. The contract is "the bytes jq would produce, or fall +# back", never "close enough". +# +# What the builtin paths rely on: the text is valid JSON. Every hook payload +# reaching hook::read_file_path has passed hook::buffer_stdin's jq validation, +# and every telemetry data object is built with jq. The helpers still verify +# the structure they walk (terminated strings, well-formed tokens, properly +# nested brackets, one root, no raw control bytes inside strings), but they +# are not a full JSON parser and do not claim to reject every malformed text. +# +# Why no scanning: on this repo's Windows hosts bash regex matching and the +# `%%`/`##` pattern operators cost about a microsecond per character, so a +# 60 KB Write payload would cost more than the jq process they replace. The +# only string operations used on the whole payload are literal-substring +# replacement, `[[ == *x* ]]` containment, IFS word splitting and offset +# slicing, all of which run at C speed. + +# hook::_json_split +# Split at every unescaped double quote into the global array +# _HOOK_JSON_PARTS: even indices hold the structural text between strings, odd +# indices hold string bodies. Before splitting, every `\\` and `\"` pair is +# replaced by `@@` (same length), so a quote that survives is a real string +# delimiter and a body's ORIGINAL bytes are ${text:offset:length}. Returns 1 +# when the text is too large, its quotes do not pair up, or it holds more +# strings than the callers' loops are meant to walk. +_HOOK_JSON_PARTS=() +hook::_json_split() { + local __hu_s="$1" __hu_t __hu_q __hu_n + ((${#__hu_s} <= 65536)) || return 1 + __hu_t=${__hu_s//"\\\\"/@@} + __hu_t=${__hu_t//"\\\""/@@} + __hu_q=${__hu_t//\"/} + __hu_n=$((${#__hu_t} - ${#__hu_q})) + ((__hu_n % 2 == 0)) || return 1 + ((__hu_n <= 800)) || return 1 + local __hu_glob=0 + [[ $- == *f* ]] || __hu_glob=1 + set -f + local IFS='"' + # shellcheck disable=SC2206 # splitting on IFS='"' is the whole point + _HOOK_JSON_PARTS=($__hu_t) + ((__hu_glob)) && set +f + ((${#_HOOK_JSON_PARTS[@]} % 2 == 1)) || return 1 + return 0 +} + +# hook::_json_skeleton +# Split (hook::_json_split), record each string body's byte offset in +# _HOOK_JSON_OFF, and build _HOOK_JSON_SK: the structural text with every +# string replaced by a numbered placeholder `"#"` and all JSON +# whitespace removed. The skeleton is small (no string bodies), so regex and +# per-token work on it is cheap. Returns 1 unless the text is exactly one JSON +# value by the JSON grammar (well-formed tokens, whitespace only between them, +# objects as key:value lists, arrays as value lists, brackets nested and +# closed once) whose strings carry no raw control byte, which jq rejects. +_HOOK_JSON_SK="" +_HOOK_JSON_OFF=() +hook::_json_skeleton() { + local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top + hook::_json_split "$__hu_s" || return 1 + __hu_n=${#_HOOK_JSON_PARTS[@]} + _HOOK_JSON_OFF=() + for ((__hu_i = 0; __hu_i < __hu_n; __hu_i++)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + if ((__hu_i % 2 == 0)); then + __hu_sk+=$__hu_part + else + _HOOK_JSON_OFF[__hu_i]=$__hu_off + [[ "$__hu_part" == *[[:cntrl:]]* ]] && return 1 + __hu_sk+="\"#$__hu_i\"" + fi + __hu_off=$((__hu_off + ${#__hu_part} + 1)) + done + # Tokenize the skeleton and run it through the JSON grammar. Each token is + # consumed by one anchored regex on the (small) remainder; whitespace is + # allowed only between tokens, so `tr ue` is two bad tokens, not `true`. + local __hu_re=$'^[ \t\n\r]*(\\{|\\}|\\[|\\]|,|:|"#[0-9]+"|-?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][-+]?[0-9]+)?|true|false|null)' + local __hu_ws=$'^[ \t\n\r]*$' + __hu_rest=$__hu_sk + while [[ -n "$__hu_rest" ]] && ! [[ "$__hu_rest" =~ $__hu_ws ]]; do + [[ "$__hu_rest" =~ $__hu_re ]] || return 1 + __hu_tok=${BASH_REMATCH[1]} + __hu_rest=${__hu_rest:${#BASH_REMATCH[0]}} + __hu_top=${__hu_stack:${#__hu_stack}-1:1} + case "$__hu_expect" in + value | value_or_end) + case "$__hu_tok" in + '{') + __hu_stack+='{' + __hu_expect=key_or_end + continue + ;; + '[') + __hu_stack+='[' + __hu_expect=value_or_end + continue + ;; + ']') + [[ "$__hu_expect" == value_or_end ]] || return 1 + ;; + '}' | ',' | ':') return 1 ;; + *) + # A scalar. Inside a container the next token is , or the close; + # at the top level nothing may follow. + if [[ -n "$__hu_stack" ]]; then __hu_expect=comma_or_end; else __hu_expect=end; fi + continue + ;; + esac + ;; + key_or_end | key) + case "$__hu_tok" in + \"#*) + __hu_expect='colon' + continue + ;; + '}') [[ "$__hu_expect" == key_or_end ]] || return 1 ;; + *) return 1 ;; + esac + ;; + colon) + [[ "$__hu_tok" == ':' ]] || return 1 + __hu_expect=value + continue + ;; + comma_or_end) + case "$__hu_tok" in + ',') + if [[ "$__hu_top" == '{' ]]; then __hu_expect=key; else __hu_expect=value; fi + continue + ;; + '}') [[ "$__hu_top" == '{' ]] || return 1 ;; + ']') [[ "$__hu_top" == '[' ]] || return 1 ;; + *) return 1 ;; + esac + ;; + *) return 1 ;; # `end`: a token after the root value closed + esac + # Reaching here means a container just closed. + [[ -n "$__hu_stack" ]] || return 1 + __hu_stack=${__hu_stack:0:${#__hu_stack}-1} + if [[ -n "$__hu_stack" ]]; then __hu_expect=comma_or_end; else __hu_expect=end; fi + done + [[ "$__hu_expect" == end ]] || return 1 + _HOOK_JSON_SK=${__hu_sk//[$' \t\n\r']/} + return 0 +} + +# hook::json_unescape_to +# Decode a JSON string body (the bytes between the quotes) into . Handles +# \" \\ \/ \b \f \n \r \t and \uXXXX for U+0001 to U+007F. Returns 1 on any +# other escape (a \u outside that range, U+0000 which bash cannot hold, or an +# invalid escape), leaving the caller to fall back to jq. +hook::json_unescape_to() { + local __hu_raw="$2" __hu_out="" __hu_pre __hu_c __hu_hex __hu_oct __hu_ch + while [[ "$__hu_raw" == *\\* ]]; do + __hu_pre=${__hu_raw%%\\*} + __hu_out+=$__hu_pre + __hu_raw=${__hu_raw:${#__hu_pre}+1} + __hu_c=${__hu_raw:0:1} + __hu_raw=${__hu_raw:1} + case "$__hu_c" in + '"') __hu_out+='"' ;; + "\\") __hu_out+="\\" ;; + '/') __hu_out+='/' ;; + b) __hu_out+=$'\b' ;; # portability-ok: bash ANSI-C backspace byte, not a GNU grep word boundary + f) __hu_out+=$'\f' ;; + n) __hu_out+=$'\n' ;; + r) __hu_out+=$'\r' ;; + t) __hu_out+=$'\t' ;; + u) + __hu_hex=${__hu_raw:0:4} + [[ "$__hu_hex" =~ ^00[0-7][0-9a-fA-F]$ ]] || return 1 + [[ "$__hu_hex" == 0000 ]] && return 1 + __hu_raw=${__hu_raw:4} + printf -v __hu_oct '%03o' "$((16#$__hu_hex))" + printf -v __hu_ch '%b' "\\0$__hu_oct" + __hu_out+=$__hu_ch + ;; + *) return 1 ;; + esac + done + __hu_out+=$__hu_raw + printf -v "$1" '%s' "$__hu_out" +} + +# hook::_fast_file_path_to +# The builtin answer to `jq -r '(.tool_input.file_path // empty) | +# gsub("\r";"")'` followed by the `$(...)` capture. Returns +# 0 proven: holds exactly what jq would have printed +# 1 proven absent: jq would have printed nothing (the guard skips) +# 2 not proven: run jq +# Proof, on top of hook::_json_skeleton's structural checks: the root is an +# object; exactly one string in the whole payload decodes to `tool_input` and +# exactly one to `file_path` (so neither a duplicate key nor a same-named key +# in another object can change jq's answer); `tool_input` is a key of the root +# (depth 1, key position) whose value is a flat object (no nested object or +# array, else jq); and inside it `file_path` is a key with a plain string +# value. A payload where `file_path` names a value or sits in another object +# has no `.tool_input.file_path`, which jq reports as nothing. Key strings are +# compared AFTER decoding their escapes, so a key spelled with \u escapes is +# still recognized; a body longer than any escaped spelling of either name is +# skipped without decoding. +hook::_fast_file_path_to() { + local __hu_s="$2" __hu_i __hu_n __hu_part __hu_body __hu_ti=-1 __hu_fp=-1 __hu_m __hu_raw __hu_pre __hu_re __hu_file + hook::_json_skeleton "$__hu_s" || return 2 + [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 + __hu_n=${#_HOOK_JSON_PARTS[@]} + for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + ((${#__hu_part} <= 60)) || continue + __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} + if [[ "$__hu_body" == *\\* ]]; then + hook::json_unescape_to __hu_body "$__hu_body" || continue + fi + case "$__hu_body" in + tool_input) + ((__hu_ti < 0)) || return 2 + __hu_ti=$__hu_i + ;; + file_path) + ((__hu_fp < 0)) || return 2 + __hu_fp=$__hu_i + ;; + *) ;; + esac + done + ((__hu_fp >= 0)) || return 1 + ((__hu_ti >= 0)) || return 1 + __hu_re="(^|[{,])\"#$__hu_ti\":\\{([^][{}]*)\\}" + if [[ "$_HOOK_JSON_SK" =~ $__hu_re ]]; then + __hu_body=${BASH_REMATCH[2]} + __hu_pre=${_HOOK_JSON_SK%%\"#"$__hu_ti"\"*} + # Depth of the key: opening brackets minus closing ones before it must be + # exactly one, i.e. a direct member of the root object. + local __hu_o1=${__hu_pre//\{/} __hu_o2=${__hu_pre//\[/} __hu_c1=${__hu_pre//\}/} __hu_c2=${__hu_pre//\]/} + local __hu_depth=$(((${#__hu_pre} - ${#__hu_o1}) + (${#__hu_pre} - ${#__hu_o2}) - (${#__hu_pre} - ${#__hu_c1}) - (${#__hu_pre} - ${#__hu_c2}))) + ((__hu_depth == 1)) || return 2 + else + return 2 + fi + __hu_re="(^|,)\"#$__hu_fp\":\"#([0-9]+)\"(,|$)" + if [[ "$__hu_body" =~ $__hu_re ]]; then + __hu_m=${BASH_REMATCH[2]} + elif [[ "$__hu_body" == *\"#$__hu_fp\"* ]]; then + return 2 + else + return 1 + fi + __hu_raw=${__hu_s:${_HOOK_JSON_OFF[__hu_m]}:${#_HOOK_JSON_PARTS[__hu_m]}} + if [[ "$__hu_raw" == *\\* ]]; then + hook::json_unescape_to __hu_file "$__hu_raw" || return 2 + else + __hu_file=$__hu_raw + fi + __hu_file=${__hu_file//$'\r'/} + while [[ "$__hu_file" == *$'\n' ]]; do + __hu_file=${__hu_file%$'\n'} + done + printf -v "$1" '%s' "$__hu_file" + return 0 +} + +# hook::dirname_to : dirname with builtins for the resolver's +# answer. Strips the last segment; a bare name lives in `.`, a root-level file +# in `/`. The path comes from realpath, so the trailing-slash and doubled-slash +# spellings dirname also collapses never occur here. +hook::dirname_to() { + local __hu_d="${2%/*}" + [[ "$__hu_d" == "$2" ]] && __hu_d=. + [[ -n "$__hu_d" ]] || __hu_d=/ + printf -v "$1" '%s' "$__hu_d" +} + +# Print the arguments joined by NUL bytes, no trailing NUL: the byte stream +# hook::read_file_path read from stdin, rebuilt for the jq fallback. +hook::_print_nul_joined() { + local __hu_first=1 __hu_c + for __hu_c in "$@"; do + ((__hu_first)) || printf '\0' + __hu_first=0 + printf '%s' "$__hu_c" + done +} + +# hook::json_compact_to +# The bytes `jq -c .` prints for , when that can be proven with +# builtins: JSON whitespace between tokens removed, everything else verbatim. +# jq's own output, compact or pretty, satisfies the proof, which is what every +# telemetry data builder in this marketplace produces (jq -c, jq -n, or a +# compact literal). Returns 1 when the text is not a single object, or a +# string carries something jq would re-encode (a \u or \/ escape, an escape +# outside the JSON set, a raw control byte), or the structure fails +# hook::_json_skeleton; the caller then runs jq. +hook::json_compact_to() { + local __hu_s="$2" __hu_out="" __hu_i __hu_part __hu_n + hook::_json_skeleton "$__hu_s" || return 1 + [[ "$_HOOK_JSON_SK" == \{*\} ]] || return 1 + # Numbers: only short integer literals are passed through. A fraction, an + # exponent or an integer beyond 15 digits is rendered differently by + # different jq releases (1.6 canonicalizes, 1.7+ preserves the literal), so + # the bytes cannot be proven; jq decides. In the skeleton every string is a + # `"#N"` placeholder and letters occur only in true/false/null, so a digit + # followed by `.`, `e` or `E` can only be part of a number. + local __hu_num='[0-9][.eE]|[0-9]{16}' + [[ "$_HOOK_JSON_SK" =~ $__hu_num ]] && return 1 + __hu_n=${#_HOOK_JSON_PARTS[@]} + for ((__hu_i = 0; __hu_i < __hu_n; __hu_i++)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + if ((__hu_i % 2 == 0)); then + __hu_out+=${__hu_part//[$' \t\n\r']/} + else + if [[ "$__hu_part" == *\\* ]]; then + [[ "$__hu_part" == *'\u'* || "$__hu_part" == *'\/'* ]] && return 1 + [[ "$__hu_part" == *\\[!bfnrt]* ]] && return 1 + fi + __hu_out+="\"${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}}\"" + fi + done + printf -v "$1" '%s' "$__hu_out" +} + +# hook::json_escape_jq_to / hook::json_escape_jq +# Escape exactly as jq serializes a string value (without the +# quotes): backslash and double quote, the five short forms \b \f \n \r \t, +# every other byte from 0x01 to 0x1f and 0x7f as lowercase \u00xx, and +# everything else, including non-ASCII, verbatim. This is the escaper the +# telemetry envelope needs for byte parity with the jq envelope it replaced; +# hook::json_escape above is a different tool (it DROPS the residual control +# bytes, which is right for a notice and wrong for parity) and is unchanged. +hook::json_escape_jq_to() { + local __hu_s="$2" __hu_i __hu_c __hu_oct __hu_hex + __hu_s="${__hu_s//\\/\\\\}" + __hu_s="${__hu_s//\"/\\\"}" + if [[ "$__hu_s" == *[[:cntrl:]]* ]]; then + __hu_s="${__hu_s//$'\n'/\\n}" + __hu_s="${__hu_s//$'\r'/\\r}" + __hu_s="${__hu_s//$'\t'/\\t}" + __hu_s="${__hu_s//$'\b'/\\b}" # portability-ok: bash ANSI-C backspace byte and the JSON \b escape, not a GNU grep word boundary + __hu_s="${__hu_s//$'\f'/\\f}" + for __hu_i in 1 2 3 4 5 6 7 11 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 127; do + printf -v __hu_oct '%03o' "$__hu_i" + printf -v __hu_c '%b' "\\0$__hu_oct" + [[ "$__hu_s" == *"$__hu_c"* ]] || continue + printf -v __hu_hex '%04x' "$__hu_i" + __hu_s="${__hu_s//"$__hu_c"/\\u$__hu_hex}" + done + fi + printf -v "$1" '%s' "$__hu_s" +} + +hook::json_escape_jq() { + local __hu_e + hook::json_escape_jq_to __hu_e "$1" + printf '%s' "$__hu_e" +} + # Parse file_path from PostToolUse JSON on stdin; validate existence and (when # CLAUDE_PROJECT_DIR is set) project membership. Both sides of the membership # comparison are canonicalized (symlinks resolved) first, so neither an # escaping symlink nor a project root reached via a symlinked path (e.g. # macOS /tmp) skews the verdict. Outputs the path on success. Returns 1 to skip. # FILE=$(hook::read_file_path) || exit 0 +# +# jq-free on the common shape. The payload is read into the shell and +# hook::_fast_file_path_to takes `.tool_input.file_path` with builtins when it +# can PROVE the answer jq would give (see that helper for what "prove" means); +# every other payload goes to the unchanged jq filter. The path comparison then +# resolves the file, the project root and the temp roots with one realpath +# process instead of one each (hook::_physical_prime), and remembers the +# directories for the process. Same verdict, same emitted path, fewer +# processes: on Windows Git Bash each spawn costs tens of milliseconds and this +# guard runs on every Write and Edit. hook::read_file_path() { - local file - file=$(jq -r '(.tool_input.file_path // empty) | gsub("\r";"")' 2>/dev/null) + local -a chunks=() + local chunk file="" mode=2 + # Builtin read to NUL or EOF. A NUL splits the payload into several chunks; + # the fast path only takes a single-chunk (NUL-free) payload, and the jq + # fallback is fed the chunks NUL-joined, so it sees the bytes jq used to + # read straight from stdin. + while :; do + chunk="" + if IFS= read -r -d '' chunk; then + chunks+=("$chunk") + continue + fi + chunks+=("$chunk") + break + done + if ((${#chunks[@]} == 1)); then + mode=0 + hook::_fast_file_path_to file "${chunks[0]}" || mode=$? + fi + case "$mode" in + 0) ;; # proven: `file` holds jq's answer + 1) return 1 ;; # proven absent + *) + file=$(hook::_print_nul_joined "${chunks[@]}" | jq -r '(.tool_input.file_path // empty) | gsub("\r";"")' 2>/dev/null) + ;; + esac [[ -n "$file" ]] || return 1 [[ -f "$file" ]] || return 1 if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then - local norm_file norm_project - norm_file=$(hook::normalize_path "$(hook::physical_path "$file")") - norm_project=$(hook::normalize_path "$(hook::physical_path "${CLAUDE_PROJECT_DIR}")") + local norm_file norm_project phys_file phys_project file_resolved=0 project_resolved=0 + hook::_temp_root_candidates + hook::_physical_prime "$file" "${CLAUDE_PROJECT_DIR}" ${_HOOK_TEMP_CANDS[@]+"${_HOOK_TEMP_CANDS[@]}"} + hook::_physical_cached_to phys_file "$file" && file_resolved=1 + hook::_phys_cache_forget "$file" + hook::_physical_cached_to phys_project "${CLAUDE_PROJECT_DIR}" && project_resolved=1 + hook::normalize_path_to norm_file "$phys_file" + hook::normalize_path_to norm_project "$phys_project" norm_project="${norm_project%/}" # Anchor on a path-segment boundary: accept the project root itself or a # child under it, but not a sibling whose name merely shares the prefix @@ -558,19 +1126,28 @@ hook::read_file_path() { # built by `mktemp -d`, which is how this repo's own hook suites run), so # the branch must not fire. Only a temp-tree file reached from a project # root OUTSIDE the temp tree is scratch. - if hook::under_temp_root "$norm_file" && ! hook::under_temp_root "$norm_project"; then - return 1 + local file_in_temp=0 + _HOOK_UTR_TARGET_PHYSICAL=$file_resolved + hook::under_temp_root "$norm_file" && file_in_temp=1 + _HOOK_UTR_TARGET_PHYSICAL=0 + if ((file_in_temp)); then + local project_in_temp=0 + _HOOK_UTR_TARGET_PHYSICAL=$project_resolved + hook::under_temp_root "$norm_project" && project_in_temp=1 + _HOOK_UTR_TARGET_PHYSICAL=0 + ((project_in_temp)) || return 1 fi elif command -v git >/dev/null 2>&1; then # When CLAUDE_PROJECT_DIR is unset, scope to git-working-tree membership so # scratch files outside any repository are not mutated by formatter hooks # (#1091 / #972). - local file_physical - file_physical=$(hook::physical_path "$file") + local file_physical file_dir + hook::physical_path_to file_physical "$file" || : if [[ -L "$file" && "$file_physical" == "$file" ]]; then return 1 fi - if ! hook::in_git_working_tree "$(dirname "$file_physical")"; then + hook::dirname_to file_dir "$file_physical" + if ! hook::in_git_working_tree "$file_dir"; then return 1 fi else @@ -1228,7 +1805,19 @@ hook::telemetry_enabled() { # Fire-and-forget: sink is dispatched in the background; the hook never waits # on it and its failure never affects the hook's own exit code or stdout. # Opt-in guard: HOOK_TELEMETRY_SINK unset or empty → return 0 immediately. -# Fail-open: jq absent → return 0 immediately. +# +# The envelope is assembled with shell builtins (hook::json_escape_jq_to for +# the string fields, hook::json_compact_to for the caller's data object) as +# ONE compact line, `jq -c` style: the same document, field order and +# escapes the jq filter below produces, with the data object compacted +# exactly as jq compacts its own output. That path needs no jq, no temp file +# and no subprocess. When the data object cannot be proven jq-idempotent (see +# hook::json_compact_to), the jq path runs, now with -c so both paths write +# the same bytes, and there jq's absence is still fail-open: return 0, no +# envelope. Before this the envelope was jq's default pretty-printed form +# (several lines, and CRLF line endings with a Windows jq); the sink contract +# was always "one JSON document on stdin", and a compact line is also valid +# JSONL for a sink that appends raw envelopes. # # Usage: # hook::emit_telemetry [repo_root] @@ -1251,7 +1840,6 @@ hook::telemetry_enabled() { # NEVER writes to fd1 (the hook's stdout / additionalContext channel). hook::emit_telemetry() { [[ -n "${HOOK_TELEMETRY_SINK:-}" ]] || return 0 - command -v jq >/dev/null 2>&1 || return 0 local hook_id="$1" local hook_event="$2" @@ -1272,35 +1860,59 @@ hook::emit_telemetry() { local e_s="${now%[.,]*}" e_f="${now#*[.,]}" local duration_ms=$(((e_s * 1000000 + 10#$e_f - s_s * 1000000 - 10#$s_f) / 1000)) - # True UTC timestamp (TZ= prefix overrides LC_ALL / local TZ; the Z is not a lie). - local timestamp - timestamp=$(TZ=UTC printf '%(%Y-%m-%dT%H:%M:%SZ)T' -1) - - # Build the envelope. The `data` object is written to a temp file, not passed - # as --argjson, so payloads larger than the Windows 32767-character command-line - # cap are not dropped before jq runs (#1595). /dev/stdin is not used: jq's - # --slurpfile there is not portable on Windows. Redirect jq stderr to - # /dev/null; output goes to a local variable — never to fd1. - local envelope data_file - data_file=$(mktemp) || return 0 - printf '%s' "$data_json" >"$data_file" || { - rm -f "$data_file" - return 0 - } - envelope=$(jq -n \ - --arg schema_version "1.0" \ - --arg timestamp "$timestamp" \ - --arg hook "$hook_id" \ - --arg hook_event "$hook_event" \ - --arg status "$status" \ - --argjson duration_ms "$duration_ms" \ - --slurpfile data "$data_file" \ - '{schema_version:$schema_version,timestamp:$timestamp,hook:$hook,hook_event:$hook_event,status:$status,duration_ms:$duration_ms,data:($data[0] // {})}' \ - 2>/dev/null) || { + # True UTC timestamp (TZ= prefix overrides LC_ALL / local TZ; the Z is not a + # lie). The %()T printf format is Bash 4.2+, and printf -v keeps it in the + # shell (a `$(...)` capture is a fork); an older bash, or a printf that + # binds nothing, falls back to date -u. + local timestamp="" + TZ=UTC printf -v timestamp '%(%Y-%m-%dT%H:%M:%SZ)T' -1 2>/dev/null || timestamp="" + [[ -n "$timestamp" ]] || timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null) || timestamp="" + + # Trailing whitespace after the object (a newline a caller left on the + # value) does not change what jq parses, so it does not block the builtin + # path either. + local envelope="" data="" data_trim="$data_json" + while [[ "$data_trim" == *[$' \t\r\n'] ]]; do + data_trim="${data_trim%?}" + done + if [[ "$data_trim" == \{*\} ]] && hook::json_compact_to data "$data_trim"; then + # Builtin envelope: the same document the jq filter below produces, field + # for field, with jq's string escaping and jq's compact data. + local e_timestamp e_hook e_event e_status + hook::json_escape_jq_to e_timestamp "$timestamp" + hook::json_escape_jq_to e_hook "$hook_id" + hook::json_escape_jq_to e_event "$hook_event" + hook::json_escape_jq_to e_status "$status" + envelope='{"schema_version":"1.0","timestamp":"'"$e_timestamp"'","hook":"'"$e_hook"'","hook_event":"'"$e_event"'","status":"'"$e_status"'","duration_ms":'"$duration_ms"',"data":'"$data"'}' + else + # jq path, for a data object the builtin compactor cannot prove. Fail-open: + # jq absent → return 0. The `data` object is written to a temp file, not + # passed as --argjson, so payloads larger than the Windows 32767-character + # command-line cap are not dropped before jq runs (#1595). /dev/stdin is + # not used: jq's --slurpfile there is not portable on Windows. Redirect jq + # stderr to /dev/null; output goes to a local variable — never to fd1. + command -v jq >/dev/null 2>&1 || return 0 + local data_file + data_file=$(mktemp) || return 0 + printf '%s' "$data_json" >"$data_file" || { + rm -f "$data_file" + return 0 + } + envelope=$(jq -nc \ + --arg schema_version "1.0" \ + --arg timestamp "$timestamp" \ + --arg hook "$hook_id" \ + --arg hook_event "$hook_event" \ + --arg status "$status" \ + --argjson duration_ms "$duration_ms" \ + --slurpfile data "$data_file" \ + '{schema_version:$schema_version,timestamp:$timestamp,hook:$hook,hook_event:$hook_event,status:$status,duration_ms:$duration_ms,data:($data[0] // {})}' \ + 2>/dev/null) || { + rm -f "$data_file" + return 0 + } rm -f "$data_file" - return 0 - } - rm -f "$data_file" + fi # Resolve the sink path. A relative HOOK_TELEMETRY_SINK is joined onto the # consuming repo root (portable, tracked wiring); absolute is used as-is. A diff --git a/plugins/context-guard/.claude-plugin/plugin.json b/plugins/context-guard/.claude-plugin/plugin.json index 540b5f20b8..3cd39ab28b 100644 --- a/plugins/context-guard/.claude-plugin/plugin.json +++ b/plugins/context-guard/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "context-guard", - "version": "0.7.34", + "version": "0.7.35", "description": "Per-session context-window observability plus the first shipped consumer: a statusline wrapper tees each session's context_window fields to a per-session snapshot file, a zone resolver classifies usage into smart/acceptable/dumb bands (percentage bands plus window-class token bands, conservative-min combination, zones.json SSOT with shipped defaults), a reader contract fixes how consuming sessions interpret the snapshots, and zone-crossing hooks report once per transition into a worse zone across two channels \u2014 the continuation menu to the operator, who owns that choice, and to the model only the zone determination plus the counter-steer that a zone word is not a decay signal (advisory by default; an optional blocking mode gates new mutating work on a fresh dumb-zone snapshot with handoff-writing exempt), with a PostCompact hook persisting an evidence-degraded marker.", "author": { "name": "Melodic Software", diff --git a/plugins/context-guard/CHANGELOG.md b/plugins/context-guard/CHANGELOG.md index c71b41ae9b..9874359748 100644 --- a/plugins/context-guard/CHANGELOG.md +++ b/plugins/context-guard/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to the `context-guard` plugin. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.7.35] + +### Changed + +- **Vendored `hook-utils.sh` builds the telemetry envelope and reads `file_path` + with shell builtins.** `hook::emit_telemetry` no longer spawns two jq + processes, a mktemp and an rm per run: the envelope is assembled in the shell + as one compact line (the same document jq produced, now `jq -c` shaped), with + jq kept only as the fallback for a data object the builtin compactor cannot + prove. `hook::read_file_path` takes `.tool_input.file_path` without jq on the + well-formed payload shape and resolves the file, project root and temp roots + with one batched `realpath` instead of one process each. Same verdicts, same + emitted path, same sink record; phase 4b of the hook-performance program + (#3623). The copy is bumped because `scripts/sync-hook-utils.sh` keeps every + carrying plugin byte-identical. + ## [0.7.34] ### Changed diff --git a/plugins/context-guard/hooks/hook-utils.sh b/plugins/context-guard/hooks/hook-utils.sh index 9818a6a73d..18e4dd5745 100755 --- a/plugins/context-guard/hooks/hook-utils.sh +++ b/plugins/context-guard/hooks/hook-utils.sh @@ -390,19 +390,34 @@ hook::require_jq_blocking() { # collapse with `/c/repo` and the membership guard would admit a sibling # outside CLAUDE_PROJECT_DIR. The result is used ONLY for comparison; the # emitted path is always the caller's original. -hook::normalize_path() { - local p="${1//\\//}" +# +# Two spellings of every path helper below: `hook::` prints the answer +# (the public, subshell-friendly form every caller already uses) and +# `hook::_to ` stores it in the caller's variable instead. +# The `_to` form is what the hot path uses: a `$(...)` capture is a fork, and +# on Windows Git Bash a fork costs milliseconds, so the file_path guard that +# ran a dozen of them per hook now runs none it does not need. The `_to` +# helpers keep their locals under a `__hu_` prefix so the caller's variable +# name cannot collide with them. +hook::normalize_path_to() { + local __hu_p="${2//\\//}" case "${OSTYPE:-}" in msys* | cygwin* | win32) - if [[ "$p" =~ ^/([a-zA-Z])/ || "$p" =~ ^([a-zA-Z]):/ ]]; then - local rest="${p:2}" - printf '%s' "${BASH_REMATCH[1]^}:${rest,,}" - return + if [[ "$__hu_p" =~ ^/([a-zA-Z])/ || "$__hu_p" =~ ^([a-zA-Z]):/ ]]; then + local __hu_rest="${__hu_p:2}" + printf -v "$1" '%s' "${BASH_REMATCH[1]^}:${__hu_rest,,}" + return 0 fi ;; *) ;; # POSIX hosts: case-sensitive FS, no drive fold — pass through below esac - printf '%s' "$p" + printf -v "$1" '%s' "$__hu_p" +} + +hook::normalize_path() { + local __hu_n + hook::normalize_path_to __hu_n "$1" + printf '%s' "$__hu_n" } # Expand Windows 8.3 short-name components (KYLESE~1 → KyleSexton) on @@ -424,25 +439,31 @@ hook::normalize_path() { # class: cygpath ships with Git Bash (the documented Windows bash), so its # absence or failure keeps the resolver's answer unchanged — degrading to the # pre-expansion comparison, same doctrine as the resolver fallback below. -hook::expand_8dot3() { - local p="$1" +hook::expand_8dot3_to() { + local __hu_p="$2" case "${OSTYPE:-}" in msys* | cygwin* | win32) ;; *) - printf '%s' "$p" - return + printf -v "$1" '%s' "$__hu_p" + return 0 ;; esac - if [[ "$p" == *~* ]] && command -v cygpath >/dev/null 2>&1; then - local plain long - if plain=$(cygpath -m -- "$p" 2>/dev/null) && - long=$(cygpath -l -m -- "$p" 2>/dev/null) && - [[ -n "$long" && "$long" != "$plain" ]]; then - printf '%s' "$long" - return + if [[ "$__hu_p" == *~* ]] && command -v cygpath >/dev/null 2>&1; then + local __hu_plain __hu_long + if __hu_plain=$(cygpath -m -- "$__hu_p" 2>/dev/null) && + __hu_long=$(cygpath -l -m -- "$__hu_p" 2>/dev/null) && + [[ -n "$__hu_long" && "$__hu_long" != "$__hu_plain" ]]; then + printf -v "$1" '%s' "$__hu_long" + return 0 fi fi - printf '%s' "$p" + printf -v "$1" '%s' "$__hu_p" +} + +hook::expand_8dot3() { + local __hu_e + hook::expand_8dot3_to __hu_e "$1" + printf '%s' "$__hu_e" } # Canonicalize to a physical path — symlinks resolved, Windows 8.3 short names @@ -462,20 +483,148 @@ hook::expand_8dot3() { # expansion applies only on the resolver's success path: consumers that fail # closed on an unresolved signature must not see a form-converted path instead. # shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED -hook::physical_path() { - local resolved +hook::physical_path_to() { + local __hu_r HOOK_PHYSICAL_PATH_UNRESOLVED=0 - if resolved=$(realpath -- "$1" 2>/dev/null) || resolved=$(readlink -f -- "$1" 2>/dev/null); then - if [[ -n "$resolved" ]]; then - hook::expand_8dot3 "$resolved" + if __hu_r=$(realpath -- "$2" 2>/dev/null) || __hu_r=$(readlink -f -- "$2" 2>/dev/null); then + if [[ -n "$__hu_r" ]]; then + hook::expand_8dot3_to "$1" "$__hu_r" return 0 fi fi HOOK_PHYSICAL_PATH_UNRESOLVED=1 - printf '%s' "$1" + printf -v "$1" '%s' "$2" + return 1 +} + +# shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED +hook::physical_path() { + local __hu_v __hu_rc=0 + hook::physical_path_to __hu_v "$1" || __hu_rc=$? + printf '%s' "$__hu_v" + return "$__hu_rc" +} + +# --- Per-process physical-path cache -------------------------------------- +# The membership guard resolves the same few directories on every call: the +# project root and this host's temp roots. Their physical form does not change +# within one hook process, so each spelling is resolved once and remembered in +# three parallel indexed arrays (keys, values, resolver status). Plain arrays, +# not an associative array, so the cache runs on Bash 3.2. Only directories are +# meant to live here for a process lifetime; hook::read_file_path forgets the +# edited file's entry as soon as it has read it, so a later call in the same +# process sees the file as it is then. +_HOOK_PHYS_KEYS=() +_HOOK_PHYS_VALS=() +_HOOK_PHYS_RCS=() +_HOOK_PHYS_I=-1 + +# hook::_phys_cache_index : sets _HOOK_PHYS_I to the slot holding , +# returns 1 when it is not cached. A forgotten slot has an empty key, which no +# real path can match. +hook::_phys_cache_index() { + local __hu_i + [[ -n "$1" ]] || return 1 + for ((__hu_i = 0; __hu_i < ${#_HOOK_PHYS_KEYS[@]}; __hu_i++)); do + if [[ "${_HOOK_PHYS_KEYS[__hu_i]}" == "$1" ]]; then + _HOOK_PHYS_I=$__hu_i + return 0 + fi + done return 1 } +hook::_phys_cache_forget() { + hook::_phys_cache_index "$1" && _HOOK_PHYS_KEYS[_HOOK_PHYS_I]="" + return 0 +} + +# hook::_physical_cached_to : hook::physical_path_to through the +# cache. Same value, same return status, same HOOK_PHYSICAL_PATH_UNRESOLVED as +# the uncached call; a miss resolves the one path and stores it. +# shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED +hook::_physical_cached_to() { + if hook::_phys_cache_index "$2"; then + printf -v "$1" '%s' "${_HOOK_PHYS_VALS[_HOOK_PHYS_I]}" + HOOK_PHYSICAL_PATH_UNRESOLVED=${_HOOK_PHYS_RCS[_HOOK_PHYS_I]} + return "${_HOOK_PHYS_RCS[_HOOK_PHYS_I]}" + fi + local __hu_v __hu_rc=0 + hook::physical_path_to __hu_v "$2" || __hu_rc=$? + if [[ -n "$2" ]]; then + _HOOK_PHYS_KEYS+=("$2") + _HOOK_PHYS_VALS+=("$__hu_v") + _HOOK_PHYS_RCS+=("$__hu_rc") + fi + printf -v "$1" '%s' "$__hu_v" + return "$__hu_rc" +} + +# hook::_physical_prime ...: resolve every uncached path with ONE +# realpath process and store the answers. realpath resolves each argument on +# its own and prints one line per argument in argument order, so the batch +# answer for a path is the same string the per-path call returns; the 8.3 +# expansion is applied per answer exactly as hook::physical_path_to does. Any +# doubt (a path carrying a newline, a non-zero exit, a line count that does not +# match) stores nothing and leaves the per-path resolver to answer lazily, so +# the batch can only ever save work, never change an answer. readlink -f hosts +# (no realpath) skip the batch for the same reason. +hook::_physical_prime() { + local -a __hu_todo=() + local __hu_p __hu_out __hu_i __hu_seen="" __hu_v + for __hu_p in "$@"; do + [[ -n "$__hu_p" ]] || continue + [[ "$__hu_p" == *$'\n'* ]] && return 0 + case "$__hu_seen" in + *"|$__hu_p|"*) continue ;; + *) ;; # first sighting + esac + __hu_seen="$__hu_seen|$__hu_p|" + hook::_phys_cache_index "$__hu_p" && continue + __hu_todo+=("$__hu_p") + done + ((${#__hu_todo[@]} > 1)) || return 0 + command -v realpath >/dev/null 2>&1 || return 0 + # portability-ok: realpath with several operands is GNU and BSD alike; a host whose realpath rejects it fails the exit-status check and falls back per path + __hu_out=$(realpath -- "${__hu_todo[@]}" 2>/dev/null) || return 0 + local -a __hu_lines=() + local __hu_glob=0 + [[ $- == *f* ]] || __hu_glob=1 + set -f + local IFS=$'\n' + # shellcheck disable=SC2206 # splitting realpath's one-line-per-operand output is the intent + __hu_lines=($__hu_out) + ((__hu_glob)) && set +f + ((${#__hu_lines[@]} == ${#__hu_todo[@]})) || return 0 + for ((__hu_i = 0; __hu_i < ${#__hu_todo[@]}; __hu_i++)); do + [[ -n "${__hu_lines[__hu_i]}" ]] || return 0 + hook::expand_8dot3_to __hu_v "${__hu_lines[__hu_i]}" + _HOOK_PHYS_KEYS+=("${__hu_todo[__hu_i]}") + _HOOK_PHYS_VALS+=("$__hu_v") + _HOOK_PHYS_RCS+=(0) + done + return 0 +} + +# The temp-root candidates hook::under_temp_root compares against: the +# environment's own answer (TMPDIR/TMP/TEMP) plus the POSIX defaults, never a +# hardcoded platform assumption. Existing directories only, each spelling +# once, in _HOOK_TEMP_CANDS. +_HOOK_TEMP_CANDS=() +hook::_temp_root_candidates() { + local __hu_cand __hu_seen="" + _HOOK_TEMP_CANDS=() + for __hu_cand in "${TMPDIR:-}" "${TMP:-}" "${TEMP:-}" /tmp /var/tmp; do + [[ -n "$__hu_cand" && -d "$__hu_cand" ]] || continue + case "$__hu_seen" in + *"|$__hu_cand|"*) continue ;; + *) ;; # first sighting of this candidate + esac + __hu_seen="$__hu_seen|$__hu_cand|" + _HOOK_TEMP_CANDS+=("$__hu_cand") + done +} + # True when sits inside one of this host's temp trees. # Both arguments and candidates go through the same canonicalize+normalize # pipeline as the membership comparison, because the same directory has several @@ -483,21 +632,40 @@ hook::physical_path() { # of the identical directory, and `realpath` resolves the Windows form to a # drive path while leaving `/tmp` as `/tmp`. Neither form alone matches a # `file_path` that could arrive in either, so every candidate is compared and a -# match on any one is a match. Duplicates are resolved once. +# match on any one is a match. Duplicates are resolved once, and each +# candidate's physical form is remembered for the process (see the cache +# above), so a second call costs no resolver process. # -# Candidates are the environment's own answer (TMPDIR/TMP/TEMP) plus the POSIX -# defaults, never a hardcoded platform assumption. +# Raw-spelling shortcut: when the caller vouches that is a +# PHYSICAL path (_HOOK_UTR_TARGET_PHYSICAL=1, set only by hook::read_file_path +# after the resolver succeeded), a candidate whose raw normalized spelling is +# already a prefix of the target is a match without resolving it. A physical +# path contains no symlink component, so a candidate that spells one of its +# prefixes names a chain of real directories whose physical form is that same +# prefix; the resolver would answer the same. A candidate that does not match +# raw is still resolved, so a symlinked temp root (macOS /tmp) is found the +# way it always was. Without the vouch the shortcut is off and every candidate +# is resolved, exactly as before. Note that hook::read_file_path primes every +# candidate into the cache with one batched realpath before calling here, so +# on that path the shortcut skips a lookup, not a process; the batch is what +# saves the processes. A direct caller without the batch saves the resolver. # hook::under_temp_root "$norm_path" && ... +_HOOK_UTR_TARGET_PHYSICAL=0 hook::under_temp_root() { - local target="$1" cand norm seen="" - for cand in "${TMPDIR:-}" "${TMP:-}" "${TEMP:-}" /tmp /var/tmp; do - [[ -n "$cand" && -d "$cand" ]] || continue - case "$seen" in - *"|$cand|"*) continue ;; - *) ;; # first sighting of this candidate — resolve it below - esac - seen="$seen|$cand|" - norm=$(hook::normalize_path "$(hook::physical_path "$cand")") + local target="$1" cand norm phys + hook::_temp_root_candidates + for cand in ${_HOOK_TEMP_CANDS[@]+"${_HOOK_TEMP_CANDS[@]}"}; do + if ((_HOOK_UTR_TARGET_PHYSICAL)); then + hook::normalize_path_to norm "$cand" + if [[ "$norm" != / ]]; then + norm="${norm%/}" + if [[ -n "$norm" && ("$target" == "$norm" || "$target" == "$norm"/*) ]]; then + return 0 + fi + fi + fi + hook::_physical_cached_to phys "$cand" || : + hook::normalize_path_to norm "$phys" # The filesystem root as a temp candidate contains every absolute path; # trimming its only slash would empty the candidate and discard it. [[ "$norm" == / ]] && return 0 @@ -523,21 +691,421 @@ hook::in_git_working_tree() { ) >/dev/null 2>&1 } +# --- Builtin JSON helpers ---------------------------------------------------- +# hook::read_file_path and hook::emit_telemetry used to spend a jq process each +# on every hook run. On Windows Git Bash one spawn costs tens of milliseconds, +# so the helpers here handle the well-formed shapes those two functions see in +# practice with shell builtins and hand anything they cannot PROVE to the +# unchanged jq path. The contract is "the bytes jq would produce, or fall +# back", never "close enough". +# +# What the builtin paths rely on: the text is valid JSON. Every hook payload +# reaching hook::read_file_path has passed hook::buffer_stdin's jq validation, +# and every telemetry data object is built with jq. The helpers still verify +# the structure they walk (terminated strings, well-formed tokens, properly +# nested brackets, one root, no raw control bytes inside strings), but they +# are not a full JSON parser and do not claim to reject every malformed text. +# +# Why no scanning: on this repo's Windows hosts bash regex matching and the +# `%%`/`##` pattern operators cost about a microsecond per character, so a +# 60 KB Write payload would cost more than the jq process they replace. The +# only string operations used on the whole payload are literal-substring +# replacement, `[[ == *x* ]]` containment, IFS word splitting and offset +# slicing, all of which run at C speed. + +# hook::_json_split +# Split at every unescaped double quote into the global array +# _HOOK_JSON_PARTS: even indices hold the structural text between strings, odd +# indices hold string bodies. Before splitting, every `\\` and `\"` pair is +# replaced by `@@` (same length), so a quote that survives is a real string +# delimiter and a body's ORIGINAL bytes are ${text:offset:length}. Returns 1 +# when the text is too large, its quotes do not pair up, or it holds more +# strings than the callers' loops are meant to walk. +_HOOK_JSON_PARTS=() +hook::_json_split() { + local __hu_s="$1" __hu_t __hu_q __hu_n + ((${#__hu_s} <= 65536)) || return 1 + __hu_t=${__hu_s//"\\\\"/@@} + __hu_t=${__hu_t//"\\\""/@@} + __hu_q=${__hu_t//\"/} + __hu_n=$((${#__hu_t} - ${#__hu_q})) + ((__hu_n % 2 == 0)) || return 1 + ((__hu_n <= 800)) || return 1 + local __hu_glob=0 + [[ $- == *f* ]] || __hu_glob=1 + set -f + local IFS='"' + # shellcheck disable=SC2206 # splitting on IFS='"' is the whole point + _HOOK_JSON_PARTS=($__hu_t) + ((__hu_glob)) && set +f + ((${#_HOOK_JSON_PARTS[@]} % 2 == 1)) || return 1 + return 0 +} + +# hook::_json_skeleton +# Split (hook::_json_split), record each string body's byte offset in +# _HOOK_JSON_OFF, and build _HOOK_JSON_SK: the structural text with every +# string replaced by a numbered placeholder `"#"` and all JSON +# whitespace removed. The skeleton is small (no string bodies), so regex and +# per-token work on it is cheap. Returns 1 unless the text is exactly one JSON +# value by the JSON grammar (well-formed tokens, whitespace only between them, +# objects as key:value lists, arrays as value lists, brackets nested and +# closed once) whose strings carry no raw control byte, which jq rejects. +_HOOK_JSON_SK="" +_HOOK_JSON_OFF=() +hook::_json_skeleton() { + local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top + hook::_json_split "$__hu_s" || return 1 + __hu_n=${#_HOOK_JSON_PARTS[@]} + _HOOK_JSON_OFF=() + for ((__hu_i = 0; __hu_i < __hu_n; __hu_i++)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + if ((__hu_i % 2 == 0)); then + __hu_sk+=$__hu_part + else + _HOOK_JSON_OFF[__hu_i]=$__hu_off + [[ "$__hu_part" == *[[:cntrl:]]* ]] && return 1 + __hu_sk+="\"#$__hu_i\"" + fi + __hu_off=$((__hu_off + ${#__hu_part} + 1)) + done + # Tokenize the skeleton and run it through the JSON grammar. Each token is + # consumed by one anchored regex on the (small) remainder; whitespace is + # allowed only between tokens, so `tr ue` is two bad tokens, not `true`. + local __hu_re=$'^[ \t\n\r]*(\\{|\\}|\\[|\\]|,|:|"#[0-9]+"|-?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][-+]?[0-9]+)?|true|false|null)' + local __hu_ws=$'^[ \t\n\r]*$' + __hu_rest=$__hu_sk + while [[ -n "$__hu_rest" ]] && ! [[ "$__hu_rest" =~ $__hu_ws ]]; do + [[ "$__hu_rest" =~ $__hu_re ]] || return 1 + __hu_tok=${BASH_REMATCH[1]} + __hu_rest=${__hu_rest:${#BASH_REMATCH[0]}} + __hu_top=${__hu_stack:${#__hu_stack}-1:1} + case "$__hu_expect" in + value | value_or_end) + case "$__hu_tok" in + '{') + __hu_stack+='{' + __hu_expect=key_or_end + continue + ;; + '[') + __hu_stack+='[' + __hu_expect=value_or_end + continue + ;; + ']') + [[ "$__hu_expect" == value_or_end ]] || return 1 + ;; + '}' | ',' | ':') return 1 ;; + *) + # A scalar. Inside a container the next token is , or the close; + # at the top level nothing may follow. + if [[ -n "$__hu_stack" ]]; then __hu_expect=comma_or_end; else __hu_expect=end; fi + continue + ;; + esac + ;; + key_or_end | key) + case "$__hu_tok" in + \"#*) + __hu_expect='colon' + continue + ;; + '}') [[ "$__hu_expect" == key_or_end ]] || return 1 ;; + *) return 1 ;; + esac + ;; + colon) + [[ "$__hu_tok" == ':' ]] || return 1 + __hu_expect=value + continue + ;; + comma_or_end) + case "$__hu_tok" in + ',') + if [[ "$__hu_top" == '{' ]]; then __hu_expect=key; else __hu_expect=value; fi + continue + ;; + '}') [[ "$__hu_top" == '{' ]] || return 1 ;; + ']') [[ "$__hu_top" == '[' ]] || return 1 ;; + *) return 1 ;; + esac + ;; + *) return 1 ;; # `end`: a token after the root value closed + esac + # Reaching here means a container just closed. + [[ -n "$__hu_stack" ]] || return 1 + __hu_stack=${__hu_stack:0:${#__hu_stack}-1} + if [[ -n "$__hu_stack" ]]; then __hu_expect=comma_or_end; else __hu_expect=end; fi + done + [[ "$__hu_expect" == end ]] || return 1 + _HOOK_JSON_SK=${__hu_sk//[$' \t\n\r']/} + return 0 +} + +# hook::json_unescape_to +# Decode a JSON string body (the bytes between the quotes) into . Handles +# \" \\ \/ \b \f \n \r \t and \uXXXX for U+0001 to U+007F. Returns 1 on any +# other escape (a \u outside that range, U+0000 which bash cannot hold, or an +# invalid escape), leaving the caller to fall back to jq. +hook::json_unescape_to() { + local __hu_raw="$2" __hu_out="" __hu_pre __hu_c __hu_hex __hu_oct __hu_ch + while [[ "$__hu_raw" == *\\* ]]; do + __hu_pre=${__hu_raw%%\\*} + __hu_out+=$__hu_pre + __hu_raw=${__hu_raw:${#__hu_pre}+1} + __hu_c=${__hu_raw:0:1} + __hu_raw=${__hu_raw:1} + case "$__hu_c" in + '"') __hu_out+='"' ;; + "\\") __hu_out+="\\" ;; + '/') __hu_out+='/' ;; + b) __hu_out+=$'\b' ;; # portability-ok: bash ANSI-C backspace byte, not a GNU grep word boundary + f) __hu_out+=$'\f' ;; + n) __hu_out+=$'\n' ;; + r) __hu_out+=$'\r' ;; + t) __hu_out+=$'\t' ;; + u) + __hu_hex=${__hu_raw:0:4} + [[ "$__hu_hex" =~ ^00[0-7][0-9a-fA-F]$ ]] || return 1 + [[ "$__hu_hex" == 0000 ]] && return 1 + __hu_raw=${__hu_raw:4} + printf -v __hu_oct '%03o' "$((16#$__hu_hex))" + printf -v __hu_ch '%b' "\\0$__hu_oct" + __hu_out+=$__hu_ch + ;; + *) return 1 ;; + esac + done + __hu_out+=$__hu_raw + printf -v "$1" '%s' "$__hu_out" +} + +# hook::_fast_file_path_to +# The builtin answer to `jq -r '(.tool_input.file_path // empty) | +# gsub("\r";"")'` followed by the `$(...)` capture. Returns +# 0 proven: holds exactly what jq would have printed +# 1 proven absent: jq would have printed nothing (the guard skips) +# 2 not proven: run jq +# Proof, on top of hook::_json_skeleton's structural checks: the root is an +# object; exactly one string in the whole payload decodes to `tool_input` and +# exactly one to `file_path` (so neither a duplicate key nor a same-named key +# in another object can change jq's answer); `tool_input` is a key of the root +# (depth 1, key position) whose value is a flat object (no nested object or +# array, else jq); and inside it `file_path` is a key with a plain string +# value. A payload where `file_path` names a value or sits in another object +# has no `.tool_input.file_path`, which jq reports as nothing. Key strings are +# compared AFTER decoding their escapes, so a key spelled with \u escapes is +# still recognized; a body longer than any escaped spelling of either name is +# skipped without decoding. +hook::_fast_file_path_to() { + local __hu_s="$2" __hu_i __hu_n __hu_part __hu_body __hu_ti=-1 __hu_fp=-1 __hu_m __hu_raw __hu_pre __hu_re __hu_file + hook::_json_skeleton "$__hu_s" || return 2 + [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 + __hu_n=${#_HOOK_JSON_PARTS[@]} + for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + ((${#__hu_part} <= 60)) || continue + __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} + if [[ "$__hu_body" == *\\* ]]; then + hook::json_unescape_to __hu_body "$__hu_body" || continue + fi + case "$__hu_body" in + tool_input) + ((__hu_ti < 0)) || return 2 + __hu_ti=$__hu_i + ;; + file_path) + ((__hu_fp < 0)) || return 2 + __hu_fp=$__hu_i + ;; + *) ;; + esac + done + ((__hu_fp >= 0)) || return 1 + ((__hu_ti >= 0)) || return 1 + __hu_re="(^|[{,])\"#$__hu_ti\":\\{([^][{}]*)\\}" + if [[ "$_HOOK_JSON_SK" =~ $__hu_re ]]; then + __hu_body=${BASH_REMATCH[2]} + __hu_pre=${_HOOK_JSON_SK%%\"#"$__hu_ti"\"*} + # Depth of the key: opening brackets minus closing ones before it must be + # exactly one, i.e. a direct member of the root object. + local __hu_o1=${__hu_pre//\{/} __hu_o2=${__hu_pre//\[/} __hu_c1=${__hu_pre//\}/} __hu_c2=${__hu_pre//\]/} + local __hu_depth=$(((${#__hu_pre} - ${#__hu_o1}) + (${#__hu_pre} - ${#__hu_o2}) - (${#__hu_pre} - ${#__hu_c1}) - (${#__hu_pre} - ${#__hu_c2}))) + ((__hu_depth == 1)) || return 2 + else + return 2 + fi + __hu_re="(^|,)\"#$__hu_fp\":\"#([0-9]+)\"(,|$)" + if [[ "$__hu_body" =~ $__hu_re ]]; then + __hu_m=${BASH_REMATCH[2]} + elif [[ "$__hu_body" == *\"#$__hu_fp\"* ]]; then + return 2 + else + return 1 + fi + __hu_raw=${__hu_s:${_HOOK_JSON_OFF[__hu_m]}:${#_HOOK_JSON_PARTS[__hu_m]}} + if [[ "$__hu_raw" == *\\* ]]; then + hook::json_unescape_to __hu_file "$__hu_raw" || return 2 + else + __hu_file=$__hu_raw + fi + __hu_file=${__hu_file//$'\r'/} + while [[ "$__hu_file" == *$'\n' ]]; do + __hu_file=${__hu_file%$'\n'} + done + printf -v "$1" '%s' "$__hu_file" + return 0 +} + +# hook::dirname_to : dirname with builtins for the resolver's +# answer. Strips the last segment; a bare name lives in `.`, a root-level file +# in `/`. The path comes from realpath, so the trailing-slash and doubled-slash +# spellings dirname also collapses never occur here. +hook::dirname_to() { + local __hu_d="${2%/*}" + [[ "$__hu_d" == "$2" ]] && __hu_d=. + [[ -n "$__hu_d" ]] || __hu_d=/ + printf -v "$1" '%s' "$__hu_d" +} + +# Print the arguments joined by NUL bytes, no trailing NUL: the byte stream +# hook::read_file_path read from stdin, rebuilt for the jq fallback. +hook::_print_nul_joined() { + local __hu_first=1 __hu_c + for __hu_c in "$@"; do + ((__hu_first)) || printf '\0' + __hu_first=0 + printf '%s' "$__hu_c" + done +} + +# hook::json_compact_to +# The bytes `jq -c .` prints for , when that can be proven with +# builtins: JSON whitespace between tokens removed, everything else verbatim. +# jq's own output, compact or pretty, satisfies the proof, which is what every +# telemetry data builder in this marketplace produces (jq -c, jq -n, or a +# compact literal). Returns 1 when the text is not a single object, or a +# string carries something jq would re-encode (a \u or \/ escape, an escape +# outside the JSON set, a raw control byte), or the structure fails +# hook::_json_skeleton; the caller then runs jq. +hook::json_compact_to() { + local __hu_s="$2" __hu_out="" __hu_i __hu_part __hu_n + hook::_json_skeleton "$__hu_s" || return 1 + [[ "$_HOOK_JSON_SK" == \{*\} ]] || return 1 + # Numbers: only short integer literals are passed through. A fraction, an + # exponent or an integer beyond 15 digits is rendered differently by + # different jq releases (1.6 canonicalizes, 1.7+ preserves the literal), so + # the bytes cannot be proven; jq decides. In the skeleton every string is a + # `"#N"` placeholder and letters occur only in true/false/null, so a digit + # followed by `.`, `e` or `E` can only be part of a number. + local __hu_num='[0-9][.eE]|[0-9]{16}' + [[ "$_HOOK_JSON_SK" =~ $__hu_num ]] && return 1 + __hu_n=${#_HOOK_JSON_PARTS[@]} + for ((__hu_i = 0; __hu_i < __hu_n; __hu_i++)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + if ((__hu_i % 2 == 0)); then + __hu_out+=${__hu_part//[$' \t\n\r']/} + else + if [[ "$__hu_part" == *\\* ]]; then + [[ "$__hu_part" == *'\u'* || "$__hu_part" == *'\/'* ]] && return 1 + [[ "$__hu_part" == *\\[!bfnrt]* ]] && return 1 + fi + __hu_out+="\"${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}}\"" + fi + done + printf -v "$1" '%s' "$__hu_out" +} + +# hook::json_escape_jq_to / hook::json_escape_jq +# Escape exactly as jq serializes a string value (without the +# quotes): backslash and double quote, the five short forms \b \f \n \r \t, +# every other byte from 0x01 to 0x1f and 0x7f as lowercase \u00xx, and +# everything else, including non-ASCII, verbatim. This is the escaper the +# telemetry envelope needs for byte parity with the jq envelope it replaced; +# hook::json_escape above is a different tool (it DROPS the residual control +# bytes, which is right for a notice and wrong for parity) and is unchanged. +hook::json_escape_jq_to() { + local __hu_s="$2" __hu_i __hu_c __hu_oct __hu_hex + __hu_s="${__hu_s//\\/\\\\}" + __hu_s="${__hu_s//\"/\\\"}" + if [[ "$__hu_s" == *[[:cntrl:]]* ]]; then + __hu_s="${__hu_s//$'\n'/\\n}" + __hu_s="${__hu_s//$'\r'/\\r}" + __hu_s="${__hu_s//$'\t'/\\t}" + __hu_s="${__hu_s//$'\b'/\\b}" # portability-ok: bash ANSI-C backspace byte and the JSON \b escape, not a GNU grep word boundary + __hu_s="${__hu_s//$'\f'/\\f}" + for __hu_i in 1 2 3 4 5 6 7 11 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 127; do + printf -v __hu_oct '%03o' "$__hu_i" + printf -v __hu_c '%b' "\\0$__hu_oct" + [[ "$__hu_s" == *"$__hu_c"* ]] || continue + printf -v __hu_hex '%04x' "$__hu_i" + __hu_s="${__hu_s//"$__hu_c"/\\u$__hu_hex}" + done + fi + printf -v "$1" '%s' "$__hu_s" +} + +hook::json_escape_jq() { + local __hu_e + hook::json_escape_jq_to __hu_e "$1" + printf '%s' "$__hu_e" +} + # Parse file_path from PostToolUse JSON on stdin; validate existence and (when # CLAUDE_PROJECT_DIR is set) project membership. Both sides of the membership # comparison are canonicalized (symlinks resolved) first, so neither an # escaping symlink nor a project root reached via a symlinked path (e.g. # macOS /tmp) skews the verdict. Outputs the path on success. Returns 1 to skip. # FILE=$(hook::read_file_path) || exit 0 +# +# jq-free on the common shape. The payload is read into the shell and +# hook::_fast_file_path_to takes `.tool_input.file_path` with builtins when it +# can PROVE the answer jq would give (see that helper for what "prove" means); +# every other payload goes to the unchanged jq filter. The path comparison then +# resolves the file, the project root and the temp roots with one realpath +# process instead of one each (hook::_physical_prime), and remembers the +# directories for the process. Same verdict, same emitted path, fewer +# processes: on Windows Git Bash each spawn costs tens of milliseconds and this +# guard runs on every Write and Edit. hook::read_file_path() { - local file - file=$(jq -r '(.tool_input.file_path // empty) | gsub("\r";"")' 2>/dev/null) + local -a chunks=() + local chunk file="" mode=2 + # Builtin read to NUL or EOF. A NUL splits the payload into several chunks; + # the fast path only takes a single-chunk (NUL-free) payload, and the jq + # fallback is fed the chunks NUL-joined, so it sees the bytes jq used to + # read straight from stdin. + while :; do + chunk="" + if IFS= read -r -d '' chunk; then + chunks+=("$chunk") + continue + fi + chunks+=("$chunk") + break + done + if ((${#chunks[@]} == 1)); then + mode=0 + hook::_fast_file_path_to file "${chunks[0]}" || mode=$? + fi + case "$mode" in + 0) ;; # proven: `file` holds jq's answer + 1) return 1 ;; # proven absent + *) + file=$(hook::_print_nul_joined "${chunks[@]}" | jq -r '(.tool_input.file_path // empty) | gsub("\r";"")' 2>/dev/null) + ;; + esac [[ -n "$file" ]] || return 1 [[ -f "$file" ]] || return 1 if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then - local norm_file norm_project - norm_file=$(hook::normalize_path "$(hook::physical_path "$file")") - norm_project=$(hook::normalize_path "$(hook::physical_path "${CLAUDE_PROJECT_DIR}")") + local norm_file norm_project phys_file phys_project file_resolved=0 project_resolved=0 + hook::_temp_root_candidates + hook::_physical_prime "$file" "${CLAUDE_PROJECT_DIR}" ${_HOOK_TEMP_CANDS[@]+"${_HOOK_TEMP_CANDS[@]}"} + hook::_physical_cached_to phys_file "$file" && file_resolved=1 + hook::_phys_cache_forget "$file" + hook::_physical_cached_to phys_project "${CLAUDE_PROJECT_DIR}" && project_resolved=1 + hook::normalize_path_to norm_file "$phys_file" + hook::normalize_path_to norm_project "$phys_project" norm_project="${norm_project%/}" # Anchor on a path-segment boundary: accept the project root itself or a # child under it, but not a sibling whose name merely shares the prefix @@ -558,19 +1126,28 @@ hook::read_file_path() { # built by `mktemp -d`, which is how this repo's own hook suites run), so # the branch must not fire. Only a temp-tree file reached from a project # root OUTSIDE the temp tree is scratch. - if hook::under_temp_root "$norm_file" && ! hook::under_temp_root "$norm_project"; then - return 1 + local file_in_temp=0 + _HOOK_UTR_TARGET_PHYSICAL=$file_resolved + hook::under_temp_root "$norm_file" && file_in_temp=1 + _HOOK_UTR_TARGET_PHYSICAL=0 + if ((file_in_temp)); then + local project_in_temp=0 + _HOOK_UTR_TARGET_PHYSICAL=$project_resolved + hook::under_temp_root "$norm_project" && project_in_temp=1 + _HOOK_UTR_TARGET_PHYSICAL=0 + ((project_in_temp)) || return 1 fi elif command -v git >/dev/null 2>&1; then # When CLAUDE_PROJECT_DIR is unset, scope to git-working-tree membership so # scratch files outside any repository are not mutated by formatter hooks # (#1091 / #972). - local file_physical - file_physical=$(hook::physical_path "$file") + local file_physical file_dir + hook::physical_path_to file_physical "$file" || : if [[ -L "$file" && "$file_physical" == "$file" ]]; then return 1 fi - if ! hook::in_git_working_tree "$(dirname "$file_physical")"; then + hook::dirname_to file_dir "$file_physical" + if ! hook::in_git_working_tree "$file_dir"; then return 1 fi else @@ -1228,7 +1805,19 @@ hook::telemetry_enabled() { # Fire-and-forget: sink is dispatched in the background; the hook never waits # on it and its failure never affects the hook's own exit code or stdout. # Opt-in guard: HOOK_TELEMETRY_SINK unset or empty → return 0 immediately. -# Fail-open: jq absent → return 0 immediately. +# +# The envelope is assembled with shell builtins (hook::json_escape_jq_to for +# the string fields, hook::json_compact_to for the caller's data object) as +# ONE compact line, `jq -c` style: the same document, field order and +# escapes the jq filter below produces, with the data object compacted +# exactly as jq compacts its own output. That path needs no jq, no temp file +# and no subprocess. When the data object cannot be proven jq-idempotent (see +# hook::json_compact_to), the jq path runs, now with -c so both paths write +# the same bytes, and there jq's absence is still fail-open: return 0, no +# envelope. Before this the envelope was jq's default pretty-printed form +# (several lines, and CRLF line endings with a Windows jq); the sink contract +# was always "one JSON document on stdin", and a compact line is also valid +# JSONL for a sink that appends raw envelopes. # # Usage: # hook::emit_telemetry [repo_root] @@ -1251,7 +1840,6 @@ hook::telemetry_enabled() { # NEVER writes to fd1 (the hook's stdout / additionalContext channel). hook::emit_telemetry() { [[ -n "${HOOK_TELEMETRY_SINK:-}" ]] || return 0 - command -v jq >/dev/null 2>&1 || return 0 local hook_id="$1" local hook_event="$2" @@ -1272,35 +1860,59 @@ hook::emit_telemetry() { local e_s="${now%[.,]*}" e_f="${now#*[.,]}" local duration_ms=$(((e_s * 1000000 + 10#$e_f - s_s * 1000000 - 10#$s_f) / 1000)) - # True UTC timestamp (TZ= prefix overrides LC_ALL / local TZ; the Z is not a lie). - local timestamp - timestamp=$(TZ=UTC printf '%(%Y-%m-%dT%H:%M:%SZ)T' -1) - - # Build the envelope. The `data` object is written to a temp file, not passed - # as --argjson, so payloads larger than the Windows 32767-character command-line - # cap are not dropped before jq runs (#1595). /dev/stdin is not used: jq's - # --slurpfile there is not portable on Windows. Redirect jq stderr to - # /dev/null; output goes to a local variable — never to fd1. - local envelope data_file - data_file=$(mktemp) || return 0 - printf '%s' "$data_json" >"$data_file" || { - rm -f "$data_file" - return 0 - } - envelope=$(jq -n \ - --arg schema_version "1.0" \ - --arg timestamp "$timestamp" \ - --arg hook "$hook_id" \ - --arg hook_event "$hook_event" \ - --arg status "$status" \ - --argjson duration_ms "$duration_ms" \ - --slurpfile data "$data_file" \ - '{schema_version:$schema_version,timestamp:$timestamp,hook:$hook,hook_event:$hook_event,status:$status,duration_ms:$duration_ms,data:($data[0] // {})}' \ - 2>/dev/null) || { + # True UTC timestamp (TZ= prefix overrides LC_ALL / local TZ; the Z is not a + # lie). The %()T printf format is Bash 4.2+, and printf -v keeps it in the + # shell (a `$(...)` capture is a fork); an older bash, or a printf that + # binds nothing, falls back to date -u. + local timestamp="" + TZ=UTC printf -v timestamp '%(%Y-%m-%dT%H:%M:%SZ)T' -1 2>/dev/null || timestamp="" + [[ -n "$timestamp" ]] || timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null) || timestamp="" + + # Trailing whitespace after the object (a newline a caller left on the + # value) does not change what jq parses, so it does not block the builtin + # path either. + local envelope="" data="" data_trim="$data_json" + while [[ "$data_trim" == *[$' \t\r\n'] ]]; do + data_trim="${data_trim%?}" + done + if [[ "$data_trim" == \{*\} ]] && hook::json_compact_to data "$data_trim"; then + # Builtin envelope: the same document the jq filter below produces, field + # for field, with jq's string escaping and jq's compact data. + local e_timestamp e_hook e_event e_status + hook::json_escape_jq_to e_timestamp "$timestamp" + hook::json_escape_jq_to e_hook "$hook_id" + hook::json_escape_jq_to e_event "$hook_event" + hook::json_escape_jq_to e_status "$status" + envelope='{"schema_version":"1.0","timestamp":"'"$e_timestamp"'","hook":"'"$e_hook"'","hook_event":"'"$e_event"'","status":"'"$e_status"'","duration_ms":'"$duration_ms"',"data":'"$data"'}' + else + # jq path, for a data object the builtin compactor cannot prove. Fail-open: + # jq absent → return 0. The `data` object is written to a temp file, not + # passed as --argjson, so payloads larger than the Windows 32767-character + # command-line cap are not dropped before jq runs (#1595). /dev/stdin is + # not used: jq's --slurpfile there is not portable on Windows. Redirect jq + # stderr to /dev/null; output goes to a local variable — never to fd1. + command -v jq >/dev/null 2>&1 || return 0 + local data_file + data_file=$(mktemp) || return 0 + printf '%s' "$data_json" >"$data_file" || { + rm -f "$data_file" + return 0 + } + envelope=$(jq -nc \ + --arg schema_version "1.0" \ + --arg timestamp "$timestamp" \ + --arg hook "$hook_id" \ + --arg hook_event "$hook_event" \ + --arg status "$status" \ + --argjson duration_ms "$duration_ms" \ + --slurpfile data "$data_file" \ + '{schema_version:$schema_version,timestamp:$timestamp,hook:$hook,hook_event:$hook_event,status:$status,duration_ms:$duration_ms,data:($data[0] // {})}' \ + 2>/dev/null) || { + rm -f "$data_file" + return 0 + } rm -f "$data_file" - return 0 - } - rm -f "$data_file" + fi # Resolve the sink path. A relative HOOK_TELEMETRY_SINK is joined onto the # consuming repo root (portable, tracked wiring); absolute is used as-is. A diff --git a/plugins/desktop-notification/.claude-plugin/plugin.json b/plugins/desktop-notification/.claude-plugin/plugin.json index cac7eb226e..a27cfe90aa 100644 --- a/plugins/desktop-notification/.claude-plugin/plugin.json +++ b/plugins/desktop-notification/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "desktop-notification", - "version": "0.6.27", + "version": "0.6.28", "description": "Alert you when Claude Code needs input \u2014 an audible terminal bell, an OSC 9 terminal notification, and an OS-native toast (macOS/Linux) on permission and idle prompts.", "author": { "name": "Melodic Software", diff --git a/plugins/desktop-notification/CHANGELOG.md b/plugins/desktop-notification/CHANGELOG.md index 86922cdc5e..6672c230e6 100644 --- a/plugins/desktop-notification/CHANGELOG.md +++ b/plugins/desktop-notification/CHANGELOG.md @@ -3,6 +3,22 @@ All notable changes to the `desktop-notification` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.6.28] + +### Changed + +- **Vendored `hook-utils.sh` builds the telemetry envelope and reads `file_path` + with shell builtins.** `hook::emit_telemetry` no longer spawns two jq + processes, a mktemp and an rm per run: the envelope is assembled in the shell + as one compact line (the same document jq produced, now `jq -c` shaped), with + jq kept only as the fallback for a data object the builtin compactor cannot + prove. `hook::read_file_path` takes `.tool_input.file_path` without jq on the + well-formed payload shape and resolves the file, project root and temp roots + with one batched `realpath` instead of one process each. Same verdicts, same + emitted path, same sink record; phase 4b of the hook-performance program + (#3623). The copy is bumped because `scripts/sync-hook-utils.sh` keeps every + carrying plugin byte-identical. + ## [0.6.27] ### Changed diff --git a/plugins/desktop-notification/hooks/hook-utils.sh b/plugins/desktop-notification/hooks/hook-utils.sh index 9818a6a73d..18e4dd5745 100644 --- a/plugins/desktop-notification/hooks/hook-utils.sh +++ b/plugins/desktop-notification/hooks/hook-utils.sh @@ -390,19 +390,34 @@ hook::require_jq_blocking() { # collapse with `/c/repo` and the membership guard would admit a sibling # outside CLAUDE_PROJECT_DIR. The result is used ONLY for comparison; the # emitted path is always the caller's original. -hook::normalize_path() { - local p="${1//\\//}" +# +# Two spellings of every path helper below: `hook::` prints the answer +# (the public, subshell-friendly form every caller already uses) and +# `hook::_to ` stores it in the caller's variable instead. +# The `_to` form is what the hot path uses: a `$(...)` capture is a fork, and +# on Windows Git Bash a fork costs milliseconds, so the file_path guard that +# ran a dozen of them per hook now runs none it does not need. The `_to` +# helpers keep their locals under a `__hu_` prefix so the caller's variable +# name cannot collide with them. +hook::normalize_path_to() { + local __hu_p="${2//\\//}" case "${OSTYPE:-}" in msys* | cygwin* | win32) - if [[ "$p" =~ ^/([a-zA-Z])/ || "$p" =~ ^([a-zA-Z]):/ ]]; then - local rest="${p:2}" - printf '%s' "${BASH_REMATCH[1]^}:${rest,,}" - return + if [[ "$__hu_p" =~ ^/([a-zA-Z])/ || "$__hu_p" =~ ^([a-zA-Z]):/ ]]; then + local __hu_rest="${__hu_p:2}" + printf -v "$1" '%s' "${BASH_REMATCH[1]^}:${__hu_rest,,}" + return 0 fi ;; *) ;; # POSIX hosts: case-sensitive FS, no drive fold — pass through below esac - printf '%s' "$p" + printf -v "$1" '%s' "$__hu_p" +} + +hook::normalize_path() { + local __hu_n + hook::normalize_path_to __hu_n "$1" + printf '%s' "$__hu_n" } # Expand Windows 8.3 short-name components (KYLESE~1 → KyleSexton) on @@ -424,25 +439,31 @@ hook::normalize_path() { # class: cygpath ships with Git Bash (the documented Windows bash), so its # absence or failure keeps the resolver's answer unchanged — degrading to the # pre-expansion comparison, same doctrine as the resolver fallback below. -hook::expand_8dot3() { - local p="$1" +hook::expand_8dot3_to() { + local __hu_p="$2" case "${OSTYPE:-}" in msys* | cygwin* | win32) ;; *) - printf '%s' "$p" - return + printf -v "$1" '%s' "$__hu_p" + return 0 ;; esac - if [[ "$p" == *~* ]] && command -v cygpath >/dev/null 2>&1; then - local plain long - if plain=$(cygpath -m -- "$p" 2>/dev/null) && - long=$(cygpath -l -m -- "$p" 2>/dev/null) && - [[ -n "$long" && "$long" != "$plain" ]]; then - printf '%s' "$long" - return + if [[ "$__hu_p" == *~* ]] && command -v cygpath >/dev/null 2>&1; then + local __hu_plain __hu_long + if __hu_plain=$(cygpath -m -- "$__hu_p" 2>/dev/null) && + __hu_long=$(cygpath -l -m -- "$__hu_p" 2>/dev/null) && + [[ -n "$__hu_long" && "$__hu_long" != "$__hu_plain" ]]; then + printf -v "$1" '%s' "$__hu_long" + return 0 fi fi - printf '%s' "$p" + printf -v "$1" '%s' "$__hu_p" +} + +hook::expand_8dot3() { + local __hu_e + hook::expand_8dot3_to __hu_e "$1" + printf '%s' "$__hu_e" } # Canonicalize to a physical path — symlinks resolved, Windows 8.3 short names @@ -462,20 +483,148 @@ hook::expand_8dot3() { # expansion applies only on the resolver's success path: consumers that fail # closed on an unresolved signature must not see a form-converted path instead. # shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED -hook::physical_path() { - local resolved +hook::physical_path_to() { + local __hu_r HOOK_PHYSICAL_PATH_UNRESOLVED=0 - if resolved=$(realpath -- "$1" 2>/dev/null) || resolved=$(readlink -f -- "$1" 2>/dev/null); then - if [[ -n "$resolved" ]]; then - hook::expand_8dot3 "$resolved" + if __hu_r=$(realpath -- "$2" 2>/dev/null) || __hu_r=$(readlink -f -- "$2" 2>/dev/null); then + if [[ -n "$__hu_r" ]]; then + hook::expand_8dot3_to "$1" "$__hu_r" return 0 fi fi HOOK_PHYSICAL_PATH_UNRESOLVED=1 - printf '%s' "$1" + printf -v "$1" '%s' "$2" + return 1 +} + +# shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED +hook::physical_path() { + local __hu_v __hu_rc=0 + hook::physical_path_to __hu_v "$1" || __hu_rc=$? + printf '%s' "$__hu_v" + return "$__hu_rc" +} + +# --- Per-process physical-path cache -------------------------------------- +# The membership guard resolves the same few directories on every call: the +# project root and this host's temp roots. Their physical form does not change +# within one hook process, so each spelling is resolved once and remembered in +# three parallel indexed arrays (keys, values, resolver status). Plain arrays, +# not an associative array, so the cache runs on Bash 3.2. Only directories are +# meant to live here for a process lifetime; hook::read_file_path forgets the +# edited file's entry as soon as it has read it, so a later call in the same +# process sees the file as it is then. +_HOOK_PHYS_KEYS=() +_HOOK_PHYS_VALS=() +_HOOK_PHYS_RCS=() +_HOOK_PHYS_I=-1 + +# hook::_phys_cache_index : sets _HOOK_PHYS_I to the slot holding , +# returns 1 when it is not cached. A forgotten slot has an empty key, which no +# real path can match. +hook::_phys_cache_index() { + local __hu_i + [[ -n "$1" ]] || return 1 + for ((__hu_i = 0; __hu_i < ${#_HOOK_PHYS_KEYS[@]}; __hu_i++)); do + if [[ "${_HOOK_PHYS_KEYS[__hu_i]}" == "$1" ]]; then + _HOOK_PHYS_I=$__hu_i + return 0 + fi + done return 1 } +hook::_phys_cache_forget() { + hook::_phys_cache_index "$1" && _HOOK_PHYS_KEYS[_HOOK_PHYS_I]="" + return 0 +} + +# hook::_physical_cached_to : hook::physical_path_to through the +# cache. Same value, same return status, same HOOK_PHYSICAL_PATH_UNRESOLVED as +# the uncached call; a miss resolves the one path and stores it. +# shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED +hook::_physical_cached_to() { + if hook::_phys_cache_index "$2"; then + printf -v "$1" '%s' "${_HOOK_PHYS_VALS[_HOOK_PHYS_I]}" + HOOK_PHYSICAL_PATH_UNRESOLVED=${_HOOK_PHYS_RCS[_HOOK_PHYS_I]} + return "${_HOOK_PHYS_RCS[_HOOK_PHYS_I]}" + fi + local __hu_v __hu_rc=0 + hook::physical_path_to __hu_v "$2" || __hu_rc=$? + if [[ -n "$2" ]]; then + _HOOK_PHYS_KEYS+=("$2") + _HOOK_PHYS_VALS+=("$__hu_v") + _HOOK_PHYS_RCS+=("$__hu_rc") + fi + printf -v "$1" '%s' "$__hu_v" + return "$__hu_rc" +} + +# hook::_physical_prime ...: resolve every uncached path with ONE +# realpath process and store the answers. realpath resolves each argument on +# its own and prints one line per argument in argument order, so the batch +# answer for a path is the same string the per-path call returns; the 8.3 +# expansion is applied per answer exactly as hook::physical_path_to does. Any +# doubt (a path carrying a newline, a non-zero exit, a line count that does not +# match) stores nothing and leaves the per-path resolver to answer lazily, so +# the batch can only ever save work, never change an answer. readlink -f hosts +# (no realpath) skip the batch for the same reason. +hook::_physical_prime() { + local -a __hu_todo=() + local __hu_p __hu_out __hu_i __hu_seen="" __hu_v + for __hu_p in "$@"; do + [[ -n "$__hu_p" ]] || continue + [[ "$__hu_p" == *$'\n'* ]] && return 0 + case "$__hu_seen" in + *"|$__hu_p|"*) continue ;; + *) ;; # first sighting + esac + __hu_seen="$__hu_seen|$__hu_p|" + hook::_phys_cache_index "$__hu_p" && continue + __hu_todo+=("$__hu_p") + done + ((${#__hu_todo[@]} > 1)) || return 0 + command -v realpath >/dev/null 2>&1 || return 0 + # portability-ok: realpath with several operands is GNU and BSD alike; a host whose realpath rejects it fails the exit-status check and falls back per path + __hu_out=$(realpath -- "${__hu_todo[@]}" 2>/dev/null) || return 0 + local -a __hu_lines=() + local __hu_glob=0 + [[ $- == *f* ]] || __hu_glob=1 + set -f + local IFS=$'\n' + # shellcheck disable=SC2206 # splitting realpath's one-line-per-operand output is the intent + __hu_lines=($__hu_out) + ((__hu_glob)) && set +f + ((${#__hu_lines[@]} == ${#__hu_todo[@]})) || return 0 + for ((__hu_i = 0; __hu_i < ${#__hu_todo[@]}; __hu_i++)); do + [[ -n "${__hu_lines[__hu_i]}" ]] || return 0 + hook::expand_8dot3_to __hu_v "${__hu_lines[__hu_i]}" + _HOOK_PHYS_KEYS+=("${__hu_todo[__hu_i]}") + _HOOK_PHYS_VALS+=("$__hu_v") + _HOOK_PHYS_RCS+=(0) + done + return 0 +} + +# The temp-root candidates hook::under_temp_root compares against: the +# environment's own answer (TMPDIR/TMP/TEMP) plus the POSIX defaults, never a +# hardcoded platform assumption. Existing directories only, each spelling +# once, in _HOOK_TEMP_CANDS. +_HOOK_TEMP_CANDS=() +hook::_temp_root_candidates() { + local __hu_cand __hu_seen="" + _HOOK_TEMP_CANDS=() + for __hu_cand in "${TMPDIR:-}" "${TMP:-}" "${TEMP:-}" /tmp /var/tmp; do + [[ -n "$__hu_cand" && -d "$__hu_cand" ]] || continue + case "$__hu_seen" in + *"|$__hu_cand|"*) continue ;; + *) ;; # first sighting of this candidate + esac + __hu_seen="$__hu_seen|$__hu_cand|" + _HOOK_TEMP_CANDS+=("$__hu_cand") + done +} + # True when sits inside one of this host's temp trees. # Both arguments and candidates go through the same canonicalize+normalize # pipeline as the membership comparison, because the same directory has several @@ -483,21 +632,40 @@ hook::physical_path() { # of the identical directory, and `realpath` resolves the Windows form to a # drive path while leaving `/tmp` as `/tmp`. Neither form alone matches a # `file_path` that could arrive in either, so every candidate is compared and a -# match on any one is a match. Duplicates are resolved once. +# match on any one is a match. Duplicates are resolved once, and each +# candidate's physical form is remembered for the process (see the cache +# above), so a second call costs no resolver process. # -# Candidates are the environment's own answer (TMPDIR/TMP/TEMP) plus the POSIX -# defaults, never a hardcoded platform assumption. +# Raw-spelling shortcut: when the caller vouches that is a +# PHYSICAL path (_HOOK_UTR_TARGET_PHYSICAL=1, set only by hook::read_file_path +# after the resolver succeeded), a candidate whose raw normalized spelling is +# already a prefix of the target is a match without resolving it. A physical +# path contains no symlink component, so a candidate that spells one of its +# prefixes names a chain of real directories whose physical form is that same +# prefix; the resolver would answer the same. A candidate that does not match +# raw is still resolved, so a symlinked temp root (macOS /tmp) is found the +# way it always was. Without the vouch the shortcut is off and every candidate +# is resolved, exactly as before. Note that hook::read_file_path primes every +# candidate into the cache with one batched realpath before calling here, so +# on that path the shortcut skips a lookup, not a process; the batch is what +# saves the processes. A direct caller without the batch saves the resolver. # hook::under_temp_root "$norm_path" && ... +_HOOK_UTR_TARGET_PHYSICAL=0 hook::under_temp_root() { - local target="$1" cand norm seen="" - for cand in "${TMPDIR:-}" "${TMP:-}" "${TEMP:-}" /tmp /var/tmp; do - [[ -n "$cand" && -d "$cand" ]] || continue - case "$seen" in - *"|$cand|"*) continue ;; - *) ;; # first sighting of this candidate — resolve it below - esac - seen="$seen|$cand|" - norm=$(hook::normalize_path "$(hook::physical_path "$cand")") + local target="$1" cand norm phys + hook::_temp_root_candidates + for cand in ${_HOOK_TEMP_CANDS[@]+"${_HOOK_TEMP_CANDS[@]}"}; do + if ((_HOOK_UTR_TARGET_PHYSICAL)); then + hook::normalize_path_to norm "$cand" + if [[ "$norm" != / ]]; then + norm="${norm%/}" + if [[ -n "$norm" && ("$target" == "$norm" || "$target" == "$norm"/*) ]]; then + return 0 + fi + fi + fi + hook::_physical_cached_to phys "$cand" || : + hook::normalize_path_to norm "$phys" # The filesystem root as a temp candidate contains every absolute path; # trimming its only slash would empty the candidate and discard it. [[ "$norm" == / ]] && return 0 @@ -523,21 +691,421 @@ hook::in_git_working_tree() { ) >/dev/null 2>&1 } +# --- Builtin JSON helpers ---------------------------------------------------- +# hook::read_file_path and hook::emit_telemetry used to spend a jq process each +# on every hook run. On Windows Git Bash one spawn costs tens of milliseconds, +# so the helpers here handle the well-formed shapes those two functions see in +# practice with shell builtins and hand anything they cannot PROVE to the +# unchanged jq path. The contract is "the bytes jq would produce, or fall +# back", never "close enough". +# +# What the builtin paths rely on: the text is valid JSON. Every hook payload +# reaching hook::read_file_path has passed hook::buffer_stdin's jq validation, +# and every telemetry data object is built with jq. The helpers still verify +# the structure they walk (terminated strings, well-formed tokens, properly +# nested brackets, one root, no raw control bytes inside strings), but they +# are not a full JSON parser and do not claim to reject every malformed text. +# +# Why no scanning: on this repo's Windows hosts bash regex matching and the +# `%%`/`##` pattern operators cost about a microsecond per character, so a +# 60 KB Write payload would cost more than the jq process they replace. The +# only string operations used on the whole payload are literal-substring +# replacement, `[[ == *x* ]]` containment, IFS word splitting and offset +# slicing, all of which run at C speed. + +# hook::_json_split +# Split at every unescaped double quote into the global array +# _HOOK_JSON_PARTS: even indices hold the structural text between strings, odd +# indices hold string bodies. Before splitting, every `\\` and `\"` pair is +# replaced by `@@` (same length), so a quote that survives is a real string +# delimiter and a body's ORIGINAL bytes are ${text:offset:length}. Returns 1 +# when the text is too large, its quotes do not pair up, or it holds more +# strings than the callers' loops are meant to walk. +_HOOK_JSON_PARTS=() +hook::_json_split() { + local __hu_s="$1" __hu_t __hu_q __hu_n + ((${#__hu_s} <= 65536)) || return 1 + __hu_t=${__hu_s//"\\\\"/@@} + __hu_t=${__hu_t//"\\\""/@@} + __hu_q=${__hu_t//\"/} + __hu_n=$((${#__hu_t} - ${#__hu_q})) + ((__hu_n % 2 == 0)) || return 1 + ((__hu_n <= 800)) || return 1 + local __hu_glob=0 + [[ $- == *f* ]] || __hu_glob=1 + set -f + local IFS='"' + # shellcheck disable=SC2206 # splitting on IFS='"' is the whole point + _HOOK_JSON_PARTS=($__hu_t) + ((__hu_glob)) && set +f + ((${#_HOOK_JSON_PARTS[@]} % 2 == 1)) || return 1 + return 0 +} + +# hook::_json_skeleton +# Split (hook::_json_split), record each string body's byte offset in +# _HOOK_JSON_OFF, and build _HOOK_JSON_SK: the structural text with every +# string replaced by a numbered placeholder `"#"` and all JSON +# whitespace removed. The skeleton is small (no string bodies), so regex and +# per-token work on it is cheap. Returns 1 unless the text is exactly one JSON +# value by the JSON grammar (well-formed tokens, whitespace only between them, +# objects as key:value lists, arrays as value lists, brackets nested and +# closed once) whose strings carry no raw control byte, which jq rejects. +_HOOK_JSON_SK="" +_HOOK_JSON_OFF=() +hook::_json_skeleton() { + local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top + hook::_json_split "$__hu_s" || return 1 + __hu_n=${#_HOOK_JSON_PARTS[@]} + _HOOK_JSON_OFF=() + for ((__hu_i = 0; __hu_i < __hu_n; __hu_i++)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + if ((__hu_i % 2 == 0)); then + __hu_sk+=$__hu_part + else + _HOOK_JSON_OFF[__hu_i]=$__hu_off + [[ "$__hu_part" == *[[:cntrl:]]* ]] && return 1 + __hu_sk+="\"#$__hu_i\"" + fi + __hu_off=$((__hu_off + ${#__hu_part} + 1)) + done + # Tokenize the skeleton and run it through the JSON grammar. Each token is + # consumed by one anchored regex on the (small) remainder; whitespace is + # allowed only between tokens, so `tr ue` is two bad tokens, not `true`. + local __hu_re=$'^[ \t\n\r]*(\\{|\\}|\\[|\\]|,|:|"#[0-9]+"|-?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][-+]?[0-9]+)?|true|false|null)' + local __hu_ws=$'^[ \t\n\r]*$' + __hu_rest=$__hu_sk + while [[ -n "$__hu_rest" ]] && ! [[ "$__hu_rest" =~ $__hu_ws ]]; do + [[ "$__hu_rest" =~ $__hu_re ]] || return 1 + __hu_tok=${BASH_REMATCH[1]} + __hu_rest=${__hu_rest:${#BASH_REMATCH[0]}} + __hu_top=${__hu_stack:${#__hu_stack}-1:1} + case "$__hu_expect" in + value | value_or_end) + case "$__hu_tok" in + '{') + __hu_stack+='{' + __hu_expect=key_or_end + continue + ;; + '[') + __hu_stack+='[' + __hu_expect=value_or_end + continue + ;; + ']') + [[ "$__hu_expect" == value_or_end ]] || return 1 + ;; + '}' | ',' | ':') return 1 ;; + *) + # A scalar. Inside a container the next token is , or the close; + # at the top level nothing may follow. + if [[ -n "$__hu_stack" ]]; then __hu_expect=comma_or_end; else __hu_expect=end; fi + continue + ;; + esac + ;; + key_or_end | key) + case "$__hu_tok" in + \"#*) + __hu_expect='colon' + continue + ;; + '}') [[ "$__hu_expect" == key_or_end ]] || return 1 ;; + *) return 1 ;; + esac + ;; + colon) + [[ "$__hu_tok" == ':' ]] || return 1 + __hu_expect=value + continue + ;; + comma_or_end) + case "$__hu_tok" in + ',') + if [[ "$__hu_top" == '{' ]]; then __hu_expect=key; else __hu_expect=value; fi + continue + ;; + '}') [[ "$__hu_top" == '{' ]] || return 1 ;; + ']') [[ "$__hu_top" == '[' ]] || return 1 ;; + *) return 1 ;; + esac + ;; + *) return 1 ;; # `end`: a token after the root value closed + esac + # Reaching here means a container just closed. + [[ -n "$__hu_stack" ]] || return 1 + __hu_stack=${__hu_stack:0:${#__hu_stack}-1} + if [[ -n "$__hu_stack" ]]; then __hu_expect=comma_or_end; else __hu_expect=end; fi + done + [[ "$__hu_expect" == end ]] || return 1 + _HOOK_JSON_SK=${__hu_sk//[$' \t\n\r']/} + return 0 +} + +# hook::json_unescape_to +# Decode a JSON string body (the bytes between the quotes) into . Handles +# \" \\ \/ \b \f \n \r \t and \uXXXX for U+0001 to U+007F. Returns 1 on any +# other escape (a \u outside that range, U+0000 which bash cannot hold, or an +# invalid escape), leaving the caller to fall back to jq. +hook::json_unescape_to() { + local __hu_raw="$2" __hu_out="" __hu_pre __hu_c __hu_hex __hu_oct __hu_ch + while [[ "$__hu_raw" == *\\* ]]; do + __hu_pre=${__hu_raw%%\\*} + __hu_out+=$__hu_pre + __hu_raw=${__hu_raw:${#__hu_pre}+1} + __hu_c=${__hu_raw:0:1} + __hu_raw=${__hu_raw:1} + case "$__hu_c" in + '"') __hu_out+='"' ;; + "\\") __hu_out+="\\" ;; + '/') __hu_out+='/' ;; + b) __hu_out+=$'\b' ;; # portability-ok: bash ANSI-C backspace byte, not a GNU grep word boundary + f) __hu_out+=$'\f' ;; + n) __hu_out+=$'\n' ;; + r) __hu_out+=$'\r' ;; + t) __hu_out+=$'\t' ;; + u) + __hu_hex=${__hu_raw:0:4} + [[ "$__hu_hex" =~ ^00[0-7][0-9a-fA-F]$ ]] || return 1 + [[ "$__hu_hex" == 0000 ]] && return 1 + __hu_raw=${__hu_raw:4} + printf -v __hu_oct '%03o' "$((16#$__hu_hex))" + printf -v __hu_ch '%b' "\\0$__hu_oct" + __hu_out+=$__hu_ch + ;; + *) return 1 ;; + esac + done + __hu_out+=$__hu_raw + printf -v "$1" '%s' "$__hu_out" +} + +# hook::_fast_file_path_to +# The builtin answer to `jq -r '(.tool_input.file_path // empty) | +# gsub("\r";"")'` followed by the `$(...)` capture. Returns +# 0 proven: holds exactly what jq would have printed +# 1 proven absent: jq would have printed nothing (the guard skips) +# 2 not proven: run jq +# Proof, on top of hook::_json_skeleton's structural checks: the root is an +# object; exactly one string in the whole payload decodes to `tool_input` and +# exactly one to `file_path` (so neither a duplicate key nor a same-named key +# in another object can change jq's answer); `tool_input` is a key of the root +# (depth 1, key position) whose value is a flat object (no nested object or +# array, else jq); and inside it `file_path` is a key with a plain string +# value. A payload where `file_path` names a value or sits in another object +# has no `.tool_input.file_path`, which jq reports as nothing. Key strings are +# compared AFTER decoding their escapes, so a key spelled with \u escapes is +# still recognized; a body longer than any escaped spelling of either name is +# skipped without decoding. +hook::_fast_file_path_to() { + local __hu_s="$2" __hu_i __hu_n __hu_part __hu_body __hu_ti=-1 __hu_fp=-1 __hu_m __hu_raw __hu_pre __hu_re __hu_file + hook::_json_skeleton "$__hu_s" || return 2 + [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 + __hu_n=${#_HOOK_JSON_PARTS[@]} + for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + ((${#__hu_part} <= 60)) || continue + __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} + if [[ "$__hu_body" == *\\* ]]; then + hook::json_unescape_to __hu_body "$__hu_body" || continue + fi + case "$__hu_body" in + tool_input) + ((__hu_ti < 0)) || return 2 + __hu_ti=$__hu_i + ;; + file_path) + ((__hu_fp < 0)) || return 2 + __hu_fp=$__hu_i + ;; + *) ;; + esac + done + ((__hu_fp >= 0)) || return 1 + ((__hu_ti >= 0)) || return 1 + __hu_re="(^|[{,])\"#$__hu_ti\":\\{([^][{}]*)\\}" + if [[ "$_HOOK_JSON_SK" =~ $__hu_re ]]; then + __hu_body=${BASH_REMATCH[2]} + __hu_pre=${_HOOK_JSON_SK%%\"#"$__hu_ti"\"*} + # Depth of the key: opening brackets minus closing ones before it must be + # exactly one, i.e. a direct member of the root object. + local __hu_o1=${__hu_pre//\{/} __hu_o2=${__hu_pre//\[/} __hu_c1=${__hu_pre//\}/} __hu_c2=${__hu_pre//\]/} + local __hu_depth=$(((${#__hu_pre} - ${#__hu_o1}) + (${#__hu_pre} - ${#__hu_o2}) - (${#__hu_pre} - ${#__hu_c1}) - (${#__hu_pre} - ${#__hu_c2}))) + ((__hu_depth == 1)) || return 2 + else + return 2 + fi + __hu_re="(^|,)\"#$__hu_fp\":\"#([0-9]+)\"(,|$)" + if [[ "$__hu_body" =~ $__hu_re ]]; then + __hu_m=${BASH_REMATCH[2]} + elif [[ "$__hu_body" == *\"#$__hu_fp\"* ]]; then + return 2 + else + return 1 + fi + __hu_raw=${__hu_s:${_HOOK_JSON_OFF[__hu_m]}:${#_HOOK_JSON_PARTS[__hu_m]}} + if [[ "$__hu_raw" == *\\* ]]; then + hook::json_unescape_to __hu_file "$__hu_raw" || return 2 + else + __hu_file=$__hu_raw + fi + __hu_file=${__hu_file//$'\r'/} + while [[ "$__hu_file" == *$'\n' ]]; do + __hu_file=${__hu_file%$'\n'} + done + printf -v "$1" '%s' "$__hu_file" + return 0 +} + +# hook::dirname_to : dirname with builtins for the resolver's +# answer. Strips the last segment; a bare name lives in `.`, a root-level file +# in `/`. The path comes from realpath, so the trailing-slash and doubled-slash +# spellings dirname also collapses never occur here. +hook::dirname_to() { + local __hu_d="${2%/*}" + [[ "$__hu_d" == "$2" ]] && __hu_d=. + [[ -n "$__hu_d" ]] || __hu_d=/ + printf -v "$1" '%s' "$__hu_d" +} + +# Print the arguments joined by NUL bytes, no trailing NUL: the byte stream +# hook::read_file_path read from stdin, rebuilt for the jq fallback. +hook::_print_nul_joined() { + local __hu_first=1 __hu_c + for __hu_c in "$@"; do + ((__hu_first)) || printf '\0' + __hu_first=0 + printf '%s' "$__hu_c" + done +} + +# hook::json_compact_to +# The bytes `jq -c .` prints for , when that can be proven with +# builtins: JSON whitespace between tokens removed, everything else verbatim. +# jq's own output, compact or pretty, satisfies the proof, which is what every +# telemetry data builder in this marketplace produces (jq -c, jq -n, or a +# compact literal). Returns 1 when the text is not a single object, or a +# string carries something jq would re-encode (a \u or \/ escape, an escape +# outside the JSON set, a raw control byte), or the structure fails +# hook::_json_skeleton; the caller then runs jq. +hook::json_compact_to() { + local __hu_s="$2" __hu_out="" __hu_i __hu_part __hu_n + hook::_json_skeleton "$__hu_s" || return 1 + [[ "$_HOOK_JSON_SK" == \{*\} ]] || return 1 + # Numbers: only short integer literals are passed through. A fraction, an + # exponent or an integer beyond 15 digits is rendered differently by + # different jq releases (1.6 canonicalizes, 1.7+ preserves the literal), so + # the bytes cannot be proven; jq decides. In the skeleton every string is a + # `"#N"` placeholder and letters occur only in true/false/null, so a digit + # followed by `.`, `e` or `E` can only be part of a number. + local __hu_num='[0-9][.eE]|[0-9]{16}' + [[ "$_HOOK_JSON_SK" =~ $__hu_num ]] && return 1 + __hu_n=${#_HOOK_JSON_PARTS[@]} + for ((__hu_i = 0; __hu_i < __hu_n; __hu_i++)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + if ((__hu_i % 2 == 0)); then + __hu_out+=${__hu_part//[$' \t\n\r']/} + else + if [[ "$__hu_part" == *\\* ]]; then + [[ "$__hu_part" == *'\u'* || "$__hu_part" == *'\/'* ]] && return 1 + [[ "$__hu_part" == *\\[!bfnrt]* ]] && return 1 + fi + __hu_out+="\"${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}}\"" + fi + done + printf -v "$1" '%s' "$__hu_out" +} + +# hook::json_escape_jq_to / hook::json_escape_jq +# Escape exactly as jq serializes a string value (without the +# quotes): backslash and double quote, the five short forms \b \f \n \r \t, +# every other byte from 0x01 to 0x1f and 0x7f as lowercase \u00xx, and +# everything else, including non-ASCII, verbatim. This is the escaper the +# telemetry envelope needs for byte parity with the jq envelope it replaced; +# hook::json_escape above is a different tool (it DROPS the residual control +# bytes, which is right for a notice and wrong for parity) and is unchanged. +hook::json_escape_jq_to() { + local __hu_s="$2" __hu_i __hu_c __hu_oct __hu_hex + __hu_s="${__hu_s//\\/\\\\}" + __hu_s="${__hu_s//\"/\\\"}" + if [[ "$__hu_s" == *[[:cntrl:]]* ]]; then + __hu_s="${__hu_s//$'\n'/\\n}" + __hu_s="${__hu_s//$'\r'/\\r}" + __hu_s="${__hu_s//$'\t'/\\t}" + __hu_s="${__hu_s//$'\b'/\\b}" # portability-ok: bash ANSI-C backspace byte and the JSON \b escape, not a GNU grep word boundary + __hu_s="${__hu_s//$'\f'/\\f}" + for __hu_i in 1 2 3 4 5 6 7 11 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 127; do + printf -v __hu_oct '%03o' "$__hu_i" + printf -v __hu_c '%b' "\\0$__hu_oct" + [[ "$__hu_s" == *"$__hu_c"* ]] || continue + printf -v __hu_hex '%04x' "$__hu_i" + __hu_s="${__hu_s//"$__hu_c"/\\u$__hu_hex}" + done + fi + printf -v "$1" '%s' "$__hu_s" +} + +hook::json_escape_jq() { + local __hu_e + hook::json_escape_jq_to __hu_e "$1" + printf '%s' "$__hu_e" +} + # Parse file_path from PostToolUse JSON on stdin; validate existence and (when # CLAUDE_PROJECT_DIR is set) project membership. Both sides of the membership # comparison are canonicalized (symlinks resolved) first, so neither an # escaping symlink nor a project root reached via a symlinked path (e.g. # macOS /tmp) skews the verdict. Outputs the path on success. Returns 1 to skip. # FILE=$(hook::read_file_path) || exit 0 +# +# jq-free on the common shape. The payload is read into the shell and +# hook::_fast_file_path_to takes `.tool_input.file_path` with builtins when it +# can PROVE the answer jq would give (see that helper for what "prove" means); +# every other payload goes to the unchanged jq filter. The path comparison then +# resolves the file, the project root and the temp roots with one realpath +# process instead of one each (hook::_physical_prime), and remembers the +# directories for the process. Same verdict, same emitted path, fewer +# processes: on Windows Git Bash each spawn costs tens of milliseconds and this +# guard runs on every Write and Edit. hook::read_file_path() { - local file - file=$(jq -r '(.tool_input.file_path // empty) | gsub("\r";"")' 2>/dev/null) + local -a chunks=() + local chunk file="" mode=2 + # Builtin read to NUL or EOF. A NUL splits the payload into several chunks; + # the fast path only takes a single-chunk (NUL-free) payload, and the jq + # fallback is fed the chunks NUL-joined, so it sees the bytes jq used to + # read straight from stdin. + while :; do + chunk="" + if IFS= read -r -d '' chunk; then + chunks+=("$chunk") + continue + fi + chunks+=("$chunk") + break + done + if ((${#chunks[@]} == 1)); then + mode=0 + hook::_fast_file_path_to file "${chunks[0]}" || mode=$? + fi + case "$mode" in + 0) ;; # proven: `file` holds jq's answer + 1) return 1 ;; # proven absent + *) + file=$(hook::_print_nul_joined "${chunks[@]}" | jq -r '(.tool_input.file_path // empty) | gsub("\r";"")' 2>/dev/null) + ;; + esac [[ -n "$file" ]] || return 1 [[ -f "$file" ]] || return 1 if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then - local norm_file norm_project - norm_file=$(hook::normalize_path "$(hook::physical_path "$file")") - norm_project=$(hook::normalize_path "$(hook::physical_path "${CLAUDE_PROJECT_DIR}")") + local norm_file norm_project phys_file phys_project file_resolved=0 project_resolved=0 + hook::_temp_root_candidates + hook::_physical_prime "$file" "${CLAUDE_PROJECT_DIR}" ${_HOOK_TEMP_CANDS[@]+"${_HOOK_TEMP_CANDS[@]}"} + hook::_physical_cached_to phys_file "$file" && file_resolved=1 + hook::_phys_cache_forget "$file" + hook::_physical_cached_to phys_project "${CLAUDE_PROJECT_DIR}" && project_resolved=1 + hook::normalize_path_to norm_file "$phys_file" + hook::normalize_path_to norm_project "$phys_project" norm_project="${norm_project%/}" # Anchor on a path-segment boundary: accept the project root itself or a # child under it, but not a sibling whose name merely shares the prefix @@ -558,19 +1126,28 @@ hook::read_file_path() { # built by `mktemp -d`, which is how this repo's own hook suites run), so # the branch must not fire. Only a temp-tree file reached from a project # root OUTSIDE the temp tree is scratch. - if hook::under_temp_root "$norm_file" && ! hook::under_temp_root "$norm_project"; then - return 1 + local file_in_temp=0 + _HOOK_UTR_TARGET_PHYSICAL=$file_resolved + hook::under_temp_root "$norm_file" && file_in_temp=1 + _HOOK_UTR_TARGET_PHYSICAL=0 + if ((file_in_temp)); then + local project_in_temp=0 + _HOOK_UTR_TARGET_PHYSICAL=$project_resolved + hook::under_temp_root "$norm_project" && project_in_temp=1 + _HOOK_UTR_TARGET_PHYSICAL=0 + ((project_in_temp)) || return 1 fi elif command -v git >/dev/null 2>&1; then # When CLAUDE_PROJECT_DIR is unset, scope to git-working-tree membership so # scratch files outside any repository are not mutated by formatter hooks # (#1091 / #972). - local file_physical - file_physical=$(hook::physical_path "$file") + local file_physical file_dir + hook::physical_path_to file_physical "$file" || : if [[ -L "$file" && "$file_physical" == "$file" ]]; then return 1 fi - if ! hook::in_git_working_tree "$(dirname "$file_physical")"; then + hook::dirname_to file_dir "$file_physical" + if ! hook::in_git_working_tree "$file_dir"; then return 1 fi else @@ -1228,7 +1805,19 @@ hook::telemetry_enabled() { # Fire-and-forget: sink is dispatched in the background; the hook never waits # on it and its failure never affects the hook's own exit code or stdout. # Opt-in guard: HOOK_TELEMETRY_SINK unset or empty → return 0 immediately. -# Fail-open: jq absent → return 0 immediately. +# +# The envelope is assembled with shell builtins (hook::json_escape_jq_to for +# the string fields, hook::json_compact_to for the caller's data object) as +# ONE compact line, `jq -c` style: the same document, field order and +# escapes the jq filter below produces, with the data object compacted +# exactly as jq compacts its own output. That path needs no jq, no temp file +# and no subprocess. When the data object cannot be proven jq-idempotent (see +# hook::json_compact_to), the jq path runs, now with -c so both paths write +# the same bytes, and there jq's absence is still fail-open: return 0, no +# envelope. Before this the envelope was jq's default pretty-printed form +# (several lines, and CRLF line endings with a Windows jq); the sink contract +# was always "one JSON document on stdin", and a compact line is also valid +# JSONL for a sink that appends raw envelopes. # # Usage: # hook::emit_telemetry [repo_root] @@ -1251,7 +1840,6 @@ hook::telemetry_enabled() { # NEVER writes to fd1 (the hook's stdout / additionalContext channel). hook::emit_telemetry() { [[ -n "${HOOK_TELEMETRY_SINK:-}" ]] || return 0 - command -v jq >/dev/null 2>&1 || return 0 local hook_id="$1" local hook_event="$2" @@ -1272,35 +1860,59 @@ hook::emit_telemetry() { local e_s="${now%[.,]*}" e_f="${now#*[.,]}" local duration_ms=$(((e_s * 1000000 + 10#$e_f - s_s * 1000000 - 10#$s_f) / 1000)) - # True UTC timestamp (TZ= prefix overrides LC_ALL / local TZ; the Z is not a lie). - local timestamp - timestamp=$(TZ=UTC printf '%(%Y-%m-%dT%H:%M:%SZ)T' -1) - - # Build the envelope. The `data` object is written to a temp file, not passed - # as --argjson, so payloads larger than the Windows 32767-character command-line - # cap are not dropped before jq runs (#1595). /dev/stdin is not used: jq's - # --slurpfile there is not portable on Windows. Redirect jq stderr to - # /dev/null; output goes to a local variable — never to fd1. - local envelope data_file - data_file=$(mktemp) || return 0 - printf '%s' "$data_json" >"$data_file" || { - rm -f "$data_file" - return 0 - } - envelope=$(jq -n \ - --arg schema_version "1.0" \ - --arg timestamp "$timestamp" \ - --arg hook "$hook_id" \ - --arg hook_event "$hook_event" \ - --arg status "$status" \ - --argjson duration_ms "$duration_ms" \ - --slurpfile data "$data_file" \ - '{schema_version:$schema_version,timestamp:$timestamp,hook:$hook,hook_event:$hook_event,status:$status,duration_ms:$duration_ms,data:($data[0] // {})}' \ - 2>/dev/null) || { + # True UTC timestamp (TZ= prefix overrides LC_ALL / local TZ; the Z is not a + # lie). The %()T printf format is Bash 4.2+, and printf -v keeps it in the + # shell (a `$(...)` capture is a fork); an older bash, or a printf that + # binds nothing, falls back to date -u. + local timestamp="" + TZ=UTC printf -v timestamp '%(%Y-%m-%dT%H:%M:%SZ)T' -1 2>/dev/null || timestamp="" + [[ -n "$timestamp" ]] || timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null) || timestamp="" + + # Trailing whitespace after the object (a newline a caller left on the + # value) does not change what jq parses, so it does not block the builtin + # path either. + local envelope="" data="" data_trim="$data_json" + while [[ "$data_trim" == *[$' \t\r\n'] ]]; do + data_trim="${data_trim%?}" + done + if [[ "$data_trim" == \{*\} ]] && hook::json_compact_to data "$data_trim"; then + # Builtin envelope: the same document the jq filter below produces, field + # for field, with jq's string escaping and jq's compact data. + local e_timestamp e_hook e_event e_status + hook::json_escape_jq_to e_timestamp "$timestamp" + hook::json_escape_jq_to e_hook "$hook_id" + hook::json_escape_jq_to e_event "$hook_event" + hook::json_escape_jq_to e_status "$status" + envelope='{"schema_version":"1.0","timestamp":"'"$e_timestamp"'","hook":"'"$e_hook"'","hook_event":"'"$e_event"'","status":"'"$e_status"'","duration_ms":'"$duration_ms"',"data":'"$data"'}' + else + # jq path, for a data object the builtin compactor cannot prove. Fail-open: + # jq absent → return 0. The `data` object is written to a temp file, not + # passed as --argjson, so payloads larger than the Windows 32767-character + # command-line cap are not dropped before jq runs (#1595). /dev/stdin is + # not used: jq's --slurpfile there is not portable on Windows. Redirect jq + # stderr to /dev/null; output goes to a local variable — never to fd1. + command -v jq >/dev/null 2>&1 || return 0 + local data_file + data_file=$(mktemp) || return 0 + printf '%s' "$data_json" >"$data_file" || { + rm -f "$data_file" + return 0 + } + envelope=$(jq -nc \ + --arg schema_version "1.0" \ + --arg timestamp "$timestamp" \ + --arg hook "$hook_id" \ + --arg hook_event "$hook_event" \ + --arg status "$status" \ + --argjson duration_ms "$duration_ms" \ + --slurpfile data "$data_file" \ + '{schema_version:$schema_version,timestamp:$timestamp,hook:$hook,hook_event:$hook_event,status:$status,duration_ms:$duration_ms,data:($data[0] // {})}' \ + 2>/dev/null) || { + rm -f "$data_file" + return 0 + } rm -f "$data_file" - return 0 - } - rm -f "$data_file" + fi # Resolve the sink path. A relative HOOK_TELEMETRY_SINK is joined onto the # consuming repo root (portable, tracked wiring); absolute is used as-is. A diff --git a/plugins/eol-normalizer/.claude-plugin/plugin.json b/plugins/eol-normalizer/.claude-plugin/plugin.json index 8d98c2ed85..c27a275963 100644 --- a/plugins/eol-normalizer/.claude-plugin/plugin.json +++ b/plugins/eol-normalizer/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "eol-normalizer", - "version": "0.6.28", + "version": "0.6.29", "description": "Normalize a written file's working-tree line endings to its .gitattributes eol value on edit \u2014 symmetric CRLF/LF driven by git check-attr, advisory and never blocking.", "author": { "name": "Melodic Software", diff --git a/plugins/eol-normalizer/CHANGELOG.md b/plugins/eol-normalizer/CHANGELOG.md index 7fd30abb3a..5747087108 100644 --- a/plugins/eol-normalizer/CHANGELOG.md +++ b/plugins/eol-normalizer/CHANGELOG.md @@ -3,6 +3,22 @@ All notable changes to the `eol-normalizer` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.6.29] + +### Changed + +- **Vendored `hook-utils.sh` builds the telemetry envelope and reads `file_path` + with shell builtins.** `hook::emit_telemetry` no longer spawns two jq + processes, a mktemp and an rm per run: the envelope is assembled in the shell + as one compact line (the same document jq produced, now `jq -c` shaped), with + jq kept only as the fallback for a data object the builtin compactor cannot + prove. `hook::read_file_path` takes `.tool_input.file_path` without jq on the + well-formed payload shape and resolves the file, project root and temp roots + with one batched `realpath` instead of one process each. Same verdicts, same + emitted path, same sink record; phase 4b of the hook-performance program + (#3623). The copy is bumped because `scripts/sync-hook-utils.sh` keeps every + carrying plugin byte-identical. + ## [0.6.28] ### Changed diff --git a/plugins/eol-normalizer/hooks/hook-utils.sh b/plugins/eol-normalizer/hooks/hook-utils.sh index 9818a6a73d..18e4dd5745 100644 --- a/plugins/eol-normalizer/hooks/hook-utils.sh +++ b/plugins/eol-normalizer/hooks/hook-utils.sh @@ -390,19 +390,34 @@ hook::require_jq_blocking() { # collapse with `/c/repo` and the membership guard would admit a sibling # outside CLAUDE_PROJECT_DIR. The result is used ONLY for comparison; the # emitted path is always the caller's original. -hook::normalize_path() { - local p="${1//\\//}" +# +# Two spellings of every path helper below: `hook::` prints the answer +# (the public, subshell-friendly form every caller already uses) and +# `hook::_to ` stores it in the caller's variable instead. +# The `_to` form is what the hot path uses: a `$(...)` capture is a fork, and +# on Windows Git Bash a fork costs milliseconds, so the file_path guard that +# ran a dozen of them per hook now runs none it does not need. The `_to` +# helpers keep their locals under a `__hu_` prefix so the caller's variable +# name cannot collide with them. +hook::normalize_path_to() { + local __hu_p="${2//\\//}" case "${OSTYPE:-}" in msys* | cygwin* | win32) - if [[ "$p" =~ ^/([a-zA-Z])/ || "$p" =~ ^([a-zA-Z]):/ ]]; then - local rest="${p:2}" - printf '%s' "${BASH_REMATCH[1]^}:${rest,,}" - return + if [[ "$__hu_p" =~ ^/([a-zA-Z])/ || "$__hu_p" =~ ^([a-zA-Z]):/ ]]; then + local __hu_rest="${__hu_p:2}" + printf -v "$1" '%s' "${BASH_REMATCH[1]^}:${__hu_rest,,}" + return 0 fi ;; *) ;; # POSIX hosts: case-sensitive FS, no drive fold — pass through below esac - printf '%s' "$p" + printf -v "$1" '%s' "$__hu_p" +} + +hook::normalize_path() { + local __hu_n + hook::normalize_path_to __hu_n "$1" + printf '%s' "$__hu_n" } # Expand Windows 8.3 short-name components (KYLESE~1 → KyleSexton) on @@ -424,25 +439,31 @@ hook::normalize_path() { # class: cygpath ships with Git Bash (the documented Windows bash), so its # absence or failure keeps the resolver's answer unchanged — degrading to the # pre-expansion comparison, same doctrine as the resolver fallback below. -hook::expand_8dot3() { - local p="$1" +hook::expand_8dot3_to() { + local __hu_p="$2" case "${OSTYPE:-}" in msys* | cygwin* | win32) ;; *) - printf '%s' "$p" - return + printf -v "$1" '%s' "$__hu_p" + return 0 ;; esac - if [[ "$p" == *~* ]] && command -v cygpath >/dev/null 2>&1; then - local plain long - if plain=$(cygpath -m -- "$p" 2>/dev/null) && - long=$(cygpath -l -m -- "$p" 2>/dev/null) && - [[ -n "$long" && "$long" != "$plain" ]]; then - printf '%s' "$long" - return + if [[ "$__hu_p" == *~* ]] && command -v cygpath >/dev/null 2>&1; then + local __hu_plain __hu_long + if __hu_plain=$(cygpath -m -- "$__hu_p" 2>/dev/null) && + __hu_long=$(cygpath -l -m -- "$__hu_p" 2>/dev/null) && + [[ -n "$__hu_long" && "$__hu_long" != "$__hu_plain" ]]; then + printf -v "$1" '%s' "$__hu_long" + return 0 fi fi - printf '%s' "$p" + printf -v "$1" '%s' "$__hu_p" +} + +hook::expand_8dot3() { + local __hu_e + hook::expand_8dot3_to __hu_e "$1" + printf '%s' "$__hu_e" } # Canonicalize to a physical path — symlinks resolved, Windows 8.3 short names @@ -462,20 +483,148 @@ hook::expand_8dot3() { # expansion applies only on the resolver's success path: consumers that fail # closed on an unresolved signature must not see a form-converted path instead. # shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED -hook::physical_path() { - local resolved +hook::physical_path_to() { + local __hu_r HOOK_PHYSICAL_PATH_UNRESOLVED=0 - if resolved=$(realpath -- "$1" 2>/dev/null) || resolved=$(readlink -f -- "$1" 2>/dev/null); then - if [[ -n "$resolved" ]]; then - hook::expand_8dot3 "$resolved" + if __hu_r=$(realpath -- "$2" 2>/dev/null) || __hu_r=$(readlink -f -- "$2" 2>/dev/null); then + if [[ -n "$__hu_r" ]]; then + hook::expand_8dot3_to "$1" "$__hu_r" return 0 fi fi HOOK_PHYSICAL_PATH_UNRESOLVED=1 - printf '%s' "$1" + printf -v "$1" '%s' "$2" + return 1 +} + +# shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED +hook::physical_path() { + local __hu_v __hu_rc=0 + hook::physical_path_to __hu_v "$1" || __hu_rc=$? + printf '%s' "$__hu_v" + return "$__hu_rc" +} + +# --- Per-process physical-path cache -------------------------------------- +# The membership guard resolves the same few directories on every call: the +# project root and this host's temp roots. Their physical form does not change +# within one hook process, so each spelling is resolved once and remembered in +# three parallel indexed arrays (keys, values, resolver status). Plain arrays, +# not an associative array, so the cache runs on Bash 3.2. Only directories are +# meant to live here for a process lifetime; hook::read_file_path forgets the +# edited file's entry as soon as it has read it, so a later call in the same +# process sees the file as it is then. +_HOOK_PHYS_KEYS=() +_HOOK_PHYS_VALS=() +_HOOK_PHYS_RCS=() +_HOOK_PHYS_I=-1 + +# hook::_phys_cache_index : sets _HOOK_PHYS_I to the slot holding , +# returns 1 when it is not cached. A forgotten slot has an empty key, which no +# real path can match. +hook::_phys_cache_index() { + local __hu_i + [[ -n "$1" ]] || return 1 + for ((__hu_i = 0; __hu_i < ${#_HOOK_PHYS_KEYS[@]}; __hu_i++)); do + if [[ "${_HOOK_PHYS_KEYS[__hu_i]}" == "$1" ]]; then + _HOOK_PHYS_I=$__hu_i + return 0 + fi + done return 1 } +hook::_phys_cache_forget() { + hook::_phys_cache_index "$1" && _HOOK_PHYS_KEYS[_HOOK_PHYS_I]="" + return 0 +} + +# hook::_physical_cached_to : hook::physical_path_to through the +# cache. Same value, same return status, same HOOK_PHYSICAL_PATH_UNRESOLVED as +# the uncached call; a miss resolves the one path and stores it. +# shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED +hook::_physical_cached_to() { + if hook::_phys_cache_index "$2"; then + printf -v "$1" '%s' "${_HOOK_PHYS_VALS[_HOOK_PHYS_I]}" + HOOK_PHYSICAL_PATH_UNRESOLVED=${_HOOK_PHYS_RCS[_HOOK_PHYS_I]} + return "${_HOOK_PHYS_RCS[_HOOK_PHYS_I]}" + fi + local __hu_v __hu_rc=0 + hook::physical_path_to __hu_v "$2" || __hu_rc=$? + if [[ -n "$2" ]]; then + _HOOK_PHYS_KEYS+=("$2") + _HOOK_PHYS_VALS+=("$__hu_v") + _HOOK_PHYS_RCS+=("$__hu_rc") + fi + printf -v "$1" '%s' "$__hu_v" + return "$__hu_rc" +} + +# hook::_physical_prime ...: resolve every uncached path with ONE +# realpath process and store the answers. realpath resolves each argument on +# its own and prints one line per argument in argument order, so the batch +# answer for a path is the same string the per-path call returns; the 8.3 +# expansion is applied per answer exactly as hook::physical_path_to does. Any +# doubt (a path carrying a newline, a non-zero exit, a line count that does not +# match) stores nothing and leaves the per-path resolver to answer lazily, so +# the batch can only ever save work, never change an answer. readlink -f hosts +# (no realpath) skip the batch for the same reason. +hook::_physical_prime() { + local -a __hu_todo=() + local __hu_p __hu_out __hu_i __hu_seen="" __hu_v + for __hu_p in "$@"; do + [[ -n "$__hu_p" ]] || continue + [[ "$__hu_p" == *$'\n'* ]] && return 0 + case "$__hu_seen" in + *"|$__hu_p|"*) continue ;; + *) ;; # first sighting + esac + __hu_seen="$__hu_seen|$__hu_p|" + hook::_phys_cache_index "$__hu_p" && continue + __hu_todo+=("$__hu_p") + done + ((${#__hu_todo[@]} > 1)) || return 0 + command -v realpath >/dev/null 2>&1 || return 0 + # portability-ok: realpath with several operands is GNU and BSD alike; a host whose realpath rejects it fails the exit-status check and falls back per path + __hu_out=$(realpath -- "${__hu_todo[@]}" 2>/dev/null) || return 0 + local -a __hu_lines=() + local __hu_glob=0 + [[ $- == *f* ]] || __hu_glob=1 + set -f + local IFS=$'\n' + # shellcheck disable=SC2206 # splitting realpath's one-line-per-operand output is the intent + __hu_lines=($__hu_out) + ((__hu_glob)) && set +f + ((${#__hu_lines[@]} == ${#__hu_todo[@]})) || return 0 + for ((__hu_i = 0; __hu_i < ${#__hu_todo[@]}; __hu_i++)); do + [[ -n "${__hu_lines[__hu_i]}" ]] || return 0 + hook::expand_8dot3_to __hu_v "${__hu_lines[__hu_i]}" + _HOOK_PHYS_KEYS+=("${__hu_todo[__hu_i]}") + _HOOK_PHYS_VALS+=("$__hu_v") + _HOOK_PHYS_RCS+=(0) + done + return 0 +} + +# The temp-root candidates hook::under_temp_root compares against: the +# environment's own answer (TMPDIR/TMP/TEMP) plus the POSIX defaults, never a +# hardcoded platform assumption. Existing directories only, each spelling +# once, in _HOOK_TEMP_CANDS. +_HOOK_TEMP_CANDS=() +hook::_temp_root_candidates() { + local __hu_cand __hu_seen="" + _HOOK_TEMP_CANDS=() + for __hu_cand in "${TMPDIR:-}" "${TMP:-}" "${TEMP:-}" /tmp /var/tmp; do + [[ -n "$__hu_cand" && -d "$__hu_cand" ]] || continue + case "$__hu_seen" in + *"|$__hu_cand|"*) continue ;; + *) ;; # first sighting of this candidate + esac + __hu_seen="$__hu_seen|$__hu_cand|" + _HOOK_TEMP_CANDS+=("$__hu_cand") + done +} + # True when sits inside one of this host's temp trees. # Both arguments and candidates go through the same canonicalize+normalize # pipeline as the membership comparison, because the same directory has several @@ -483,21 +632,40 @@ hook::physical_path() { # of the identical directory, and `realpath` resolves the Windows form to a # drive path while leaving `/tmp` as `/tmp`. Neither form alone matches a # `file_path` that could arrive in either, so every candidate is compared and a -# match on any one is a match. Duplicates are resolved once. +# match on any one is a match. Duplicates are resolved once, and each +# candidate's physical form is remembered for the process (see the cache +# above), so a second call costs no resolver process. # -# Candidates are the environment's own answer (TMPDIR/TMP/TEMP) plus the POSIX -# defaults, never a hardcoded platform assumption. +# Raw-spelling shortcut: when the caller vouches that is a +# PHYSICAL path (_HOOK_UTR_TARGET_PHYSICAL=1, set only by hook::read_file_path +# after the resolver succeeded), a candidate whose raw normalized spelling is +# already a prefix of the target is a match without resolving it. A physical +# path contains no symlink component, so a candidate that spells one of its +# prefixes names a chain of real directories whose physical form is that same +# prefix; the resolver would answer the same. A candidate that does not match +# raw is still resolved, so a symlinked temp root (macOS /tmp) is found the +# way it always was. Without the vouch the shortcut is off and every candidate +# is resolved, exactly as before. Note that hook::read_file_path primes every +# candidate into the cache with one batched realpath before calling here, so +# on that path the shortcut skips a lookup, not a process; the batch is what +# saves the processes. A direct caller without the batch saves the resolver. # hook::under_temp_root "$norm_path" && ... +_HOOK_UTR_TARGET_PHYSICAL=0 hook::under_temp_root() { - local target="$1" cand norm seen="" - for cand in "${TMPDIR:-}" "${TMP:-}" "${TEMP:-}" /tmp /var/tmp; do - [[ -n "$cand" && -d "$cand" ]] || continue - case "$seen" in - *"|$cand|"*) continue ;; - *) ;; # first sighting of this candidate — resolve it below - esac - seen="$seen|$cand|" - norm=$(hook::normalize_path "$(hook::physical_path "$cand")") + local target="$1" cand norm phys + hook::_temp_root_candidates + for cand in ${_HOOK_TEMP_CANDS[@]+"${_HOOK_TEMP_CANDS[@]}"}; do + if ((_HOOK_UTR_TARGET_PHYSICAL)); then + hook::normalize_path_to norm "$cand" + if [[ "$norm" != / ]]; then + norm="${norm%/}" + if [[ -n "$norm" && ("$target" == "$norm" || "$target" == "$norm"/*) ]]; then + return 0 + fi + fi + fi + hook::_physical_cached_to phys "$cand" || : + hook::normalize_path_to norm "$phys" # The filesystem root as a temp candidate contains every absolute path; # trimming its only slash would empty the candidate and discard it. [[ "$norm" == / ]] && return 0 @@ -523,21 +691,421 @@ hook::in_git_working_tree() { ) >/dev/null 2>&1 } +# --- Builtin JSON helpers ---------------------------------------------------- +# hook::read_file_path and hook::emit_telemetry used to spend a jq process each +# on every hook run. On Windows Git Bash one spawn costs tens of milliseconds, +# so the helpers here handle the well-formed shapes those two functions see in +# practice with shell builtins and hand anything they cannot PROVE to the +# unchanged jq path. The contract is "the bytes jq would produce, or fall +# back", never "close enough". +# +# What the builtin paths rely on: the text is valid JSON. Every hook payload +# reaching hook::read_file_path has passed hook::buffer_stdin's jq validation, +# and every telemetry data object is built with jq. The helpers still verify +# the structure they walk (terminated strings, well-formed tokens, properly +# nested brackets, one root, no raw control bytes inside strings), but they +# are not a full JSON parser and do not claim to reject every malformed text. +# +# Why no scanning: on this repo's Windows hosts bash regex matching and the +# `%%`/`##` pattern operators cost about a microsecond per character, so a +# 60 KB Write payload would cost more than the jq process they replace. The +# only string operations used on the whole payload are literal-substring +# replacement, `[[ == *x* ]]` containment, IFS word splitting and offset +# slicing, all of which run at C speed. + +# hook::_json_split +# Split at every unescaped double quote into the global array +# _HOOK_JSON_PARTS: even indices hold the structural text between strings, odd +# indices hold string bodies. Before splitting, every `\\` and `\"` pair is +# replaced by `@@` (same length), so a quote that survives is a real string +# delimiter and a body's ORIGINAL bytes are ${text:offset:length}. Returns 1 +# when the text is too large, its quotes do not pair up, or it holds more +# strings than the callers' loops are meant to walk. +_HOOK_JSON_PARTS=() +hook::_json_split() { + local __hu_s="$1" __hu_t __hu_q __hu_n + ((${#__hu_s} <= 65536)) || return 1 + __hu_t=${__hu_s//"\\\\"/@@} + __hu_t=${__hu_t//"\\\""/@@} + __hu_q=${__hu_t//\"/} + __hu_n=$((${#__hu_t} - ${#__hu_q})) + ((__hu_n % 2 == 0)) || return 1 + ((__hu_n <= 800)) || return 1 + local __hu_glob=0 + [[ $- == *f* ]] || __hu_glob=1 + set -f + local IFS='"' + # shellcheck disable=SC2206 # splitting on IFS='"' is the whole point + _HOOK_JSON_PARTS=($__hu_t) + ((__hu_glob)) && set +f + ((${#_HOOK_JSON_PARTS[@]} % 2 == 1)) || return 1 + return 0 +} + +# hook::_json_skeleton +# Split (hook::_json_split), record each string body's byte offset in +# _HOOK_JSON_OFF, and build _HOOK_JSON_SK: the structural text with every +# string replaced by a numbered placeholder `"#"` and all JSON +# whitespace removed. The skeleton is small (no string bodies), so regex and +# per-token work on it is cheap. Returns 1 unless the text is exactly one JSON +# value by the JSON grammar (well-formed tokens, whitespace only between them, +# objects as key:value lists, arrays as value lists, brackets nested and +# closed once) whose strings carry no raw control byte, which jq rejects. +_HOOK_JSON_SK="" +_HOOK_JSON_OFF=() +hook::_json_skeleton() { + local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top + hook::_json_split "$__hu_s" || return 1 + __hu_n=${#_HOOK_JSON_PARTS[@]} + _HOOK_JSON_OFF=() + for ((__hu_i = 0; __hu_i < __hu_n; __hu_i++)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + if ((__hu_i % 2 == 0)); then + __hu_sk+=$__hu_part + else + _HOOK_JSON_OFF[__hu_i]=$__hu_off + [[ "$__hu_part" == *[[:cntrl:]]* ]] && return 1 + __hu_sk+="\"#$__hu_i\"" + fi + __hu_off=$((__hu_off + ${#__hu_part} + 1)) + done + # Tokenize the skeleton and run it through the JSON grammar. Each token is + # consumed by one anchored regex on the (small) remainder; whitespace is + # allowed only between tokens, so `tr ue` is two bad tokens, not `true`. + local __hu_re=$'^[ \t\n\r]*(\\{|\\}|\\[|\\]|,|:|"#[0-9]+"|-?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][-+]?[0-9]+)?|true|false|null)' + local __hu_ws=$'^[ \t\n\r]*$' + __hu_rest=$__hu_sk + while [[ -n "$__hu_rest" ]] && ! [[ "$__hu_rest" =~ $__hu_ws ]]; do + [[ "$__hu_rest" =~ $__hu_re ]] || return 1 + __hu_tok=${BASH_REMATCH[1]} + __hu_rest=${__hu_rest:${#BASH_REMATCH[0]}} + __hu_top=${__hu_stack:${#__hu_stack}-1:1} + case "$__hu_expect" in + value | value_or_end) + case "$__hu_tok" in + '{') + __hu_stack+='{' + __hu_expect=key_or_end + continue + ;; + '[') + __hu_stack+='[' + __hu_expect=value_or_end + continue + ;; + ']') + [[ "$__hu_expect" == value_or_end ]] || return 1 + ;; + '}' | ',' | ':') return 1 ;; + *) + # A scalar. Inside a container the next token is , or the close; + # at the top level nothing may follow. + if [[ -n "$__hu_stack" ]]; then __hu_expect=comma_or_end; else __hu_expect=end; fi + continue + ;; + esac + ;; + key_or_end | key) + case "$__hu_tok" in + \"#*) + __hu_expect='colon' + continue + ;; + '}') [[ "$__hu_expect" == key_or_end ]] || return 1 ;; + *) return 1 ;; + esac + ;; + colon) + [[ "$__hu_tok" == ':' ]] || return 1 + __hu_expect=value + continue + ;; + comma_or_end) + case "$__hu_tok" in + ',') + if [[ "$__hu_top" == '{' ]]; then __hu_expect=key; else __hu_expect=value; fi + continue + ;; + '}') [[ "$__hu_top" == '{' ]] || return 1 ;; + ']') [[ "$__hu_top" == '[' ]] || return 1 ;; + *) return 1 ;; + esac + ;; + *) return 1 ;; # `end`: a token after the root value closed + esac + # Reaching here means a container just closed. + [[ -n "$__hu_stack" ]] || return 1 + __hu_stack=${__hu_stack:0:${#__hu_stack}-1} + if [[ -n "$__hu_stack" ]]; then __hu_expect=comma_or_end; else __hu_expect=end; fi + done + [[ "$__hu_expect" == end ]] || return 1 + _HOOK_JSON_SK=${__hu_sk//[$' \t\n\r']/} + return 0 +} + +# hook::json_unescape_to +# Decode a JSON string body (the bytes between the quotes) into . Handles +# \" \\ \/ \b \f \n \r \t and \uXXXX for U+0001 to U+007F. Returns 1 on any +# other escape (a \u outside that range, U+0000 which bash cannot hold, or an +# invalid escape), leaving the caller to fall back to jq. +hook::json_unescape_to() { + local __hu_raw="$2" __hu_out="" __hu_pre __hu_c __hu_hex __hu_oct __hu_ch + while [[ "$__hu_raw" == *\\* ]]; do + __hu_pre=${__hu_raw%%\\*} + __hu_out+=$__hu_pre + __hu_raw=${__hu_raw:${#__hu_pre}+1} + __hu_c=${__hu_raw:0:1} + __hu_raw=${__hu_raw:1} + case "$__hu_c" in + '"') __hu_out+='"' ;; + "\\") __hu_out+="\\" ;; + '/') __hu_out+='/' ;; + b) __hu_out+=$'\b' ;; # portability-ok: bash ANSI-C backspace byte, not a GNU grep word boundary + f) __hu_out+=$'\f' ;; + n) __hu_out+=$'\n' ;; + r) __hu_out+=$'\r' ;; + t) __hu_out+=$'\t' ;; + u) + __hu_hex=${__hu_raw:0:4} + [[ "$__hu_hex" =~ ^00[0-7][0-9a-fA-F]$ ]] || return 1 + [[ "$__hu_hex" == 0000 ]] && return 1 + __hu_raw=${__hu_raw:4} + printf -v __hu_oct '%03o' "$((16#$__hu_hex))" + printf -v __hu_ch '%b' "\\0$__hu_oct" + __hu_out+=$__hu_ch + ;; + *) return 1 ;; + esac + done + __hu_out+=$__hu_raw + printf -v "$1" '%s' "$__hu_out" +} + +# hook::_fast_file_path_to +# The builtin answer to `jq -r '(.tool_input.file_path // empty) | +# gsub("\r";"")'` followed by the `$(...)` capture. Returns +# 0 proven: holds exactly what jq would have printed +# 1 proven absent: jq would have printed nothing (the guard skips) +# 2 not proven: run jq +# Proof, on top of hook::_json_skeleton's structural checks: the root is an +# object; exactly one string in the whole payload decodes to `tool_input` and +# exactly one to `file_path` (so neither a duplicate key nor a same-named key +# in another object can change jq's answer); `tool_input` is a key of the root +# (depth 1, key position) whose value is a flat object (no nested object or +# array, else jq); and inside it `file_path` is a key with a plain string +# value. A payload where `file_path` names a value or sits in another object +# has no `.tool_input.file_path`, which jq reports as nothing. Key strings are +# compared AFTER decoding their escapes, so a key spelled with \u escapes is +# still recognized; a body longer than any escaped spelling of either name is +# skipped without decoding. +hook::_fast_file_path_to() { + local __hu_s="$2" __hu_i __hu_n __hu_part __hu_body __hu_ti=-1 __hu_fp=-1 __hu_m __hu_raw __hu_pre __hu_re __hu_file + hook::_json_skeleton "$__hu_s" || return 2 + [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 + __hu_n=${#_HOOK_JSON_PARTS[@]} + for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + ((${#__hu_part} <= 60)) || continue + __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} + if [[ "$__hu_body" == *\\* ]]; then + hook::json_unescape_to __hu_body "$__hu_body" || continue + fi + case "$__hu_body" in + tool_input) + ((__hu_ti < 0)) || return 2 + __hu_ti=$__hu_i + ;; + file_path) + ((__hu_fp < 0)) || return 2 + __hu_fp=$__hu_i + ;; + *) ;; + esac + done + ((__hu_fp >= 0)) || return 1 + ((__hu_ti >= 0)) || return 1 + __hu_re="(^|[{,])\"#$__hu_ti\":\\{([^][{}]*)\\}" + if [[ "$_HOOK_JSON_SK" =~ $__hu_re ]]; then + __hu_body=${BASH_REMATCH[2]} + __hu_pre=${_HOOK_JSON_SK%%\"#"$__hu_ti"\"*} + # Depth of the key: opening brackets minus closing ones before it must be + # exactly one, i.e. a direct member of the root object. + local __hu_o1=${__hu_pre//\{/} __hu_o2=${__hu_pre//\[/} __hu_c1=${__hu_pre//\}/} __hu_c2=${__hu_pre//\]/} + local __hu_depth=$(((${#__hu_pre} - ${#__hu_o1}) + (${#__hu_pre} - ${#__hu_o2}) - (${#__hu_pre} - ${#__hu_c1}) - (${#__hu_pre} - ${#__hu_c2}))) + ((__hu_depth == 1)) || return 2 + else + return 2 + fi + __hu_re="(^|,)\"#$__hu_fp\":\"#([0-9]+)\"(,|$)" + if [[ "$__hu_body" =~ $__hu_re ]]; then + __hu_m=${BASH_REMATCH[2]} + elif [[ "$__hu_body" == *\"#$__hu_fp\"* ]]; then + return 2 + else + return 1 + fi + __hu_raw=${__hu_s:${_HOOK_JSON_OFF[__hu_m]}:${#_HOOK_JSON_PARTS[__hu_m]}} + if [[ "$__hu_raw" == *\\* ]]; then + hook::json_unescape_to __hu_file "$__hu_raw" || return 2 + else + __hu_file=$__hu_raw + fi + __hu_file=${__hu_file//$'\r'/} + while [[ "$__hu_file" == *$'\n' ]]; do + __hu_file=${__hu_file%$'\n'} + done + printf -v "$1" '%s' "$__hu_file" + return 0 +} + +# hook::dirname_to : dirname with builtins for the resolver's +# answer. Strips the last segment; a bare name lives in `.`, a root-level file +# in `/`. The path comes from realpath, so the trailing-slash and doubled-slash +# spellings dirname also collapses never occur here. +hook::dirname_to() { + local __hu_d="${2%/*}" + [[ "$__hu_d" == "$2" ]] && __hu_d=. + [[ -n "$__hu_d" ]] || __hu_d=/ + printf -v "$1" '%s' "$__hu_d" +} + +# Print the arguments joined by NUL bytes, no trailing NUL: the byte stream +# hook::read_file_path read from stdin, rebuilt for the jq fallback. +hook::_print_nul_joined() { + local __hu_first=1 __hu_c + for __hu_c in "$@"; do + ((__hu_first)) || printf '\0' + __hu_first=0 + printf '%s' "$__hu_c" + done +} + +# hook::json_compact_to +# The bytes `jq -c .` prints for , when that can be proven with +# builtins: JSON whitespace between tokens removed, everything else verbatim. +# jq's own output, compact or pretty, satisfies the proof, which is what every +# telemetry data builder in this marketplace produces (jq -c, jq -n, or a +# compact literal). Returns 1 when the text is not a single object, or a +# string carries something jq would re-encode (a \u or \/ escape, an escape +# outside the JSON set, a raw control byte), or the structure fails +# hook::_json_skeleton; the caller then runs jq. +hook::json_compact_to() { + local __hu_s="$2" __hu_out="" __hu_i __hu_part __hu_n + hook::_json_skeleton "$__hu_s" || return 1 + [[ "$_HOOK_JSON_SK" == \{*\} ]] || return 1 + # Numbers: only short integer literals are passed through. A fraction, an + # exponent or an integer beyond 15 digits is rendered differently by + # different jq releases (1.6 canonicalizes, 1.7+ preserves the literal), so + # the bytes cannot be proven; jq decides. In the skeleton every string is a + # `"#N"` placeholder and letters occur only in true/false/null, so a digit + # followed by `.`, `e` or `E` can only be part of a number. + local __hu_num='[0-9][.eE]|[0-9]{16}' + [[ "$_HOOK_JSON_SK" =~ $__hu_num ]] && return 1 + __hu_n=${#_HOOK_JSON_PARTS[@]} + for ((__hu_i = 0; __hu_i < __hu_n; __hu_i++)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + if ((__hu_i % 2 == 0)); then + __hu_out+=${__hu_part//[$' \t\n\r']/} + else + if [[ "$__hu_part" == *\\* ]]; then + [[ "$__hu_part" == *'\u'* || "$__hu_part" == *'\/'* ]] && return 1 + [[ "$__hu_part" == *\\[!bfnrt]* ]] && return 1 + fi + __hu_out+="\"${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}}\"" + fi + done + printf -v "$1" '%s' "$__hu_out" +} + +# hook::json_escape_jq_to / hook::json_escape_jq +# Escape exactly as jq serializes a string value (without the +# quotes): backslash and double quote, the five short forms \b \f \n \r \t, +# every other byte from 0x01 to 0x1f and 0x7f as lowercase \u00xx, and +# everything else, including non-ASCII, verbatim. This is the escaper the +# telemetry envelope needs for byte parity with the jq envelope it replaced; +# hook::json_escape above is a different tool (it DROPS the residual control +# bytes, which is right for a notice and wrong for parity) and is unchanged. +hook::json_escape_jq_to() { + local __hu_s="$2" __hu_i __hu_c __hu_oct __hu_hex + __hu_s="${__hu_s//\\/\\\\}" + __hu_s="${__hu_s//\"/\\\"}" + if [[ "$__hu_s" == *[[:cntrl:]]* ]]; then + __hu_s="${__hu_s//$'\n'/\\n}" + __hu_s="${__hu_s//$'\r'/\\r}" + __hu_s="${__hu_s//$'\t'/\\t}" + __hu_s="${__hu_s//$'\b'/\\b}" # portability-ok: bash ANSI-C backspace byte and the JSON \b escape, not a GNU grep word boundary + __hu_s="${__hu_s//$'\f'/\\f}" + for __hu_i in 1 2 3 4 5 6 7 11 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 127; do + printf -v __hu_oct '%03o' "$__hu_i" + printf -v __hu_c '%b' "\\0$__hu_oct" + [[ "$__hu_s" == *"$__hu_c"* ]] || continue + printf -v __hu_hex '%04x' "$__hu_i" + __hu_s="${__hu_s//"$__hu_c"/\\u$__hu_hex}" + done + fi + printf -v "$1" '%s' "$__hu_s" +} + +hook::json_escape_jq() { + local __hu_e + hook::json_escape_jq_to __hu_e "$1" + printf '%s' "$__hu_e" +} + # Parse file_path from PostToolUse JSON on stdin; validate existence and (when # CLAUDE_PROJECT_DIR is set) project membership. Both sides of the membership # comparison are canonicalized (symlinks resolved) first, so neither an # escaping symlink nor a project root reached via a symlinked path (e.g. # macOS /tmp) skews the verdict. Outputs the path on success. Returns 1 to skip. # FILE=$(hook::read_file_path) || exit 0 +# +# jq-free on the common shape. The payload is read into the shell and +# hook::_fast_file_path_to takes `.tool_input.file_path` with builtins when it +# can PROVE the answer jq would give (see that helper for what "prove" means); +# every other payload goes to the unchanged jq filter. The path comparison then +# resolves the file, the project root and the temp roots with one realpath +# process instead of one each (hook::_physical_prime), and remembers the +# directories for the process. Same verdict, same emitted path, fewer +# processes: on Windows Git Bash each spawn costs tens of milliseconds and this +# guard runs on every Write and Edit. hook::read_file_path() { - local file - file=$(jq -r '(.tool_input.file_path // empty) | gsub("\r";"")' 2>/dev/null) + local -a chunks=() + local chunk file="" mode=2 + # Builtin read to NUL or EOF. A NUL splits the payload into several chunks; + # the fast path only takes a single-chunk (NUL-free) payload, and the jq + # fallback is fed the chunks NUL-joined, so it sees the bytes jq used to + # read straight from stdin. + while :; do + chunk="" + if IFS= read -r -d '' chunk; then + chunks+=("$chunk") + continue + fi + chunks+=("$chunk") + break + done + if ((${#chunks[@]} == 1)); then + mode=0 + hook::_fast_file_path_to file "${chunks[0]}" || mode=$? + fi + case "$mode" in + 0) ;; # proven: `file` holds jq's answer + 1) return 1 ;; # proven absent + *) + file=$(hook::_print_nul_joined "${chunks[@]}" | jq -r '(.tool_input.file_path // empty) | gsub("\r";"")' 2>/dev/null) + ;; + esac [[ -n "$file" ]] || return 1 [[ -f "$file" ]] || return 1 if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then - local norm_file norm_project - norm_file=$(hook::normalize_path "$(hook::physical_path "$file")") - norm_project=$(hook::normalize_path "$(hook::physical_path "${CLAUDE_PROJECT_DIR}")") + local norm_file norm_project phys_file phys_project file_resolved=0 project_resolved=0 + hook::_temp_root_candidates + hook::_physical_prime "$file" "${CLAUDE_PROJECT_DIR}" ${_HOOK_TEMP_CANDS[@]+"${_HOOK_TEMP_CANDS[@]}"} + hook::_physical_cached_to phys_file "$file" && file_resolved=1 + hook::_phys_cache_forget "$file" + hook::_physical_cached_to phys_project "${CLAUDE_PROJECT_DIR}" && project_resolved=1 + hook::normalize_path_to norm_file "$phys_file" + hook::normalize_path_to norm_project "$phys_project" norm_project="${norm_project%/}" # Anchor on a path-segment boundary: accept the project root itself or a # child under it, but not a sibling whose name merely shares the prefix @@ -558,19 +1126,28 @@ hook::read_file_path() { # built by `mktemp -d`, which is how this repo's own hook suites run), so # the branch must not fire. Only a temp-tree file reached from a project # root OUTSIDE the temp tree is scratch. - if hook::under_temp_root "$norm_file" && ! hook::under_temp_root "$norm_project"; then - return 1 + local file_in_temp=0 + _HOOK_UTR_TARGET_PHYSICAL=$file_resolved + hook::under_temp_root "$norm_file" && file_in_temp=1 + _HOOK_UTR_TARGET_PHYSICAL=0 + if ((file_in_temp)); then + local project_in_temp=0 + _HOOK_UTR_TARGET_PHYSICAL=$project_resolved + hook::under_temp_root "$norm_project" && project_in_temp=1 + _HOOK_UTR_TARGET_PHYSICAL=0 + ((project_in_temp)) || return 1 fi elif command -v git >/dev/null 2>&1; then # When CLAUDE_PROJECT_DIR is unset, scope to git-working-tree membership so # scratch files outside any repository are not mutated by formatter hooks # (#1091 / #972). - local file_physical - file_physical=$(hook::physical_path "$file") + local file_physical file_dir + hook::physical_path_to file_physical "$file" || : if [[ -L "$file" && "$file_physical" == "$file" ]]; then return 1 fi - if ! hook::in_git_working_tree "$(dirname "$file_physical")"; then + hook::dirname_to file_dir "$file_physical" + if ! hook::in_git_working_tree "$file_dir"; then return 1 fi else @@ -1228,7 +1805,19 @@ hook::telemetry_enabled() { # Fire-and-forget: sink is dispatched in the background; the hook never waits # on it and its failure never affects the hook's own exit code or stdout. # Opt-in guard: HOOK_TELEMETRY_SINK unset or empty → return 0 immediately. -# Fail-open: jq absent → return 0 immediately. +# +# The envelope is assembled with shell builtins (hook::json_escape_jq_to for +# the string fields, hook::json_compact_to for the caller's data object) as +# ONE compact line, `jq -c` style: the same document, field order and +# escapes the jq filter below produces, with the data object compacted +# exactly as jq compacts its own output. That path needs no jq, no temp file +# and no subprocess. When the data object cannot be proven jq-idempotent (see +# hook::json_compact_to), the jq path runs, now with -c so both paths write +# the same bytes, and there jq's absence is still fail-open: return 0, no +# envelope. Before this the envelope was jq's default pretty-printed form +# (several lines, and CRLF line endings with a Windows jq); the sink contract +# was always "one JSON document on stdin", and a compact line is also valid +# JSONL for a sink that appends raw envelopes. # # Usage: # hook::emit_telemetry [repo_root] @@ -1251,7 +1840,6 @@ hook::telemetry_enabled() { # NEVER writes to fd1 (the hook's stdout / additionalContext channel). hook::emit_telemetry() { [[ -n "${HOOK_TELEMETRY_SINK:-}" ]] || return 0 - command -v jq >/dev/null 2>&1 || return 0 local hook_id="$1" local hook_event="$2" @@ -1272,35 +1860,59 @@ hook::emit_telemetry() { local e_s="${now%[.,]*}" e_f="${now#*[.,]}" local duration_ms=$(((e_s * 1000000 + 10#$e_f - s_s * 1000000 - 10#$s_f) / 1000)) - # True UTC timestamp (TZ= prefix overrides LC_ALL / local TZ; the Z is not a lie). - local timestamp - timestamp=$(TZ=UTC printf '%(%Y-%m-%dT%H:%M:%SZ)T' -1) - - # Build the envelope. The `data` object is written to a temp file, not passed - # as --argjson, so payloads larger than the Windows 32767-character command-line - # cap are not dropped before jq runs (#1595). /dev/stdin is not used: jq's - # --slurpfile there is not portable on Windows. Redirect jq stderr to - # /dev/null; output goes to a local variable — never to fd1. - local envelope data_file - data_file=$(mktemp) || return 0 - printf '%s' "$data_json" >"$data_file" || { - rm -f "$data_file" - return 0 - } - envelope=$(jq -n \ - --arg schema_version "1.0" \ - --arg timestamp "$timestamp" \ - --arg hook "$hook_id" \ - --arg hook_event "$hook_event" \ - --arg status "$status" \ - --argjson duration_ms "$duration_ms" \ - --slurpfile data "$data_file" \ - '{schema_version:$schema_version,timestamp:$timestamp,hook:$hook,hook_event:$hook_event,status:$status,duration_ms:$duration_ms,data:($data[0] // {})}' \ - 2>/dev/null) || { + # True UTC timestamp (TZ= prefix overrides LC_ALL / local TZ; the Z is not a + # lie). The %()T printf format is Bash 4.2+, and printf -v keeps it in the + # shell (a `$(...)` capture is a fork); an older bash, or a printf that + # binds nothing, falls back to date -u. + local timestamp="" + TZ=UTC printf -v timestamp '%(%Y-%m-%dT%H:%M:%SZ)T' -1 2>/dev/null || timestamp="" + [[ -n "$timestamp" ]] || timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null) || timestamp="" + + # Trailing whitespace after the object (a newline a caller left on the + # value) does not change what jq parses, so it does not block the builtin + # path either. + local envelope="" data="" data_trim="$data_json" + while [[ "$data_trim" == *[$' \t\r\n'] ]]; do + data_trim="${data_trim%?}" + done + if [[ "$data_trim" == \{*\} ]] && hook::json_compact_to data "$data_trim"; then + # Builtin envelope: the same document the jq filter below produces, field + # for field, with jq's string escaping and jq's compact data. + local e_timestamp e_hook e_event e_status + hook::json_escape_jq_to e_timestamp "$timestamp" + hook::json_escape_jq_to e_hook "$hook_id" + hook::json_escape_jq_to e_event "$hook_event" + hook::json_escape_jq_to e_status "$status" + envelope='{"schema_version":"1.0","timestamp":"'"$e_timestamp"'","hook":"'"$e_hook"'","hook_event":"'"$e_event"'","status":"'"$e_status"'","duration_ms":'"$duration_ms"',"data":'"$data"'}' + else + # jq path, for a data object the builtin compactor cannot prove. Fail-open: + # jq absent → return 0. The `data` object is written to a temp file, not + # passed as --argjson, so payloads larger than the Windows 32767-character + # command-line cap are not dropped before jq runs (#1595). /dev/stdin is + # not used: jq's --slurpfile there is not portable on Windows. Redirect jq + # stderr to /dev/null; output goes to a local variable — never to fd1. + command -v jq >/dev/null 2>&1 || return 0 + local data_file + data_file=$(mktemp) || return 0 + printf '%s' "$data_json" >"$data_file" || { + rm -f "$data_file" + return 0 + } + envelope=$(jq -nc \ + --arg schema_version "1.0" \ + --arg timestamp "$timestamp" \ + --arg hook "$hook_id" \ + --arg hook_event "$hook_event" \ + --arg status "$status" \ + --argjson duration_ms "$duration_ms" \ + --slurpfile data "$data_file" \ + '{schema_version:$schema_version,timestamp:$timestamp,hook:$hook,hook_event:$hook_event,status:$status,duration_ms:$duration_ms,data:($data[0] // {})}' \ + 2>/dev/null) || { + rm -f "$data_file" + return 0 + } rm -f "$data_file" - return 0 - } - rm -f "$data_file" + fi # Resolve the sink path. A relative HOOK_TELEMETRY_SINK is joined onto the # consuming repo root (portable, tracked wiring); absolute is used as-is. A diff --git a/plugins/go-format/.claude-plugin/plugin.json b/plugins/go-format/.claude-plugin/plugin.json index 7cd23845bf..7ef83b398a 100644 --- a/plugins/go-format/.claude-plugin/plugin.json +++ b/plugins/go-format/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "go-format", - "version": "0.3.32", + "version": "0.3.33", "description": "Auto-fix Go formatting and import management on edit via goimports \u2014 runs unconditionally (no consumer-config gate), skipping generated files.", "author": { "name": "Melodic Software", diff --git a/plugins/go-format/CHANGELOG.md b/plugins/go-format/CHANGELOG.md index cfc4b1725a..5b77bc13b8 100644 --- a/plugins/go-format/CHANGELOG.md +++ b/plugins/go-format/CHANGELOG.md @@ -3,6 +3,22 @@ All notable changes to the `go-format` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.3.33] + +### Changed + +- **Vendored `hook-utils.sh` builds the telemetry envelope and reads `file_path` + with shell builtins.** `hook::emit_telemetry` no longer spawns two jq + processes, a mktemp and an rm per run: the envelope is assembled in the shell + as one compact line (the same document jq produced, now `jq -c` shaped), with + jq kept only as the fallback for a data object the builtin compactor cannot + prove. `hook::read_file_path` takes `.tool_input.file_path` without jq on the + well-formed payload shape and resolves the file, project root and temp roots + with one batched `realpath` instead of one process each. Same verdicts, same + emitted path, same sink record; phase 4b of the hook-performance program + (#3623). The copy is bumped because `scripts/sync-hook-utils.sh` keeps every + carrying plugin byte-identical. + ## [0.3.32] ### Changed diff --git a/plugins/go-format/hooks/hook-utils.sh b/plugins/go-format/hooks/hook-utils.sh index 9818a6a73d..18e4dd5745 100644 --- a/plugins/go-format/hooks/hook-utils.sh +++ b/plugins/go-format/hooks/hook-utils.sh @@ -390,19 +390,34 @@ hook::require_jq_blocking() { # collapse with `/c/repo` and the membership guard would admit a sibling # outside CLAUDE_PROJECT_DIR. The result is used ONLY for comparison; the # emitted path is always the caller's original. -hook::normalize_path() { - local p="${1//\\//}" +# +# Two spellings of every path helper below: `hook::` prints the answer +# (the public, subshell-friendly form every caller already uses) and +# `hook::_to ` stores it in the caller's variable instead. +# The `_to` form is what the hot path uses: a `$(...)` capture is a fork, and +# on Windows Git Bash a fork costs milliseconds, so the file_path guard that +# ran a dozen of them per hook now runs none it does not need. The `_to` +# helpers keep their locals under a `__hu_` prefix so the caller's variable +# name cannot collide with them. +hook::normalize_path_to() { + local __hu_p="${2//\\//}" case "${OSTYPE:-}" in msys* | cygwin* | win32) - if [[ "$p" =~ ^/([a-zA-Z])/ || "$p" =~ ^([a-zA-Z]):/ ]]; then - local rest="${p:2}" - printf '%s' "${BASH_REMATCH[1]^}:${rest,,}" - return + if [[ "$__hu_p" =~ ^/([a-zA-Z])/ || "$__hu_p" =~ ^([a-zA-Z]):/ ]]; then + local __hu_rest="${__hu_p:2}" + printf -v "$1" '%s' "${BASH_REMATCH[1]^}:${__hu_rest,,}" + return 0 fi ;; *) ;; # POSIX hosts: case-sensitive FS, no drive fold — pass through below esac - printf '%s' "$p" + printf -v "$1" '%s' "$__hu_p" +} + +hook::normalize_path() { + local __hu_n + hook::normalize_path_to __hu_n "$1" + printf '%s' "$__hu_n" } # Expand Windows 8.3 short-name components (KYLESE~1 → KyleSexton) on @@ -424,25 +439,31 @@ hook::normalize_path() { # class: cygpath ships with Git Bash (the documented Windows bash), so its # absence or failure keeps the resolver's answer unchanged — degrading to the # pre-expansion comparison, same doctrine as the resolver fallback below. -hook::expand_8dot3() { - local p="$1" +hook::expand_8dot3_to() { + local __hu_p="$2" case "${OSTYPE:-}" in msys* | cygwin* | win32) ;; *) - printf '%s' "$p" - return + printf -v "$1" '%s' "$__hu_p" + return 0 ;; esac - if [[ "$p" == *~* ]] && command -v cygpath >/dev/null 2>&1; then - local plain long - if plain=$(cygpath -m -- "$p" 2>/dev/null) && - long=$(cygpath -l -m -- "$p" 2>/dev/null) && - [[ -n "$long" && "$long" != "$plain" ]]; then - printf '%s' "$long" - return + if [[ "$__hu_p" == *~* ]] && command -v cygpath >/dev/null 2>&1; then + local __hu_plain __hu_long + if __hu_plain=$(cygpath -m -- "$__hu_p" 2>/dev/null) && + __hu_long=$(cygpath -l -m -- "$__hu_p" 2>/dev/null) && + [[ -n "$__hu_long" && "$__hu_long" != "$__hu_plain" ]]; then + printf -v "$1" '%s' "$__hu_long" + return 0 fi fi - printf '%s' "$p" + printf -v "$1" '%s' "$__hu_p" +} + +hook::expand_8dot3() { + local __hu_e + hook::expand_8dot3_to __hu_e "$1" + printf '%s' "$__hu_e" } # Canonicalize to a physical path — symlinks resolved, Windows 8.3 short names @@ -462,20 +483,148 @@ hook::expand_8dot3() { # expansion applies only on the resolver's success path: consumers that fail # closed on an unresolved signature must not see a form-converted path instead. # shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED -hook::physical_path() { - local resolved +hook::physical_path_to() { + local __hu_r HOOK_PHYSICAL_PATH_UNRESOLVED=0 - if resolved=$(realpath -- "$1" 2>/dev/null) || resolved=$(readlink -f -- "$1" 2>/dev/null); then - if [[ -n "$resolved" ]]; then - hook::expand_8dot3 "$resolved" + if __hu_r=$(realpath -- "$2" 2>/dev/null) || __hu_r=$(readlink -f -- "$2" 2>/dev/null); then + if [[ -n "$__hu_r" ]]; then + hook::expand_8dot3_to "$1" "$__hu_r" return 0 fi fi HOOK_PHYSICAL_PATH_UNRESOLVED=1 - printf '%s' "$1" + printf -v "$1" '%s' "$2" + return 1 +} + +# shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED +hook::physical_path() { + local __hu_v __hu_rc=0 + hook::physical_path_to __hu_v "$1" || __hu_rc=$? + printf '%s' "$__hu_v" + return "$__hu_rc" +} + +# --- Per-process physical-path cache -------------------------------------- +# The membership guard resolves the same few directories on every call: the +# project root and this host's temp roots. Their physical form does not change +# within one hook process, so each spelling is resolved once and remembered in +# three parallel indexed arrays (keys, values, resolver status). Plain arrays, +# not an associative array, so the cache runs on Bash 3.2. Only directories are +# meant to live here for a process lifetime; hook::read_file_path forgets the +# edited file's entry as soon as it has read it, so a later call in the same +# process sees the file as it is then. +_HOOK_PHYS_KEYS=() +_HOOK_PHYS_VALS=() +_HOOK_PHYS_RCS=() +_HOOK_PHYS_I=-1 + +# hook::_phys_cache_index : sets _HOOK_PHYS_I to the slot holding , +# returns 1 when it is not cached. A forgotten slot has an empty key, which no +# real path can match. +hook::_phys_cache_index() { + local __hu_i + [[ -n "$1" ]] || return 1 + for ((__hu_i = 0; __hu_i < ${#_HOOK_PHYS_KEYS[@]}; __hu_i++)); do + if [[ "${_HOOK_PHYS_KEYS[__hu_i]}" == "$1" ]]; then + _HOOK_PHYS_I=$__hu_i + return 0 + fi + done return 1 } +hook::_phys_cache_forget() { + hook::_phys_cache_index "$1" && _HOOK_PHYS_KEYS[_HOOK_PHYS_I]="" + return 0 +} + +# hook::_physical_cached_to : hook::physical_path_to through the +# cache. Same value, same return status, same HOOK_PHYSICAL_PATH_UNRESOLVED as +# the uncached call; a miss resolves the one path and stores it. +# shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED +hook::_physical_cached_to() { + if hook::_phys_cache_index "$2"; then + printf -v "$1" '%s' "${_HOOK_PHYS_VALS[_HOOK_PHYS_I]}" + HOOK_PHYSICAL_PATH_UNRESOLVED=${_HOOK_PHYS_RCS[_HOOK_PHYS_I]} + return "${_HOOK_PHYS_RCS[_HOOK_PHYS_I]}" + fi + local __hu_v __hu_rc=0 + hook::physical_path_to __hu_v "$2" || __hu_rc=$? + if [[ -n "$2" ]]; then + _HOOK_PHYS_KEYS+=("$2") + _HOOK_PHYS_VALS+=("$__hu_v") + _HOOK_PHYS_RCS+=("$__hu_rc") + fi + printf -v "$1" '%s' "$__hu_v" + return "$__hu_rc" +} + +# hook::_physical_prime ...: resolve every uncached path with ONE +# realpath process and store the answers. realpath resolves each argument on +# its own and prints one line per argument in argument order, so the batch +# answer for a path is the same string the per-path call returns; the 8.3 +# expansion is applied per answer exactly as hook::physical_path_to does. Any +# doubt (a path carrying a newline, a non-zero exit, a line count that does not +# match) stores nothing and leaves the per-path resolver to answer lazily, so +# the batch can only ever save work, never change an answer. readlink -f hosts +# (no realpath) skip the batch for the same reason. +hook::_physical_prime() { + local -a __hu_todo=() + local __hu_p __hu_out __hu_i __hu_seen="" __hu_v + for __hu_p in "$@"; do + [[ -n "$__hu_p" ]] || continue + [[ "$__hu_p" == *$'\n'* ]] && return 0 + case "$__hu_seen" in + *"|$__hu_p|"*) continue ;; + *) ;; # first sighting + esac + __hu_seen="$__hu_seen|$__hu_p|" + hook::_phys_cache_index "$__hu_p" && continue + __hu_todo+=("$__hu_p") + done + ((${#__hu_todo[@]} > 1)) || return 0 + command -v realpath >/dev/null 2>&1 || return 0 + # portability-ok: realpath with several operands is GNU and BSD alike; a host whose realpath rejects it fails the exit-status check and falls back per path + __hu_out=$(realpath -- "${__hu_todo[@]}" 2>/dev/null) || return 0 + local -a __hu_lines=() + local __hu_glob=0 + [[ $- == *f* ]] || __hu_glob=1 + set -f + local IFS=$'\n' + # shellcheck disable=SC2206 # splitting realpath's one-line-per-operand output is the intent + __hu_lines=($__hu_out) + ((__hu_glob)) && set +f + ((${#__hu_lines[@]} == ${#__hu_todo[@]})) || return 0 + for ((__hu_i = 0; __hu_i < ${#__hu_todo[@]}; __hu_i++)); do + [[ -n "${__hu_lines[__hu_i]}" ]] || return 0 + hook::expand_8dot3_to __hu_v "${__hu_lines[__hu_i]}" + _HOOK_PHYS_KEYS+=("${__hu_todo[__hu_i]}") + _HOOK_PHYS_VALS+=("$__hu_v") + _HOOK_PHYS_RCS+=(0) + done + return 0 +} + +# The temp-root candidates hook::under_temp_root compares against: the +# environment's own answer (TMPDIR/TMP/TEMP) plus the POSIX defaults, never a +# hardcoded platform assumption. Existing directories only, each spelling +# once, in _HOOK_TEMP_CANDS. +_HOOK_TEMP_CANDS=() +hook::_temp_root_candidates() { + local __hu_cand __hu_seen="" + _HOOK_TEMP_CANDS=() + for __hu_cand in "${TMPDIR:-}" "${TMP:-}" "${TEMP:-}" /tmp /var/tmp; do + [[ -n "$__hu_cand" && -d "$__hu_cand" ]] || continue + case "$__hu_seen" in + *"|$__hu_cand|"*) continue ;; + *) ;; # first sighting of this candidate + esac + __hu_seen="$__hu_seen|$__hu_cand|" + _HOOK_TEMP_CANDS+=("$__hu_cand") + done +} + # True when sits inside one of this host's temp trees. # Both arguments and candidates go through the same canonicalize+normalize # pipeline as the membership comparison, because the same directory has several @@ -483,21 +632,40 @@ hook::physical_path() { # of the identical directory, and `realpath` resolves the Windows form to a # drive path while leaving `/tmp` as `/tmp`. Neither form alone matches a # `file_path` that could arrive in either, so every candidate is compared and a -# match on any one is a match. Duplicates are resolved once. +# match on any one is a match. Duplicates are resolved once, and each +# candidate's physical form is remembered for the process (see the cache +# above), so a second call costs no resolver process. # -# Candidates are the environment's own answer (TMPDIR/TMP/TEMP) plus the POSIX -# defaults, never a hardcoded platform assumption. +# Raw-spelling shortcut: when the caller vouches that is a +# PHYSICAL path (_HOOK_UTR_TARGET_PHYSICAL=1, set only by hook::read_file_path +# after the resolver succeeded), a candidate whose raw normalized spelling is +# already a prefix of the target is a match without resolving it. A physical +# path contains no symlink component, so a candidate that spells one of its +# prefixes names a chain of real directories whose physical form is that same +# prefix; the resolver would answer the same. A candidate that does not match +# raw is still resolved, so a symlinked temp root (macOS /tmp) is found the +# way it always was. Without the vouch the shortcut is off and every candidate +# is resolved, exactly as before. Note that hook::read_file_path primes every +# candidate into the cache with one batched realpath before calling here, so +# on that path the shortcut skips a lookup, not a process; the batch is what +# saves the processes. A direct caller without the batch saves the resolver. # hook::under_temp_root "$norm_path" && ... +_HOOK_UTR_TARGET_PHYSICAL=0 hook::under_temp_root() { - local target="$1" cand norm seen="" - for cand in "${TMPDIR:-}" "${TMP:-}" "${TEMP:-}" /tmp /var/tmp; do - [[ -n "$cand" && -d "$cand" ]] || continue - case "$seen" in - *"|$cand|"*) continue ;; - *) ;; # first sighting of this candidate — resolve it below - esac - seen="$seen|$cand|" - norm=$(hook::normalize_path "$(hook::physical_path "$cand")") + local target="$1" cand norm phys + hook::_temp_root_candidates + for cand in ${_HOOK_TEMP_CANDS[@]+"${_HOOK_TEMP_CANDS[@]}"}; do + if ((_HOOK_UTR_TARGET_PHYSICAL)); then + hook::normalize_path_to norm "$cand" + if [[ "$norm" != / ]]; then + norm="${norm%/}" + if [[ -n "$norm" && ("$target" == "$norm" || "$target" == "$norm"/*) ]]; then + return 0 + fi + fi + fi + hook::_physical_cached_to phys "$cand" || : + hook::normalize_path_to norm "$phys" # The filesystem root as a temp candidate contains every absolute path; # trimming its only slash would empty the candidate and discard it. [[ "$norm" == / ]] && return 0 @@ -523,21 +691,421 @@ hook::in_git_working_tree() { ) >/dev/null 2>&1 } +# --- Builtin JSON helpers ---------------------------------------------------- +# hook::read_file_path and hook::emit_telemetry used to spend a jq process each +# on every hook run. On Windows Git Bash one spawn costs tens of milliseconds, +# so the helpers here handle the well-formed shapes those two functions see in +# practice with shell builtins and hand anything they cannot PROVE to the +# unchanged jq path. The contract is "the bytes jq would produce, or fall +# back", never "close enough". +# +# What the builtin paths rely on: the text is valid JSON. Every hook payload +# reaching hook::read_file_path has passed hook::buffer_stdin's jq validation, +# and every telemetry data object is built with jq. The helpers still verify +# the structure they walk (terminated strings, well-formed tokens, properly +# nested brackets, one root, no raw control bytes inside strings), but they +# are not a full JSON parser and do not claim to reject every malformed text. +# +# Why no scanning: on this repo's Windows hosts bash regex matching and the +# `%%`/`##` pattern operators cost about a microsecond per character, so a +# 60 KB Write payload would cost more than the jq process they replace. The +# only string operations used on the whole payload are literal-substring +# replacement, `[[ == *x* ]]` containment, IFS word splitting and offset +# slicing, all of which run at C speed. + +# hook::_json_split +# Split at every unescaped double quote into the global array +# _HOOK_JSON_PARTS: even indices hold the structural text between strings, odd +# indices hold string bodies. Before splitting, every `\\` and `\"` pair is +# replaced by `@@` (same length), so a quote that survives is a real string +# delimiter and a body's ORIGINAL bytes are ${text:offset:length}. Returns 1 +# when the text is too large, its quotes do not pair up, or it holds more +# strings than the callers' loops are meant to walk. +_HOOK_JSON_PARTS=() +hook::_json_split() { + local __hu_s="$1" __hu_t __hu_q __hu_n + ((${#__hu_s} <= 65536)) || return 1 + __hu_t=${__hu_s//"\\\\"/@@} + __hu_t=${__hu_t//"\\\""/@@} + __hu_q=${__hu_t//\"/} + __hu_n=$((${#__hu_t} - ${#__hu_q})) + ((__hu_n % 2 == 0)) || return 1 + ((__hu_n <= 800)) || return 1 + local __hu_glob=0 + [[ $- == *f* ]] || __hu_glob=1 + set -f + local IFS='"' + # shellcheck disable=SC2206 # splitting on IFS='"' is the whole point + _HOOK_JSON_PARTS=($__hu_t) + ((__hu_glob)) && set +f + ((${#_HOOK_JSON_PARTS[@]} % 2 == 1)) || return 1 + return 0 +} + +# hook::_json_skeleton +# Split (hook::_json_split), record each string body's byte offset in +# _HOOK_JSON_OFF, and build _HOOK_JSON_SK: the structural text with every +# string replaced by a numbered placeholder `"#"` and all JSON +# whitespace removed. The skeleton is small (no string bodies), so regex and +# per-token work on it is cheap. Returns 1 unless the text is exactly one JSON +# value by the JSON grammar (well-formed tokens, whitespace only between them, +# objects as key:value lists, arrays as value lists, brackets nested and +# closed once) whose strings carry no raw control byte, which jq rejects. +_HOOK_JSON_SK="" +_HOOK_JSON_OFF=() +hook::_json_skeleton() { + local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top + hook::_json_split "$__hu_s" || return 1 + __hu_n=${#_HOOK_JSON_PARTS[@]} + _HOOK_JSON_OFF=() + for ((__hu_i = 0; __hu_i < __hu_n; __hu_i++)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + if ((__hu_i % 2 == 0)); then + __hu_sk+=$__hu_part + else + _HOOK_JSON_OFF[__hu_i]=$__hu_off + [[ "$__hu_part" == *[[:cntrl:]]* ]] && return 1 + __hu_sk+="\"#$__hu_i\"" + fi + __hu_off=$((__hu_off + ${#__hu_part} + 1)) + done + # Tokenize the skeleton and run it through the JSON grammar. Each token is + # consumed by one anchored regex on the (small) remainder; whitespace is + # allowed only between tokens, so `tr ue` is two bad tokens, not `true`. + local __hu_re=$'^[ \t\n\r]*(\\{|\\}|\\[|\\]|,|:|"#[0-9]+"|-?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][-+]?[0-9]+)?|true|false|null)' + local __hu_ws=$'^[ \t\n\r]*$' + __hu_rest=$__hu_sk + while [[ -n "$__hu_rest" ]] && ! [[ "$__hu_rest" =~ $__hu_ws ]]; do + [[ "$__hu_rest" =~ $__hu_re ]] || return 1 + __hu_tok=${BASH_REMATCH[1]} + __hu_rest=${__hu_rest:${#BASH_REMATCH[0]}} + __hu_top=${__hu_stack:${#__hu_stack}-1:1} + case "$__hu_expect" in + value | value_or_end) + case "$__hu_tok" in + '{') + __hu_stack+='{' + __hu_expect=key_or_end + continue + ;; + '[') + __hu_stack+='[' + __hu_expect=value_or_end + continue + ;; + ']') + [[ "$__hu_expect" == value_or_end ]] || return 1 + ;; + '}' | ',' | ':') return 1 ;; + *) + # A scalar. Inside a container the next token is , or the close; + # at the top level nothing may follow. + if [[ -n "$__hu_stack" ]]; then __hu_expect=comma_or_end; else __hu_expect=end; fi + continue + ;; + esac + ;; + key_or_end | key) + case "$__hu_tok" in + \"#*) + __hu_expect='colon' + continue + ;; + '}') [[ "$__hu_expect" == key_or_end ]] || return 1 ;; + *) return 1 ;; + esac + ;; + colon) + [[ "$__hu_tok" == ':' ]] || return 1 + __hu_expect=value + continue + ;; + comma_or_end) + case "$__hu_tok" in + ',') + if [[ "$__hu_top" == '{' ]]; then __hu_expect=key; else __hu_expect=value; fi + continue + ;; + '}') [[ "$__hu_top" == '{' ]] || return 1 ;; + ']') [[ "$__hu_top" == '[' ]] || return 1 ;; + *) return 1 ;; + esac + ;; + *) return 1 ;; # `end`: a token after the root value closed + esac + # Reaching here means a container just closed. + [[ -n "$__hu_stack" ]] || return 1 + __hu_stack=${__hu_stack:0:${#__hu_stack}-1} + if [[ -n "$__hu_stack" ]]; then __hu_expect=comma_or_end; else __hu_expect=end; fi + done + [[ "$__hu_expect" == end ]] || return 1 + _HOOK_JSON_SK=${__hu_sk//[$' \t\n\r']/} + return 0 +} + +# hook::json_unescape_to +# Decode a JSON string body (the bytes between the quotes) into . Handles +# \" \\ \/ \b \f \n \r \t and \uXXXX for U+0001 to U+007F. Returns 1 on any +# other escape (a \u outside that range, U+0000 which bash cannot hold, or an +# invalid escape), leaving the caller to fall back to jq. +hook::json_unescape_to() { + local __hu_raw="$2" __hu_out="" __hu_pre __hu_c __hu_hex __hu_oct __hu_ch + while [[ "$__hu_raw" == *\\* ]]; do + __hu_pre=${__hu_raw%%\\*} + __hu_out+=$__hu_pre + __hu_raw=${__hu_raw:${#__hu_pre}+1} + __hu_c=${__hu_raw:0:1} + __hu_raw=${__hu_raw:1} + case "$__hu_c" in + '"') __hu_out+='"' ;; + "\\") __hu_out+="\\" ;; + '/') __hu_out+='/' ;; + b) __hu_out+=$'\b' ;; # portability-ok: bash ANSI-C backspace byte, not a GNU grep word boundary + f) __hu_out+=$'\f' ;; + n) __hu_out+=$'\n' ;; + r) __hu_out+=$'\r' ;; + t) __hu_out+=$'\t' ;; + u) + __hu_hex=${__hu_raw:0:4} + [[ "$__hu_hex" =~ ^00[0-7][0-9a-fA-F]$ ]] || return 1 + [[ "$__hu_hex" == 0000 ]] && return 1 + __hu_raw=${__hu_raw:4} + printf -v __hu_oct '%03o' "$((16#$__hu_hex))" + printf -v __hu_ch '%b' "\\0$__hu_oct" + __hu_out+=$__hu_ch + ;; + *) return 1 ;; + esac + done + __hu_out+=$__hu_raw + printf -v "$1" '%s' "$__hu_out" +} + +# hook::_fast_file_path_to +# The builtin answer to `jq -r '(.tool_input.file_path // empty) | +# gsub("\r";"")'` followed by the `$(...)` capture. Returns +# 0 proven: holds exactly what jq would have printed +# 1 proven absent: jq would have printed nothing (the guard skips) +# 2 not proven: run jq +# Proof, on top of hook::_json_skeleton's structural checks: the root is an +# object; exactly one string in the whole payload decodes to `tool_input` and +# exactly one to `file_path` (so neither a duplicate key nor a same-named key +# in another object can change jq's answer); `tool_input` is a key of the root +# (depth 1, key position) whose value is a flat object (no nested object or +# array, else jq); and inside it `file_path` is a key with a plain string +# value. A payload where `file_path` names a value or sits in another object +# has no `.tool_input.file_path`, which jq reports as nothing. Key strings are +# compared AFTER decoding their escapes, so a key spelled with \u escapes is +# still recognized; a body longer than any escaped spelling of either name is +# skipped without decoding. +hook::_fast_file_path_to() { + local __hu_s="$2" __hu_i __hu_n __hu_part __hu_body __hu_ti=-1 __hu_fp=-1 __hu_m __hu_raw __hu_pre __hu_re __hu_file + hook::_json_skeleton "$__hu_s" || return 2 + [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 + __hu_n=${#_HOOK_JSON_PARTS[@]} + for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + ((${#__hu_part} <= 60)) || continue + __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} + if [[ "$__hu_body" == *\\* ]]; then + hook::json_unescape_to __hu_body "$__hu_body" || continue + fi + case "$__hu_body" in + tool_input) + ((__hu_ti < 0)) || return 2 + __hu_ti=$__hu_i + ;; + file_path) + ((__hu_fp < 0)) || return 2 + __hu_fp=$__hu_i + ;; + *) ;; + esac + done + ((__hu_fp >= 0)) || return 1 + ((__hu_ti >= 0)) || return 1 + __hu_re="(^|[{,])\"#$__hu_ti\":\\{([^][{}]*)\\}" + if [[ "$_HOOK_JSON_SK" =~ $__hu_re ]]; then + __hu_body=${BASH_REMATCH[2]} + __hu_pre=${_HOOK_JSON_SK%%\"#"$__hu_ti"\"*} + # Depth of the key: opening brackets minus closing ones before it must be + # exactly one, i.e. a direct member of the root object. + local __hu_o1=${__hu_pre//\{/} __hu_o2=${__hu_pre//\[/} __hu_c1=${__hu_pre//\}/} __hu_c2=${__hu_pre//\]/} + local __hu_depth=$(((${#__hu_pre} - ${#__hu_o1}) + (${#__hu_pre} - ${#__hu_o2}) - (${#__hu_pre} - ${#__hu_c1}) - (${#__hu_pre} - ${#__hu_c2}))) + ((__hu_depth == 1)) || return 2 + else + return 2 + fi + __hu_re="(^|,)\"#$__hu_fp\":\"#([0-9]+)\"(,|$)" + if [[ "$__hu_body" =~ $__hu_re ]]; then + __hu_m=${BASH_REMATCH[2]} + elif [[ "$__hu_body" == *\"#$__hu_fp\"* ]]; then + return 2 + else + return 1 + fi + __hu_raw=${__hu_s:${_HOOK_JSON_OFF[__hu_m]}:${#_HOOK_JSON_PARTS[__hu_m]}} + if [[ "$__hu_raw" == *\\* ]]; then + hook::json_unescape_to __hu_file "$__hu_raw" || return 2 + else + __hu_file=$__hu_raw + fi + __hu_file=${__hu_file//$'\r'/} + while [[ "$__hu_file" == *$'\n' ]]; do + __hu_file=${__hu_file%$'\n'} + done + printf -v "$1" '%s' "$__hu_file" + return 0 +} + +# hook::dirname_to : dirname with builtins for the resolver's +# answer. Strips the last segment; a bare name lives in `.`, a root-level file +# in `/`. The path comes from realpath, so the trailing-slash and doubled-slash +# spellings dirname also collapses never occur here. +hook::dirname_to() { + local __hu_d="${2%/*}" + [[ "$__hu_d" == "$2" ]] && __hu_d=. + [[ -n "$__hu_d" ]] || __hu_d=/ + printf -v "$1" '%s' "$__hu_d" +} + +# Print the arguments joined by NUL bytes, no trailing NUL: the byte stream +# hook::read_file_path read from stdin, rebuilt for the jq fallback. +hook::_print_nul_joined() { + local __hu_first=1 __hu_c + for __hu_c in "$@"; do + ((__hu_first)) || printf '\0' + __hu_first=0 + printf '%s' "$__hu_c" + done +} + +# hook::json_compact_to +# The bytes `jq -c .` prints for , when that can be proven with +# builtins: JSON whitespace between tokens removed, everything else verbatim. +# jq's own output, compact or pretty, satisfies the proof, which is what every +# telemetry data builder in this marketplace produces (jq -c, jq -n, or a +# compact literal). Returns 1 when the text is not a single object, or a +# string carries something jq would re-encode (a \u or \/ escape, an escape +# outside the JSON set, a raw control byte), or the structure fails +# hook::_json_skeleton; the caller then runs jq. +hook::json_compact_to() { + local __hu_s="$2" __hu_out="" __hu_i __hu_part __hu_n + hook::_json_skeleton "$__hu_s" || return 1 + [[ "$_HOOK_JSON_SK" == \{*\} ]] || return 1 + # Numbers: only short integer literals are passed through. A fraction, an + # exponent or an integer beyond 15 digits is rendered differently by + # different jq releases (1.6 canonicalizes, 1.7+ preserves the literal), so + # the bytes cannot be proven; jq decides. In the skeleton every string is a + # `"#N"` placeholder and letters occur only in true/false/null, so a digit + # followed by `.`, `e` or `E` can only be part of a number. + local __hu_num='[0-9][.eE]|[0-9]{16}' + [[ "$_HOOK_JSON_SK" =~ $__hu_num ]] && return 1 + __hu_n=${#_HOOK_JSON_PARTS[@]} + for ((__hu_i = 0; __hu_i < __hu_n; __hu_i++)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + if ((__hu_i % 2 == 0)); then + __hu_out+=${__hu_part//[$' \t\n\r']/} + else + if [[ "$__hu_part" == *\\* ]]; then + [[ "$__hu_part" == *'\u'* || "$__hu_part" == *'\/'* ]] && return 1 + [[ "$__hu_part" == *\\[!bfnrt]* ]] && return 1 + fi + __hu_out+="\"${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}}\"" + fi + done + printf -v "$1" '%s' "$__hu_out" +} + +# hook::json_escape_jq_to / hook::json_escape_jq +# Escape exactly as jq serializes a string value (without the +# quotes): backslash and double quote, the five short forms \b \f \n \r \t, +# every other byte from 0x01 to 0x1f and 0x7f as lowercase \u00xx, and +# everything else, including non-ASCII, verbatim. This is the escaper the +# telemetry envelope needs for byte parity with the jq envelope it replaced; +# hook::json_escape above is a different tool (it DROPS the residual control +# bytes, which is right for a notice and wrong for parity) and is unchanged. +hook::json_escape_jq_to() { + local __hu_s="$2" __hu_i __hu_c __hu_oct __hu_hex + __hu_s="${__hu_s//\\/\\\\}" + __hu_s="${__hu_s//\"/\\\"}" + if [[ "$__hu_s" == *[[:cntrl:]]* ]]; then + __hu_s="${__hu_s//$'\n'/\\n}" + __hu_s="${__hu_s//$'\r'/\\r}" + __hu_s="${__hu_s//$'\t'/\\t}" + __hu_s="${__hu_s//$'\b'/\\b}" # portability-ok: bash ANSI-C backspace byte and the JSON \b escape, not a GNU grep word boundary + __hu_s="${__hu_s//$'\f'/\\f}" + for __hu_i in 1 2 3 4 5 6 7 11 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 127; do + printf -v __hu_oct '%03o' "$__hu_i" + printf -v __hu_c '%b' "\\0$__hu_oct" + [[ "$__hu_s" == *"$__hu_c"* ]] || continue + printf -v __hu_hex '%04x' "$__hu_i" + __hu_s="${__hu_s//"$__hu_c"/\\u$__hu_hex}" + done + fi + printf -v "$1" '%s' "$__hu_s" +} + +hook::json_escape_jq() { + local __hu_e + hook::json_escape_jq_to __hu_e "$1" + printf '%s' "$__hu_e" +} + # Parse file_path from PostToolUse JSON on stdin; validate existence and (when # CLAUDE_PROJECT_DIR is set) project membership. Both sides of the membership # comparison are canonicalized (symlinks resolved) first, so neither an # escaping symlink nor a project root reached via a symlinked path (e.g. # macOS /tmp) skews the verdict. Outputs the path on success. Returns 1 to skip. # FILE=$(hook::read_file_path) || exit 0 +# +# jq-free on the common shape. The payload is read into the shell and +# hook::_fast_file_path_to takes `.tool_input.file_path` with builtins when it +# can PROVE the answer jq would give (see that helper for what "prove" means); +# every other payload goes to the unchanged jq filter. The path comparison then +# resolves the file, the project root and the temp roots with one realpath +# process instead of one each (hook::_physical_prime), and remembers the +# directories for the process. Same verdict, same emitted path, fewer +# processes: on Windows Git Bash each spawn costs tens of milliseconds and this +# guard runs on every Write and Edit. hook::read_file_path() { - local file - file=$(jq -r '(.tool_input.file_path // empty) | gsub("\r";"")' 2>/dev/null) + local -a chunks=() + local chunk file="" mode=2 + # Builtin read to NUL or EOF. A NUL splits the payload into several chunks; + # the fast path only takes a single-chunk (NUL-free) payload, and the jq + # fallback is fed the chunks NUL-joined, so it sees the bytes jq used to + # read straight from stdin. + while :; do + chunk="" + if IFS= read -r -d '' chunk; then + chunks+=("$chunk") + continue + fi + chunks+=("$chunk") + break + done + if ((${#chunks[@]} == 1)); then + mode=0 + hook::_fast_file_path_to file "${chunks[0]}" || mode=$? + fi + case "$mode" in + 0) ;; # proven: `file` holds jq's answer + 1) return 1 ;; # proven absent + *) + file=$(hook::_print_nul_joined "${chunks[@]}" | jq -r '(.tool_input.file_path // empty) | gsub("\r";"")' 2>/dev/null) + ;; + esac [[ -n "$file" ]] || return 1 [[ -f "$file" ]] || return 1 if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then - local norm_file norm_project - norm_file=$(hook::normalize_path "$(hook::physical_path "$file")") - norm_project=$(hook::normalize_path "$(hook::physical_path "${CLAUDE_PROJECT_DIR}")") + local norm_file norm_project phys_file phys_project file_resolved=0 project_resolved=0 + hook::_temp_root_candidates + hook::_physical_prime "$file" "${CLAUDE_PROJECT_DIR}" ${_HOOK_TEMP_CANDS[@]+"${_HOOK_TEMP_CANDS[@]}"} + hook::_physical_cached_to phys_file "$file" && file_resolved=1 + hook::_phys_cache_forget "$file" + hook::_physical_cached_to phys_project "${CLAUDE_PROJECT_DIR}" && project_resolved=1 + hook::normalize_path_to norm_file "$phys_file" + hook::normalize_path_to norm_project "$phys_project" norm_project="${norm_project%/}" # Anchor on a path-segment boundary: accept the project root itself or a # child under it, but not a sibling whose name merely shares the prefix @@ -558,19 +1126,28 @@ hook::read_file_path() { # built by `mktemp -d`, which is how this repo's own hook suites run), so # the branch must not fire. Only a temp-tree file reached from a project # root OUTSIDE the temp tree is scratch. - if hook::under_temp_root "$norm_file" && ! hook::under_temp_root "$norm_project"; then - return 1 + local file_in_temp=0 + _HOOK_UTR_TARGET_PHYSICAL=$file_resolved + hook::under_temp_root "$norm_file" && file_in_temp=1 + _HOOK_UTR_TARGET_PHYSICAL=0 + if ((file_in_temp)); then + local project_in_temp=0 + _HOOK_UTR_TARGET_PHYSICAL=$project_resolved + hook::under_temp_root "$norm_project" && project_in_temp=1 + _HOOK_UTR_TARGET_PHYSICAL=0 + ((project_in_temp)) || return 1 fi elif command -v git >/dev/null 2>&1; then # When CLAUDE_PROJECT_DIR is unset, scope to git-working-tree membership so # scratch files outside any repository are not mutated by formatter hooks # (#1091 / #972). - local file_physical - file_physical=$(hook::physical_path "$file") + local file_physical file_dir + hook::physical_path_to file_physical "$file" || : if [[ -L "$file" && "$file_physical" == "$file" ]]; then return 1 fi - if ! hook::in_git_working_tree "$(dirname "$file_physical")"; then + hook::dirname_to file_dir "$file_physical" + if ! hook::in_git_working_tree "$file_dir"; then return 1 fi else @@ -1228,7 +1805,19 @@ hook::telemetry_enabled() { # Fire-and-forget: sink is dispatched in the background; the hook never waits # on it and its failure never affects the hook's own exit code or stdout. # Opt-in guard: HOOK_TELEMETRY_SINK unset or empty → return 0 immediately. -# Fail-open: jq absent → return 0 immediately. +# +# The envelope is assembled with shell builtins (hook::json_escape_jq_to for +# the string fields, hook::json_compact_to for the caller's data object) as +# ONE compact line, `jq -c` style: the same document, field order and +# escapes the jq filter below produces, with the data object compacted +# exactly as jq compacts its own output. That path needs no jq, no temp file +# and no subprocess. When the data object cannot be proven jq-idempotent (see +# hook::json_compact_to), the jq path runs, now with -c so both paths write +# the same bytes, and there jq's absence is still fail-open: return 0, no +# envelope. Before this the envelope was jq's default pretty-printed form +# (several lines, and CRLF line endings with a Windows jq); the sink contract +# was always "one JSON document on stdin", and a compact line is also valid +# JSONL for a sink that appends raw envelopes. # # Usage: # hook::emit_telemetry [repo_root] @@ -1251,7 +1840,6 @@ hook::telemetry_enabled() { # NEVER writes to fd1 (the hook's stdout / additionalContext channel). hook::emit_telemetry() { [[ -n "${HOOK_TELEMETRY_SINK:-}" ]] || return 0 - command -v jq >/dev/null 2>&1 || return 0 local hook_id="$1" local hook_event="$2" @@ -1272,35 +1860,59 @@ hook::emit_telemetry() { local e_s="${now%[.,]*}" e_f="${now#*[.,]}" local duration_ms=$(((e_s * 1000000 + 10#$e_f - s_s * 1000000 - 10#$s_f) / 1000)) - # True UTC timestamp (TZ= prefix overrides LC_ALL / local TZ; the Z is not a lie). - local timestamp - timestamp=$(TZ=UTC printf '%(%Y-%m-%dT%H:%M:%SZ)T' -1) - - # Build the envelope. The `data` object is written to a temp file, not passed - # as --argjson, so payloads larger than the Windows 32767-character command-line - # cap are not dropped before jq runs (#1595). /dev/stdin is not used: jq's - # --slurpfile there is not portable on Windows. Redirect jq stderr to - # /dev/null; output goes to a local variable — never to fd1. - local envelope data_file - data_file=$(mktemp) || return 0 - printf '%s' "$data_json" >"$data_file" || { - rm -f "$data_file" - return 0 - } - envelope=$(jq -n \ - --arg schema_version "1.0" \ - --arg timestamp "$timestamp" \ - --arg hook "$hook_id" \ - --arg hook_event "$hook_event" \ - --arg status "$status" \ - --argjson duration_ms "$duration_ms" \ - --slurpfile data "$data_file" \ - '{schema_version:$schema_version,timestamp:$timestamp,hook:$hook,hook_event:$hook_event,status:$status,duration_ms:$duration_ms,data:($data[0] // {})}' \ - 2>/dev/null) || { + # True UTC timestamp (TZ= prefix overrides LC_ALL / local TZ; the Z is not a + # lie). The %()T printf format is Bash 4.2+, and printf -v keeps it in the + # shell (a `$(...)` capture is a fork); an older bash, or a printf that + # binds nothing, falls back to date -u. + local timestamp="" + TZ=UTC printf -v timestamp '%(%Y-%m-%dT%H:%M:%SZ)T' -1 2>/dev/null || timestamp="" + [[ -n "$timestamp" ]] || timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null) || timestamp="" + + # Trailing whitespace after the object (a newline a caller left on the + # value) does not change what jq parses, so it does not block the builtin + # path either. + local envelope="" data="" data_trim="$data_json" + while [[ "$data_trim" == *[$' \t\r\n'] ]]; do + data_trim="${data_trim%?}" + done + if [[ "$data_trim" == \{*\} ]] && hook::json_compact_to data "$data_trim"; then + # Builtin envelope: the same document the jq filter below produces, field + # for field, with jq's string escaping and jq's compact data. + local e_timestamp e_hook e_event e_status + hook::json_escape_jq_to e_timestamp "$timestamp" + hook::json_escape_jq_to e_hook "$hook_id" + hook::json_escape_jq_to e_event "$hook_event" + hook::json_escape_jq_to e_status "$status" + envelope='{"schema_version":"1.0","timestamp":"'"$e_timestamp"'","hook":"'"$e_hook"'","hook_event":"'"$e_event"'","status":"'"$e_status"'","duration_ms":'"$duration_ms"',"data":'"$data"'}' + else + # jq path, for a data object the builtin compactor cannot prove. Fail-open: + # jq absent → return 0. The `data` object is written to a temp file, not + # passed as --argjson, so payloads larger than the Windows 32767-character + # command-line cap are not dropped before jq runs (#1595). /dev/stdin is + # not used: jq's --slurpfile there is not portable on Windows. Redirect jq + # stderr to /dev/null; output goes to a local variable — never to fd1. + command -v jq >/dev/null 2>&1 || return 0 + local data_file + data_file=$(mktemp) || return 0 + printf '%s' "$data_json" >"$data_file" || { + rm -f "$data_file" + return 0 + } + envelope=$(jq -nc \ + --arg schema_version "1.0" \ + --arg timestamp "$timestamp" \ + --arg hook "$hook_id" \ + --arg hook_event "$hook_event" \ + --arg status "$status" \ + --argjson duration_ms "$duration_ms" \ + --slurpfile data "$data_file" \ + '{schema_version:$schema_version,timestamp:$timestamp,hook:$hook,hook_event:$hook_event,status:$status,duration_ms:$duration_ms,data:($data[0] // {})}' \ + 2>/dev/null) || { + rm -f "$data_file" + return 0 + } rm -f "$data_file" - return 0 - } - rm -f "$data_file" + fi # Resolve the sink path. A relative HOOK_TELEMETRY_SINK is joined onto the # consuming repo root (portable, tracked wiring); absolute is used as-is. A diff --git a/plugins/guardrails/.claude-plugin/plugin.json b/plugins/guardrails/.claude-plugin/plugin.json index 4cd3ad8100..3a0c088865 100644 --- a/plugins/guardrails/.claude-plugin/plugin.json +++ b/plugins/guardrails/.claude-plugin/plugin.json @@ -147,5 +147,5 @@ "min": 1 } }, - "version": "0.31.1" + "version": "0.31.2" } diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index 2971ad6820..ca03744a31 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -3,6 +3,22 @@ All notable changes to the `guardrails` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.31.2] + +### Changed + +- **Vendored `hook-utils.sh` builds the telemetry envelope and reads `file_path` + with shell builtins.** `hook::emit_telemetry` no longer spawns two jq + processes, a mktemp and an rm per run: the envelope is assembled in the shell + as one compact line (the same document jq produced, now `jq -c` shaped), with + jq kept only as the fallback for a data object the builtin compactor cannot + prove. `hook::read_file_path` takes `.tool_input.file_path` without jq on the + well-formed payload shape and resolves the file, project root and temp roots + with one batched `realpath` instead of one process each. Same verdicts, same + emitted path, same sink record; phase 4b of the hook-performance program + (#3623). The copy is bumped because `scripts/sync-hook-utils.sh` keeps every + carrying plugin byte-identical. + ## [0.31.1] ### Changed diff --git a/plugins/guardrails/hooks/hook-utils.sh b/plugins/guardrails/hooks/hook-utils.sh index 9818a6a73d..18e4dd5745 100644 --- a/plugins/guardrails/hooks/hook-utils.sh +++ b/plugins/guardrails/hooks/hook-utils.sh @@ -390,19 +390,34 @@ hook::require_jq_blocking() { # collapse with `/c/repo` and the membership guard would admit a sibling # outside CLAUDE_PROJECT_DIR. The result is used ONLY for comparison; the # emitted path is always the caller's original. -hook::normalize_path() { - local p="${1//\\//}" +# +# Two spellings of every path helper below: `hook::` prints the answer +# (the public, subshell-friendly form every caller already uses) and +# `hook::_to ` stores it in the caller's variable instead. +# The `_to` form is what the hot path uses: a `$(...)` capture is a fork, and +# on Windows Git Bash a fork costs milliseconds, so the file_path guard that +# ran a dozen of them per hook now runs none it does not need. The `_to` +# helpers keep their locals under a `__hu_` prefix so the caller's variable +# name cannot collide with them. +hook::normalize_path_to() { + local __hu_p="${2//\\//}" case "${OSTYPE:-}" in msys* | cygwin* | win32) - if [[ "$p" =~ ^/([a-zA-Z])/ || "$p" =~ ^([a-zA-Z]):/ ]]; then - local rest="${p:2}" - printf '%s' "${BASH_REMATCH[1]^}:${rest,,}" - return + if [[ "$__hu_p" =~ ^/([a-zA-Z])/ || "$__hu_p" =~ ^([a-zA-Z]):/ ]]; then + local __hu_rest="${__hu_p:2}" + printf -v "$1" '%s' "${BASH_REMATCH[1]^}:${__hu_rest,,}" + return 0 fi ;; *) ;; # POSIX hosts: case-sensitive FS, no drive fold — pass through below esac - printf '%s' "$p" + printf -v "$1" '%s' "$__hu_p" +} + +hook::normalize_path() { + local __hu_n + hook::normalize_path_to __hu_n "$1" + printf '%s' "$__hu_n" } # Expand Windows 8.3 short-name components (KYLESE~1 → KyleSexton) on @@ -424,25 +439,31 @@ hook::normalize_path() { # class: cygpath ships with Git Bash (the documented Windows bash), so its # absence or failure keeps the resolver's answer unchanged — degrading to the # pre-expansion comparison, same doctrine as the resolver fallback below. -hook::expand_8dot3() { - local p="$1" +hook::expand_8dot3_to() { + local __hu_p="$2" case "${OSTYPE:-}" in msys* | cygwin* | win32) ;; *) - printf '%s' "$p" - return + printf -v "$1" '%s' "$__hu_p" + return 0 ;; esac - if [[ "$p" == *~* ]] && command -v cygpath >/dev/null 2>&1; then - local plain long - if plain=$(cygpath -m -- "$p" 2>/dev/null) && - long=$(cygpath -l -m -- "$p" 2>/dev/null) && - [[ -n "$long" && "$long" != "$plain" ]]; then - printf '%s' "$long" - return + if [[ "$__hu_p" == *~* ]] && command -v cygpath >/dev/null 2>&1; then + local __hu_plain __hu_long + if __hu_plain=$(cygpath -m -- "$__hu_p" 2>/dev/null) && + __hu_long=$(cygpath -l -m -- "$__hu_p" 2>/dev/null) && + [[ -n "$__hu_long" && "$__hu_long" != "$__hu_plain" ]]; then + printf -v "$1" '%s' "$__hu_long" + return 0 fi fi - printf '%s' "$p" + printf -v "$1" '%s' "$__hu_p" +} + +hook::expand_8dot3() { + local __hu_e + hook::expand_8dot3_to __hu_e "$1" + printf '%s' "$__hu_e" } # Canonicalize to a physical path — symlinks resolved, Windows 8.3 short names @@ -462,20 +483,148 @@ hook::expand_8dot3() { # expansion applies only on the resolver's success path: consumers that fail # closed on an unresolved signature must not see a form-converted path instead. # shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED -hook::physical_path() { - local resolved +hook::physical_path_to() { + local __hu_r HOOK_PHYSICAL_PATH_UNRESOLVED=0 - if resolved=$(realpath -- "$1" 2>/dev/null) || resolved=$(readlink -f -- "$1" 2>/dev/null); then - if [[ -n "$resolved" ]]; then - hook::expand_8dot3 "$resolved" + if __hu_r=$(realpath -- "$2" 2>/dev/null) || __hu_r=$(readlink -f -- "$2" 2>/dev/null); then + if [[ -n "$__hu_r" ]]; then + hook::expand_8dot3_to "$1" "$__hu_r" return 0 fi fi HOOK_PHYSICAL_PATH_UNRESOLVED=1 - printf '%s' "$1" + printf -v "$1" '%s' "$2" + return 1 +} + +# shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED +hook::physical_path() { + local __hu_v __hu_rc=0 + hook::physical_path_to __hu_v "$1" || __hu_rc=$? + printf '%s' "$__hu_v" + return "$__hu_rc" +} + +# --- Per-process physical-path cache -------------------------------------- +# The membership guard resolves the same few directories on every call: the +# project root and this host's temp roots. Their physical form does not change +# within one hook process, so each spelling is resolved once and remembered in +# three parallel indexed arrays (keys, values, resolver status). Plain arrays, +# not an associative array, so the cache runs on Bash 3.2. Only directories are +# meant to live here for a process lifetime; hook::read_file_path forgets the +# edited file's entry as soon as it has read it, so a later call in the same +# process sees the file as it is then. +_HOOK_PHYS_KEYS=() +_HOOK_PHYS_VALS=() +_HOOK_PHYS_RCS=() +_HOOK_PHYS_I=-1 + +# hook::_phys_cache_index : sets _HOOK_PHYS_I to the slot holding , +# returns 1 when it is not cached. A forgotten slot has an empty key, which no +# real path can match. +hook::_phys_cache_index() { + local __hu_i + [[ -n "$1" ]] || return 1 + for ((__hu_i = 0; __hu_i < ${#_HOOK_PHYS_KEYS[@]}; __hu_i++)); do + if [[ "${_HOOK_PHYS_KEYS[__hu_i]}" == "$1" ]]; then + _HOOK_PHYS_I=$__hu_i + return 0 + fi + done return 1 } +hook::_phys_cache_forget() { + hook::_phys_cache_index "$1" && _HOOK_PHYS_KEYS[_HOOK_PHYS_I]="" + return 0 +} + +# hook::_physical_cached_to : hook::physical_path_to through the +# cache. Same value, same return status, same HOOK_PHYSICAL_PATH_UNRESOLVED as +# the uncached call; a miss resolves the one path and stores it. +# shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED +hook::_physical_cached_to() { + if hook::_phys_cache_index "$2"; then + printf -v "$1" '%s' "${_HOOK_PHYS_VALS[_HOOK_PHYS_I]}" + HOOK_PHYSICAL_PATH_UNRESOLVED=${_HOOK_PHYS_RCS[_HOOK_PHYS_I]} + return "${_HOOK_PHYS_RCS[_HOOK_PHYS_I]}" + fi + local __hu_v __hu_rc=0 + hook::physical_path_to __hu_v "$2" || __hu_rc=$? + if [[ -n "$2" ]]; then + _HOOK_PHYS_KEYS+=("$2") + _HOOK_PHYS_VALS+=("$__hu_v") + _HOOK_PHYS_RCS+=("$__hu_rc") + fi + printf -v "$1" '%s' "$__hu_v" + return "$__hu_rc" +} + +# hook::_physical_prime ...: resolve every uncached path with ONE +# realpath process and store the answers. realpath resolves each argument on +# its own and prints one line per argument in argument order, so the batch +# answer for a path is the same string the per-path call returns; the 8.3 +# expansion is applied per answer exactly as hook::physical_path_to does. Any +# doubt (a path carrying a newline, a non-zero exit, a line count that does not +# match) stores nothing and leaves the per-path resolver to answer lazily, so +# the batch can only ever save work, never change an answer. readlink -f hosts +# (no realpath) skip the batch for the same reason. +hook::_physical_prime() { + local -a __hu_todo=() + local __hu_p __hu_out __hu_i __hu_seen="" __hu_v + for __hu_p in "$@"; do + [[ -n "$__hu_p" ]] || continue + [[ "$__hu_p" == *$'\n'* ]] && return 0 + case "$__hu_seen" in + *"|$__hu_p|"*) continue ;; + *) ;; # first sighting + esac + __hu_seen="$__hu_seen|$__hu_p|" + hook::_phys_cache_index "$__hu_p" && continue + __hu_todo+=("$__hu_p") + done + ((${#__hu_todo[@]} > 1)) || return 0 + command -v realpath >/dev/null 2>&1 || return 0 + # portability-ok: realpath with several operands is GNU and BSD alike; a host whose realpath rejects it fails the exit-status check and falls back per path + __hu_out=$(realpath -- "${__hu_todo[@]}" 2>/dev/null) || return 0 + local -a __hu_lines=() + local __hu_glob=0 + [[ $- == *f* ]] || __hu_glob=1 + set -f + local IFS=$'\n' + # shellcheck disable=SC2206 # splitting realpath's one-line-per-operand output is the intent + __hu_lines=($__hu_out) + ((__hu_glob)) && set +f + ((${#__hu_lines[@]} == ${#__hu_todo[@]})) || return 0 + for ((__hu_i = 0; __hu_i < ${#__hu_todo[@]}; __hu_i++)); do + [[ -n "${__hu_lines[__hu_i]}" ]] || return 0 + hook::expand_8dot3_to __hu_v "${__hu_lines[__hu_i]}" + _HOOK_PHYS_KEYS+=("${__hu_todo[__hu_i]}") + _HOOK_PHYS_VALS+=("$__hu_v") + _HOOK_PHYS_RCS+=(0) + done + return 0 +} + +# The temp-root candidates hook::under_temp_root compares against: the +# environment's own answer (TMPDIR/TMP/TEMP) plus the POSIX defaults, never a +# hardcoded platform assumption. Existing directories only, each spelling +# once, in _HOOK_TEMP_CANDS. +_HOOK_TEMP_CANDS=() +hook::_temp_root_candidates() { + local __hu_cand __hu_seen="" + _HOOK_TEMP_CANDS=() + for __hu_cand in "${TMPDIR:-}" "${TMP:-}" "${TEMP:-}" /tmp /var/tmp; do + [[ -n "$__hu_cand" && -d "$__hu_cand" ]] || continue + case "$__hu_seen" in + *"|$__hu_cand|"*) continue ;; + *) ;; # first sighting of this candidate + esac + __hu_seen="$__hu_seen|$__hu_cand|" + _HOOK_TEMP_CANDS+=("$__hu_cand") + done +} + # True when sits inside one of this host's temp trees. # Both arguments and candidates go through the same canonicalize+normalize # pipeline as the membership comparison, because the same directory has several @@ -483,21 +632,40 @@ hook::physical_path() { # of the identical directory, and `realpath` resolves the Windows form to a # drive path while leaving `/tmp` as `/tmp`. Neither form alone matches a # `file_path` that could arrive in either, so every candidate is compared and a -# match on any one is a match. Duplicates are resolved once. +# match on any one is a match. Duplicates are resolved once, and each +# candidate's physical form is remembered for the process (see the cache +# above), so a second call costs no resolver process. # -# Candidates are the environment's own answer (TMPDIR/TMP/TEMP) plus the POSIX -# defaults, never a hardcoded platform assumption. +# Raw-spelling shortcut: when the caller vouches that is a +# PHYSICAL path (_HOOK_UTR_TARGET_PHYSICAL=1, set only by hook::read_file_path +# after the resolver succeeded), a candidate whose raw normalized spelling is +# already a prefix of the target is a match without resolving it. A physical +# path contains no symlink component, so a candidate that spells one of its +# prefixes names a chain of real directories whose physical form is that same +# prefix; the resolver would answer the same. A candidate that does not match +# raw is still resolved, so a symlinked temp root (macOS /tmp) is found the +# way it always was. Without the vouch the shortcut is off and every candidate +# is resolved, exactly as before. Note that hook::read_file_path primes every +# candidate into the cache with one batched realpath before calling here, so +# on that path the shortcut skips a lookup, not a process; the batch is what +# saves the processes. A direct caller without the batch saves the resolver. # hook::under_temp_root "$norm_path" && ... +_HOOK_UTR_TARGET_PHYSICAL=0 hook::under_temp_root() { - local target="$1" cand norm seen="" - for cand in "${TMPDIR:-}" "${TMP:-}" "${TEMP:-}" /tmp /var/tmp; do - [[ -n "$cand" && -d "$cand" ]] || continue - case "$seen" in - *"|$cand|"*) continue ;; - *) ;; # first sighting of this candidate — resolve it below - esac - seen="$seen|$cand|" - norm=$(hook::normalize_path "$(hook::physical_path "$cand")") + local target="$1" cand norm phys + hook::_temp_root_candidates + for cand in ${_HOOK_TEMP_CANDS[@]+"${_HOOK_TEMP_CANDS[@]}"}; do + if ((_HOOK_UTR_TARGET_PHYSICAL)); then + hook::normalize_path_to norm "$cand" + if [[ "$norm" != / ]]; then + norm="${norm%/}" + if [[ -n "$norm" && ("$target" == "$norm" || "$target" == "$norm"/*) ]]; then + return 0 + fi + fi + fi + hook::_physical_cached_to phys "$cand" || : + hook::normalize_path_to norm "$phys" # The filesystem root as a temp candidate contains every absolute path; # trimming its only slash would empty the candidate and discard it. [[ "$norm" == / ]] && return 0 @@ -523,21 +691,421 @@ hook::in_git_working_tree() { ) >/dev/null 2>&1 } +# --- Builtin JSON helpers ---------------------------------------------------- +# hook::read_file_path and hook::emit_telemetry used to spend a jq process each +# on every hook run. On Windows Git Bash one spawn costs tens of milliseconds, +# so the helpers here handle the well-formed shapes those two functions see in +# practice with shell builtins and hand anything they cannot PROVE to the +# unchanged jq path. The contract is "the bytes jq would produce, or fall +# back", never "close enough". +# +# What the builtin paths rely on: the text is valid JSON. Every hook payload +# reaching hook::read_file_path has passed hook::buffer_stdin's jq validation, +# and every telemetry data object is built with jq. The helpers still verify +# the structure they walk (terminated strings, well-formed tokens, properly +# nested brackets, one root, no raw control bytes inside strings), but they +# are not a full JSON parser and do not claim to reject every malformed text. +# +# Why no scanning: on this repo's Windows hosts bash regex matching and the +# `%%`/`##` pattern operators cost about a microsecond per character, so a +# 60 KB Write payload would cost more than the jq process they replace. The +# only string operations used on the whole payload are literal-substring +# replacement, `[[ == *x* ]]` containment, IFS word splitting and offset +# slicing, all of which run at C speed. + +# hook::_json_split +# Split at every unescaped double quote into the global array +# _HOOK_JSON_PARTS: even indices hold the structural text between strings, odd +# indices hold string bodies. Before splitting, every `\\` and `\"` pair is +# replaced by `@@` (same length), so a quote that survives is a real string +# delimiter and a body's ORIGINAL bytes are ${text:offset:length}. Returns 1 +# when the text is too large, its quotes do not pair up, or it holds more +# strings than the callers' loops are meant to walk. +_HOOK_JSON_PARTS=() +hook::_json_split() { + local __hu_s="$1" __hu_t __hu_q __hu_n + ((${#__hu_s} <= 65536)) || return 1 + __hu_t=${__hu_s//"\\\\"/@@} + __hu_t=${__hu_t//"\\\""/@@} + __hu_q=${__hu_t//\"/} + __hu_n=$((${#__hu_t} - ${#__hu_q})) + ((__hu_n % 2 == 0)) || return 1 + ((__hu_n <= 800)) || return 1 + local __hu_glob=0 + [[ $- == *f* ]] || __hu_glob=1 + set -f + local IFS='"' + # shellcheck disable=SC2206 # splitting on IFS='"' is the whole point + _HOOK_JSON_PARTS=($__hu_t) + ((__hu_glob)) && set +f + ((${#_HOOK_JSON_PARTS[@]} % 2 == 1)) || return 1 + return 0 +} + +# hook::_json_skeleton +# Split (hook::_json_split), record each string body's byte offset in +# _HOOK_JSON_OFF, and build _HOOK_JSON_SK: the structural text with every +# string replaced by a numbered placeholder `"#"` and all JSON +# whitespace removed. The skeleton is small (no string bodies), so regex and +# per-token work on it is cheap. Returns 1 unless the text is exactly one JSON +# value by the JSON grammar (well-formed tokens, whitespace only between them, +# objects as key:value lists, arrays as value lists, brackets nested and +# closed once) whose strings carry no raw control byte, which jq rejects. +_HOOK_JSON_SK="" +_HOOK_JSON_OFF=() +hook::_json_skeleton() { + local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top + hook::_json_split "$__hu_s" || return 1 + __hu_n=${#_HOOK_JSON_PARTS[@]} + _HOOK_JSON_OFF=() + for ((__hu_i = 0; __hu_i < __hu_n; __hu_i++)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + if ((__hu_i % 2 == 0)); then + __hu_sk+=$__hu_part + else + _HOOK_JSON_OFF[__hu_i]=$__hu_off + [[ "$__hu_part" == *[[:cntrl:]]* ]] && return 1 + __hu_sk+="\"#$__hu_i\"" + fi + __hu_off=$((__hu_off + ${#__hu_part} + 1)) + done + # Tokenize the skeleton and run it through the JSON grammar. Each token is + # consumed by one anchored regex on the (small) remainder; whitespace is + # allowed only between tokens, so `tr ue` is two bad tokens, not `true`. + local __hu_re=$'^[ \t\n\r]*(\\{|\\}|\\[|\\]|,|:|"#[0-9]+"|-?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][-+]?[0-9]+)?|true|false|null)' + local __hu_ws=$'^[ \t\n\r]*$' + __hu_rest=$__hu_sk + while [[ -n "$__hu_rest" ]] && ! [[ "$__hu_rest" =~ $__hu_ws ]]; do + [[ "$__hu_rest" =~ $__hu_re ]] || return 1 + __hu_tok=${BASH_REMATCH[1]} + __hu_rest=${__hu_rest:${#BASH_REMATCH[0]}} + __hu_top=${__hu_stack:${#__hu_stack}-1:1} + case "$__hu_expect" in + value | value_or_end) + case "$__hu_tok" in + '{') + __hu_stack+='{' + __hu_expect=key_or_end + continue + ;; + '[') + __hu_stack+='[' + __hu_expect=value_or_end + continue + ;; + ']') + [[ "$__hu_expect" == value_or_end ]] || return 1 + ;; + '}' | ',' | ':') return 1 ;; + *) + # A scalar. Inside a container the next token is , or the close; + # at the top level nothing may follow. + if [[ -n "$__hu_stack" ]]; then __hu_expect=comma_or_end; else __hu_expect=end; fi + continue + ;; + esac + ;; + key_or_end | key) + case "$__hu_tok" in + \"#*) + __hu_expect='colon' + continue + ;; + '}') [[ "$__hu_expect" == key_or_end ]] || return 1 ;; + *) return 1 ;; + esac + ;; + colon) + [[ "$__hu_tok" == ':' ]] || return 1 + __hu_expect=value + continue + ;; + comma_or_end) + case "$__hu_tok" in + ',') + if [[ "$__hu_top" == '{' ]]; then __hu_expect=key; else __hu_expect=value; fi + continue + ;; + '}') [[ "$__hu_top" == '{' ]] || return 1 ;; + ']') [[ "$__hu_top" == '[' ]] || return 1 ;; + *) return 1 ;; + esac + ;; + *) return 1 ;; # `end`: a token after the root value closed + esac + # Reaching here means a container just closed. + [[ -n "$__hu_stack" ]] || return 1 + __hu_stack=${__hu_stack:0:${#__hu_stack}-1} + if [[ -n "$__hu_stack" ]]; then __hu_expect=comma_or_end; else __hu_expect=end; fi + done + [[ "$__hu_expect" == end ]] || return 1 + _HOOK_JSON_SK=${__hu_sk//[$' \t\n\r']/} + return 0 +} + +# hook::json_unescape_to +# Decode a JSON string body (the bytes between the quotes) into . Handles +# \" \\ \/ \b \f \n \r \t and \uXXXX for U+0001 to U+007F. Returns 1 on any +# other escape (a \u outside that range, U+0000 which bash cannot hold, or an +# invalid escape), leaving the caller to fall back to jq. +hook::json_unescape_to() { + local __hu_raw="$2" __hu_out="" __hu_pre __hu_c __hu_hex __hu_oct __hu_ch + while [[ "$__hu_raw" == *\\* ]]; do + __hu_pre=${__hu_raw%%\\*} + __hu_out+=$__hu_pre + __hu_raw=${__hu_raw:${#__hu_pre}+1} + __hu_c=${__hu_raw:0:1} + __hu_raw=${__hu_raw:1} + case "$__hu_c" in + '"') __hu_out+='"' ;; + "\\") __hu_out+="\\" ;; + '/') __hu_out+='/' ;; + b) __hu_out+=$'\b' ;; # portability-ok: bash ANSI-C backspace byte, not a GNU grep word boundary + f) __hu_out+=$'\f' ;; + n) __hu_out+=$'\n' ;; + r) __hu_out+=$'\r' ;; + t) __hu_out+=$'\t' ;; + u) + __hu_hex=${__hu_raw:0:4} + [[ "$__hu_hex" =~ ^00[0-7][0-9a-fA-F]$ ]] || return 1 + [[ "$__hu_hex" == 0000 ]] && return 1 + __hu_raw=${__hu_raw:4} + printf -v __hu_oct '%03o' "$((16#$__hu_hex))" + printf -v __hu_ch '%b' "\\0$__hu_oct" + __hu_out+=$__hu_ch + ;; + *) return 1 ;; + esac + done + __hu_out+=$__hu_raw + printf -v "$1" '%s' "$__hu_out" +} + +# hook::_fast_file_path_to +# The builtin answer to `jq -r '(.tool_input.file_path // empty) | +# gsub("\r";"")'` followed by the `$(...)` capture. Returns +# 0 proven: holds exactly what jq would have printed +# 1 proven absent: jq would have printed nothing (the guard skips) +# 2 not proven: run jq +# Proof, on top of hook::_json_skeleton's structural checks: the root is an +# object; exactly one string in the whole payload decodes to `tool_input` and +# exactly one to `file_path` (so neither a duplicate key nor a same-named key +# in another object can change jq's answer); `tool_input` is a key of the root +# (depth 1, key position) whose value is a flat object (no nested object or +# array, else jq); and inside it `file_path` is a key with a plain string +# value. A payload where `file_path` names a value or sits in another object +# has no `.tool_input.file_path`, which jq reports as nothing. Key strings are +# compared AFTER decoding their escapes, so a key spelled with \u escapes is +# still recognized; a body longer than any escaped spelling of either name is +# skipped without decoding. +hook::_fast_file_path_to() { + local __hu_s="$2" __hu_i __hu_n __hu_part __hu_body __hu_ti=-1 __hu_fp=-1 __hu_m __hu_raw __hu_pre __hu_re __hu_file + hook::_json_skeleton "$__hu_s" || return 2 + [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 + __hu_n=${#_HOOK_JSON_PARTS[@]} + for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + ((${#__hu_part} <= 60)) || continue + __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} + if [[ "$__hu_body" == *\\* ]]; then + hook::json_unescape_to __hu_body "$__hu_body" || continue + fi + case "$__hu_body" in + tool_input) + ((__hu_ti < 0)) || return 2 + __hu_ti=$__hu_i + ;; + file_path) + ((__hu_fp < 0)) || return 2 + __hu_fp=$__hu_i + ;; + *) ;; + esac + done + ((__hu_fp >= 0)) || return 1 + ((__hu_ti >= 0)) || return 1 + __hu_re="(^|[{,])\"#$__hu_ti\":\\{([^][{}]*)\\}" + if [[ "$_HOOK_JSON_SK" =~ $__hu_re ]]; then + __hu_body=${BASH_REMATCH[2]} + __hu_pre=${_HOOK_JSON_SK%%\"#"$__hu_ti"\"*} + # Depth of the key: opening brackets minus closing ones before it must be + # exactly one, i.e. a direct member of the root object. + local __hu_o1=${__hu_pre//\{/} __hu_o2=${__hu_pre//\[/} __hu_c1=${__hu_pre//\}/} __hu_c2=${__hu_pre//\]/} + local __hu_depth=$(((${#__hu_pre} - ${#__hu_o1}) + (${#__hu_pre} - ${#__hu_o2}) - (${#__hu_pre} - ${#__hu_c1}) - (${#__hu_pre} - ${#__hu_c2}))) + ((__hu_depth == 1)) || return 2 + else + return 2 + fi + __hu_re="(^|,)\"#$__hu_fp\":\"#([0-9]+)\"(,|$)" + if [[ "$__hu_body" =~ $__hu_re ]]; then + __hu_m=${BASH_REMATCH[2]} + elif [[ "$__hu_body" == *\"#$__hu_fp\"* ]]; then + return 2 + else + return 1 + fi + __hu_raw=${__hu_s:${_HOOK_JSON_OFF[__hu_m]}:${#_HOOK_JSON_PARTS[__hu_m]}} + if [[ "$__hu_raw" == *\\* ]]; then + hook::json_unescape_to __hu_file "$__hu_raw" || return 2 + else + __hu_file=$__hu_raw + fi + __hu_file=${__hu_file//$'\r'/} + while [[ "$__hu_file" == *$'\n' ]]; do + __hu_file=${__hu_file%$'\n'} + done + printf -v "$1" '%s' "$__hu_file" + return 0 +} + +# hook::dirname_to : dirname with builtins for the resolver's +# answer. Strips the last segment; a bare name lives in `.`, a root-level file +# in `/`. The path comes from realpath, so the trailing-slash and doubled-slash +# spellings dirname also collapses never occur here. +hook::dirname_to() { + local __hu_d="${2%/*}" + [[ "$__hu_d" == "$2" ]] && __hu_d=. + [[ -n "$__hu_d" ]] || __hu_d=/ + printf -v "$1" '%s' "$__hu_d" +} + +# Print the arguments joined by NUL bytes, no trailing NUL: the byte stream +# hook::read_file_path read from stdin, rebuilt for the jq fallback. +hook::_print_nul_joined() { + local __hu_first=1 __hu_c + for __hu_c in "$@"; do + ((__hu_first)) || printf '\0' + __hu_first=0 + printf '%s' "$__hu_c" + done +} + +# hook::json_compact_to +# The bytes `jq -c .` prints for , when that can be proven with +# builtins: JSON whitespace between tokens removed, everything else verbatim. +# jq's own output, compact or pretty, satisfies the proof, which is what every +# telemetry data builder in this marketplace produces (jq -c, jq -n, or a +# compact literal). Returns 1 when the text is not a single object, or a +# string carries something jq would re-encode (a \u or \/ escape, an escape +# outside the JSON set, a raw control byte), or the structure fails +# hook::_json_skeleton; the caller then runs jq. +hook::json_compact_to() { + local __hu_s="$2" __hu_out="" __hu_i __hu_part __hu_n + hook::_json_skeleton "$__hu_s" || return 1 + [[ "$_HOOK_JSON_SK" == \{*\} ]] || return 1 + # Numbers: only short integer literals are passed through. A fraction, an + # exponent or an integer beyond 15 digits is rendered differently by + # different jq releases (1.6 canonicalizes, 1.7+ preserves the literal), so + # the bytes cannot be proven; jq decides. In the skeleton every string is a + # `"#N"` placeholder and letters occur only in true/false/null, so a digit + # followed by `.`, `e` or `E` can only be part of a number. + local __hu_num='[0-9][.eE]|[0-9]{16}' + [[ "$_HOOK_JSON_SK" =~ $__hu_num ]] && return 1 + __hu_n=${#_HOOK_JSON_PARTS[@]} + for ((__hu_i = 0; __hu_i < __hu_n; __hu_i++)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + if ((__hu_i % 2 == 0)); then + __hu_out+=${__hu_part//[$' \t\n\r']/} + else + if [[ "$__hu_part" == *\\* ]]; then + [[ "$__hu_part" == *'\u'* || "$__hu_part" == *'\/'* ]] && return 1 + [[ "$__hu_part" == *\\[!bfnrt]* ]] && return 1 + fi + __hu_out+="\"${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}}\"" + fi + done + printf -v "$1" '%s' "$__hu_out" +} + +# hook::json_escape_jq_to / hook::json_escape_jq +# Escape exactly as jq serializes a string value (without the +# quotes): backslash and double quote, the five short forms \b \f \n \r \t, +# every other byte from 0x01 to 0x1f and 0x7f as lowercase \u00xx, and +# everything else, including non-ASCII, verbatim. This is the escaper the +# telemetry envelope needs for byte parity with the jq envelope it replaced; +# hook::json_escape above is a different tool (it DROPS the residual control +# bytes, which is right for a notice and wrong for parity) and is unchanged. +hook::json_escape_jq_to() { + local __hu_s="$2" __hu_i __hu_c __hu_oct __hu_hex + __hu_s="${__hu_s//\\/\\\\}" + __hu_s="${__hu_s//\"/\\\"}" + if [[ "$__hu_s" == *[[:cntrl:]]* ]]; then + __hu_s="${__hu_s//$'\n'/\\n}" + __hu_s="${__hu_s//$'\r'/\\r}" + __hu_s="${__hu_s//$'\t'/\\t}" + __hu_s="${__hu_s//$'\b'/\\b}" # portability-ok: bash ANSI-C backspace byte and the JSON \b escape, not a GNU grep word boundary + __hu_s="${__hu_s//$'\f'/\\f}" + for __hu_i in 1 2 3 4 5 6 7 11 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 127; do + printf -v __hu_oct '%03o' "$__hu_i" + printf -v __hu_c '%b' "\\0$__hu_oct" + [[ "$__hu_s" == *"$__hu_c"* ]] || continue + printf -v __hu_hex '%04x' "$__hu_i" + __hu_s="${__hu_s//"$__hu_c"/\\u$__hu_hex}" + done + fi + printf -v "$1" '%s' "$__hu_s" +} + +hook::json_escape_jq() { + local __hu_e + hook::json_escape_jq_to __hu_e "$1" + printf '%s' "$__hu_e" +} + # Parse file_path from PostToolUse JSON on stdin; validate existence and (when # CLAUDE_PROJECT_DIR is set) project membership. Both sides of the membership # comparison are canonicalized (symlinks resolved) first, so neither an # escaping symlink nor a project root reached via a symlinked path (e.g. # macOS /tmp) skews the verdict. Outputs the path on success. Returns 1 to skip. # FILE=$(hook::read_file_path) || exit 0 +# +# jq-free on the common shape. The payload is read into the shell and +# hook::_fast_file_path_to takes `.tool_input.file_path` with builtins when it +# can PROVE the answer jq would give (see that helper for what "prove" means); +# every other payload goes to the unchanged jq filter. The path comparison then +# resolves the file, the project root and the temp roots with one realpath +# process instead of one each (hook::_physical_prime), and remembers the +# directories for the process. Same verdict, same emitted path, fewer +# processes: on Windows Git Bash each spawn costs tens of milliseconds and this +# guard runs on every Write and Edit. hook::read_file_path() { - local file - file=$(jq -r '(.tool_input.file_path // empty) | gsub("\r";"")' 2>/dev/null) + local -a chunks=() + local chunk file="" mode=2 + # Builtin read to NUL or EOF. A NUL splits the payload into several chunks; + # the fast path only takes a single-chunk (NUL-free) payload, and the jq + # fallback is fed the chunks NUL-joined, so it sees the bytes jq used to + # read straight from stdin. + while :; do + chunk="" + if IFS= read -r -d '' chunk; then + chunks+=("$chunk") + continue + fi + chunks+=("$chunk") + break + done + if ((${#chunks[@]} == 1)); then + mode=0 + hook::_fast_file_path_to file "${chunks[0]}" || mode=$? + fi + case "$mode" in + 0) ;; # proven: `file` holds jq's answer + 1) return 1 ;; # proven absent + *) + file=$(hook::_print_nul_joined "${chunks[@]}" | jq -r '(.tool_input.file_path // empty) | gsub("\r";"")' 2>/dev/null) + ;; + esac [[ -n "$file" ]] || return 1 [[ -f "$file" ]] || return 1 if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then - local norm_file norm_project - norm_file=$(hook::normalize_path "$(hook::physical_path "$file")") - norm_project=$(hook::normalize_path "$(hook::physical_path "${CLAUDE_PROJECT_DIR}")") + local norm_file norm_project phys_file phys_project file_resolved=0 project_resolved=0 + hook::_temp_root_candidates + hook::_physical_prime "$file" "${CLAUDE_PROJECT_DIR}" ${_HOOK_TEMP_CANDS[@]+"${_HOOK_TEMP_CANDS[@]}"} + hook::_physical_cached_to phys_file "$file" && file_resolved=1 + hook::_phys_cache_forget "$file" + hook::_physical_cached_to phys_project "${CLAUDE_PROJECT_DIR}" && project_resolved=1 + hook::normalize_path_to norm_file "$phys_file" + hook::normalize_path_to norm_project "$phys_project" norm_project="${norm_project%/}" # Anchor on a path-segment boundary: accept the project root itself or a # child under it, but not a sibling whose name merely shares the prefix @@ -558,19 +1126,28 @@ hook::read_file_path() { # built by `mktemp -d`, which is how this repo's own hook suites run), so # the branch must not fire. Only a temp-tree file reached from a project # root OUTSIDE the temp tree is scratch. - if hook::under_temp_root "$norm_file" && ! hook::under_temp_root "$norm_project"; then - return 1 + local file_in_temp=0 + _HOOK_UTR_TARGET_PHYSICAL=$file_resolved + hook::under_temp_root "$norm_file" && file_in_temp=1 + _HOOK_UTR_TARGET_PHYSICAL=0 + if ((file_in_temp)); then + local project_in_temp=0 + _HOOK_UTR_TARGET_PHYSICAL=$project_resolved + hook::under_temp_root "$norm_project" && project_in_temp=1 + _HOOK_UTR_TARGET_PHYSICAL=0 + ((project_in_temp)) || return 1 fi elif command -v git >/dev/null 2>&1; then # When CLAUDE_PROJECT_DIR is unset, scope to git-working-tree membership so # scratch files outside any repository are not mutated by formatter hooks # (#1091 / #972). - local file_physical - file_physical=$(hook::physical_path "$file") + local file_physical file_dir + hook::physical_path_to file_physical "$file" || : if [[ -L "$file" && "$file_physical" == "$file" ]]; then return 1 fi - if ! hook::in_git_working_tree "$(dirname "$file_physical")"; then + hook::dirname_to file_dir "$file_physical" + if ! hook::in_git_working_tree "$file_dir"; then return 1 fi else @@ -1228,7 +1805,19 @@ hook::telemetry_enabled() { # Fire-and-forget: sink is dispatched in the background; the hook never waits # on it and its failure never affects the hook's own exit code or stdout. # Opt-in guard: HOOK_TELEMETRY_SINK unset or empty → return 0 immediately. -# Fail-open: jq absent → return 0 immediately. +# +# The envelope is assembled with shell builtins (hook::json_escape_jq_to for +# the string fields, hook::json_compact_to for the caller's data object) as +# ONE compact line, `jq -c` style: the same document, field order and +# escapes the jq filter below produces, with the data object compacted +# exactly as jq compacts its own output. That path needs no jq, no temp file +# and no subprocess. When the data object cannot be proven jq-idempotent (see +# hook::json_compact_to), the jq path runs, now with -c so both paths write +# the same bytes, and there jq's absence is still fail-open: return 0, no +# envelope. Before this the envelope was jq's default pretty-printed form +# (several lines, and CRLF line endings with a Windows jq); the sink contract +# was always "one JSON document on stdin", and a compact line is also valid +# JSONL for a sink that appends raw envelopes. # # Usage: # hook::emit_telemetry [repo_root] @@ -1251,7 +1840,6 @@ hook::telemetry_enabled() { # NEVER writes to fd1 (the hook's stdout / additionalContext channel). hook::emit_telemetry() { [[ -n "${HOOK_TELEMETRY_SINK:-}" ]] || return 0 - command -v jq >/dev/null 2>&1 || return 0 local hook_id="$1" local hook_event="$2" @@ -1272,35 +1860,59 @@ hook::emit_telemetry() { local e_s="${now%[.,]*}" e_f="${now#*[.,]}" local duration_ms=$(((e_s * 1000000 + 10#$e_f - s_s * 1000000 - 10#$s_f) / 1000)) - # True UTC timestamp (TZ= prefix overrides LC_ALL / local TZ; the Z is not a lie). - local timestamp - timestamp=$(TZ=UTC printf '%(%Y-%m-%dT%H:%M:%SZ)T' -1) - - # Build the envelope. The `data` object is written to a temp file, not passed - # as --argjson, so payloads larger than the Windows 32767-character command-line - # cap are not dropped before jq runs (#1595). /dev/stdin is not used: jq's - # --slurpfile there is not portable on Windows. Redirect jq stderr to - # /dev/null; output goes to a local variable — never to fd1. - local envelope data_file - data_file=$(mktemp) || return 0 - printf '%s' "$data_json" >"$data_file" || { - rm -f "$data_file" - return 0 - } - envelope=$(jq -n \ - --arg schema_version "1.0" \ - --arg timestamp "$timestamp" \ - --arg hook "$hook_id" \ - --arg hook_event "$hook_event" \ - --arg status "$status" \ - --argjson duration_ms "$duration_ms" \ - --slurpfile data "$data_file" \ - '{schema_version:$schema_version,timestamp:$timestamp,hook:$hook,hook_event:$hook_event,status:$status,duration_ms:$duration_ms,data:($data[0] // {})}' \ - 2>/dev/null) || { + # True UTC timestamp (TZ= prefix overrides LC_ALL / local TZ; the Z is not a + # lie). The %()T printf format is Bash 4.2+, and printf -v keeps it in the + # shell (a `$(...)` capture is a fork); an older bash, or a printf that + # binds nothing, falls back to date -u. + local timestamp="" + TZ=UTC printf -v timestamp '%(%Y-%m-%dT%H:%M:%SZ)T' -1 2>/dev/null || timestamp="" + [[ -n "$timestamp" ]] || timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null) || timestamp="" + + # Trailing whitespace after the object (a newline a caller left on the + # value) does not change what jq parses, so it does not block the builtin + # path either. + local envelope="" data="" data_trim="$data_json" + while [[ "$data_trim" == *[$' \t\r\n'] ]]; do + data_trim="${data_trim%?}" + done + if [[ "$data_trim" == \{*\} ]] && hook::json_compact_to data "$data_trim"; then + # Builtin envelope: the same document the jq filter below produces, field + # for field, with jq's string escaping and jq's compact data. + local e_timestamp e_hook e_event e_status + hook::json_escape_jq_to e_timestamp "$timestamp" + hook::json_escape_jq_to e_hook "$hook_id" + hook::json_escape_jq_to e_event "$hook_event" + hook::json_escape_jq_to e_status "$status" + envelope='{"schema_version":"1.0","timestamp":"'"$e_timestamp"'","hook":"'"$e_hook"'","hook_event":"'"$e_event"'","status":"'"$e_status"'","duration_ms":'"$duration_ms"',"data":'"$data"'}' + else + # jq path, for a data object the builtin compactor cannot prove. Fail-open: + # jq absent → return 0. The `data` object is written to a temp file, not + # passed as --argjson, so payloads larger than the Windows 32767-character + # command-line cap are not dropped before jq runs (#1595). /dev/stdin is + # not used: jq's --slurpfile there is not portable on Windows. Redirect jq + # stderr to /dev/null; output goes to a local variable — never to fd1. + command -v jq >/dev/null 2>&1 || return 0 + local data_file + data_file=$(mktemp) || return 0 + printf '%s' "$data_json" >"$data_file" || { + rm -f "$data_file" + return 0 + } + envelope=$(jq -nc \ + --arg schema_version "1.0" \ + --arg timestamp "$timestamp" \ + --arg hook "$hook_id" \ + --arg hook_event "$hook_event" \ + --arg status "$status" \ + --argjson duration_ms "$duration_ms" \ + --slurpfile data "$data_file" \ + '{schema_version:$schema_version,timestamp:$timestamp,hook:$hook,hook_event:$hook_event,status:$status,duration_ms:$duration_ms,data:($data[0] // {})}' \ + 2>/dev/null) || { + rm -f "$data_file" + return 0 + } rm -f "$data_file" - return 0 - } - rm -f "$data_file" + fi # Resolve the sink path. A relative HOOK_TELEMETRY_SINK is joined onto the # consuming repo root (portable, tracked wiring); absolute is used as-is. A diff --git a/plugins/instruction-placement/.claude-plugin/plugin.json b/plugins/instruction-placement/.claude-plugin/plugin.json index 25f5197bbe..1d188efc4c 100644 --- a/plugins/instruction-placement/.claude-plugin/plugin.json +++ b/plugins/instruction-placement/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "instruction-placement", - "version": "0.11.20", + "version": "0.11.21", "description": "Routes agent-instruction content to the surface that loads it at the right moment. The audit skill sweeps a repository's instruction layer and its ordinary markdown for content whose scope is narrower than the surface carrying it \u2014 conventions keyed to one file type or one subtree sitting in an always-loaded CLAUDE.md or AGENTS.md \u2014 and for normative conventions stranded in documentation Claude never loads at all, then classifies each against a routing rubric and proposes a destination whose `paths:` glob is machine-validated before it is ever offered. Safety-class content (irreversible actions, secrets, data integrity, external publication, compliance, agent authority) is hard-denied from demotion and reported as held back rather than proposed, because demotion trades guaranteed presence for conditional presence and deferred surfaces are invisible inside subagents and absent after compaction until re-triggered. Every accepted move regenerates an always-loaded index of deferred surfaces, which is what keeps a demoted rule reachable from a subagent that never receives its injection. The audit is read-only and emits a diffable findings artifact; realignment is a separate skill gated per item with no blanket-approve path; a deterministic check skill gates that every rule glob still resolves and the index is current; and a setup skill verifies the one thing no other gate can see \u2014 that the index target is a file Claude Code will actually read, since it reads CLAUDE.md and not AGENTS.md.", "author": { "name": "Melodic Software", diff --git a/plugins/instruction-placement/CHANGELOG.md b/plugins/instruction-placement/CHANGELOG.md index 8651c684f2..1ea9101368 100644 --- a/plugins/instruction-placement/CHANGELOG.md +++ b/plugins/instruction-placement/CHANGELOG.md @@ -3,6 +3,22 @@ All notable changes to the `instruction-placement` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.11.21] + +### Changed + +- **Vendored `hook-utils.sh` builds the telemetry envelope and reads `file_path` + with shell builtins.** `hook::emit_telemetry` no longer spawns two jq + processes, a mktemp and an rm per run: the envelope is assembled in the shell + as one compact line (the same document jq produced, now `jq -c` shaped), with + jq kept only as the fallback for a data object the builtin compactor cannot + prove. `hook::read_file_path` takes `.tool_input.file_path` without jq on the + well-formed payload shape and resolves the file, project root and temp roots + with one batched `realpath` instead of one process each. Same verdicts, same + emitted path, same sink record; phase 4b of the hook-performance program + (#3623). The copy is bumped because `scripts/sync-hook-utils.sh` keeps every + carrying plugin byte-identical. + ## [0.11.20] ### Changed diff --git a/plugins/instruction-placement/hooks/hook-utils.sh b/plugins/instruction-placement/hooks/hook-utils.sh index 9818a6a73d..18e4dd5745 100644 --- a/plugins/instruction-placement/hooks/hook-utils.sh +++ b/plugins/instruction-placement/hooks/hook-utils.sh @@ -390,19 +390,34 @@ hook::require_jq_blocking() { # collapse with `/c/repo` and the membership guard would admit a sibling # outside CLAUDE_PROJECT_DIR. The result is used ONLY for comparison; the # emitted path is always the caller's original. -hook::normalize_path() { - local p="${1//\\//}" +# +# Two spellings of every path helper below: `hook::` prints the answer +# (the public, subshell-friendly form every caller already uses) and +# `hook::_to ` stores it in the caller's variable instead. +# The `_to` form is what the hot path uses: a `$(...)` capture is a fork, and +# on Windows Git Bash a fork costs milliseconds, so the file_path guard that +# ran a dozen of them per hook now runs none it does not need. The `_to` +# helpers keep their locals under a `__hu_` prefix so the caller's variable +# name cannot collide with them. +hook::normalize_path_to() { + local __hu_p="${2//\\//}" case "${OSTYPE:-}" in msys* | cygwin* | win32) - if [[ "$p" =~ ^/([a-zA-Z])/ || "$p" =~ ^([a-zA-Z]):/ ]]; then - local rest="${p:2}" - printf '%s' "${BASH_REMATCH[1]^}:${rest,,}" - return + if [[ "$__hu_p" =~ ^/([a-zA-Z])/ || "$__hu_p" =~ ^([a-zA-Z]):/ ]]; then + local __hu_rest="${__hu_p:2}" + printf -v "$1" '%s' "${BASH_REMATCH[1]^}:${__hu_rest,,}" + return 0 fi ;; *) ;; # POSIX hosts: case-sensitive FS, no drive fold — pass through below esac - printf '%s' "$p" + printf -v "$1" '%s' "$__hu_p" +} + +hook::normalize_path() { + local __hu_n + hook::normalize_path_to __hu_n "$1" + printf '%s' "$__hu_n" } # Expand Windows 8.3 short-name components (KYLESE~1 → KyleSexton) on @@ -424,25 +439,31 @@ hook::normalize_path() { # class: cygpath ships with Git Bash (the documented Windows bash), so its # absence or failure keeps the resolver's answer unchanged — degrading to the # pre-expansion comparison, same doctrine as the resolver fallback below. -hook::expand_8dot3() { - local p="$1" +hook::expand_8dot3_to() { + local __hu_p="$2" case "${OSTYPE:-}" in msys* | cygwin* | win32) ;; *) - printf '%s' "$p" - return + printf -v "$1" '%s' "$__hu_p" + return 0 ;; esac - if [[ "$p" == *~* ]] && command -v cygpath >/dev/null 2>&1; then - local plain long - if plain=$(cygpath -m -- "$p" 2>/dev/null) && - long=$(cygpath -l -m -- "$p" 2>/dev/null) && - [[ -n "$long" && "$long" != "$plain" ]]; then - printf '%s' "$long" - return + if [[ "$__hu_p" == *~* ]] && command -v cygpath >/dev/null 2>&1; then + local __hu_plain __hu_long + if __hu_plain=$(cygpath -m -- "$__hu_p" 2>/dev/null) && + __hu_long=$(cygpath -l -m -- "$__hu_p" 2>/dev/null) && + [[ -n "$__hu_long" && "$__hu_long" != "$__hu_plain" ]]; then + printf -v "$1" '%s' "$__hu_long" + return 0 fi fi - printf '%s' "$p" + printf -v "$1" '%s' "$__hu_p" +} + +hook::expand_8dot3() { + local __hu_e + hook::expand_8dot3_to __hu_e "$1" + printf '%s' "$__hu_e" } # Canonicalize to a physical path — symlinks resolved, Windows 8.3 short names @@ -462,20 +483,148 @@ hook::expand_8dot3() { # expansion applies only on the resolver's success path: consumers that fail # closed on an unresolved signature must not see a form-converted path instead. # shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED -hook::physical_path() { - local resolved +hook::physical_path_to() { + local __hu_r HOOK_PHYSICAL_PATH_UNRESOLVED=0 - if resolved=$(realpath -- "$1" 2>/dev/null) || resolved=$(readlink -f -- "$1" 2>/dev/null); then - if [[ -n "$resolved" ]]; then - hook::expand_8dot3 "$resolved" + if __hu_r=$(realpath -- "$2" 2>/dev/null) || __hu_r=$(readlink -f -- "$2" 2>/dev/null); then + if [[ -n "$__hu_r" ]]; then + hook::expand_8dot3_to "$1" "$__hu_r" return 0 fi fi HOOK_PHYSICAL_PATH_UNRESOLVED=1 - printf '%s' "$1" + printf -v "$1" '%s' "$2" + return 1 +} + +# shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED +hook::physical_path() { + local __hu_v __hu_rc=0 + hook::physical_path_to __hu_v "$1" || __hu_rc=$? + printf '%s' "$__hu_v" + return "$__hu_rc" +} + +# --- Per-process physical-path cache -------------------------------------- +# The membership guard resolves the same few directories on every call: the +# project root and this host's temp roots. Their physical form does not change +# within one hook process, so each spelling is resolved once and remembered in +# three parallel indexed arrays (keys, values, resolver status). Plain arrays, +# not an associative array, so the cache runs on Bash 3.2. Only directories are +# meant to live here for a process lifetime; hook::read_file_path forgets the +# edited file's entry as soon as it has read it, so a later call in the same +# process sees the file as it is then. +_HOOK_PHYS_KEYS=() +_HOOK_PHYS_VALS=() +_HOOK_PHYS_RCS=() +_HOOK_PHYS_I=-1 + +# hook::_phys_cache_index : sets _HOOK_PHYS_I to the slot holding , +# returns 1 when it is not cached. A forgotten slot has an empty key, which no +# real path can match. +hook::_phys_cache_index() { + local __hu_i + [[ -n "$1" ]] || return 1 + for ((__hu_i = 0; __hu_i < ${#_HOOK_PHYS_KEYS[@]}; __hu_i++)); do + if [[ "${_HOOK_PHYS_KEYS[__hu_i]}" == "$1" ]]; then + _HOOK_PHYS_I=$__hu_i + return 0 + fi + done return 1 } +hook::_phys_cache_forget() { + hook::_phys_cache_index "$1" && _HOOK_PHYS_KEYS[_HOOK_PHYS_I]="" + return 0 +} + +# hook::_physical_cached_to : hook::physical_path_to through the +# cache. Same value, same return status, same HOOK_PHYSICAL_PATH_UNRESOLVED as +# the uncached call; a miss resolves the one path and stores it. +# shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED +hook::_physical_cached_to() { + if hook::_phys_cache_index "$2"; then + printf -v "$1" '%s' "${_HOOK_PHYS_VALS[_HOOK_PHYS_I]}" + HOOK_PHYSICAL_PATH_UNRESOLVED=${_HOOK_PHYS_RCS[_HOOK_PHYS_I]} + return "${_HOOK_PHYS_RCS[_HOOK_PHYS_I]}" + fi + local __hu_v __hu_rc=0 + hook::physical_path_to __hu_v "$2" || __hu_rc=$? + if [[ -n "$2" ]]; then + _HOOK_PHYS_KEYS+=("$2") + _HOOK_PHYS_VALS+=("$__hu_v") + _HOOK_PHYS_RCS+=("$__hu_rc") + fi + printf -v "$1" '%s' "$__hu_v" + return "$__hu_rc" +} + +# hook::_physical_prime ...: resolve every uncached path with ONE +# realpath process and store the answers. realpath resolves each argument on +# its own and prints one line per argument in argument order, so the batch +# answer for a path is the same string the per-path call returns; the 8.3 +# expansion is applied per answer exactly as hook::physical_path_to does. Any +# doubt (a path carrying a newline, a non-zero exit, a line count that does not +# match) stores nothing and leaves the per-path resolver to answer lazily, so +# the batch can only ever save work, never change an answer. readlink -f hosts +# (no realpath) skip the batch for the same reason. +hook::_physical_prime() { + local -a __hu_todo=() + local __hu_p __hu_out __hu_i __hu_seen="" __hu_v + for __hu_p in "$@"; do + [[ -n "$__hu_p" ]] || continue + [[ "$__hu_p" == *$'\n'* ]] && return 0 + case "$__hu_seen" in + *"|$__hu_p|"*) continue ;; + *) ;; # first sighting + esac + __hu_seen="$__hu_seen|$__hu_p|" + hook::_phys_cache_index "$__hu_p" && continue + __hu_todo+=("$__hu_p") + done + ((${#__hu_todo[@]} > 1)) || return 0 + command -v realpath >/dev/null 2>&1 || return 0 + # portability-ok: realpath with several operands is GNU and BSD alike; a host whose realpath rejects it fails the exit-status check and falls back per path + __hu_out=$(realpath -- "${__hu_todo[@]}" 2>/dev/null) || return 0 + local -a __hu_lines=() + local __hu_glob=0 + [[ $- == *f* ]] || __hu_glob=1 + set -f + local IFS=$'\n' + # shellcheck disable=SC2206 # splitting realpath's one-line-per-operand output is the intent + __hu_lines=($__hu_out) + ((__hu_glob)) && set +f + ((${#__hu_lines[@]} == ${#__hu_todo[@]})) || return 0 + for ((__hu_i = 0; __hu_i < ${#__hu_todo[@]}; __hu_i++)); do + [[ -n "${__hu_lines[__hu_i]}" ]] || return 0 + hook::expand_8dot3_to __hu_v "${__hu_lines[__hu_i]}" + _HOOK_PHYS_KEYS+=("${__hu_todo[__hu_i]}") + _HOOK_PHYS_VALS+=("$__hu_v") + _HOOK_PHYS_RCS+=(0) + done + return 0 +} + +# The temp-root candidates hook::under_temp_root compares against: the +# environment's own answer (TMPDIR/TMP/TEMP) plus the POSIX defaults, never a +# hardcoded platform assumption. Existing directories only, each spelling +# once, in _HOOK_TEMP_CANDS. +_HOOK_TEMP_CANDS=() +hook::_temp_root_candidates() { + local __hu_cand __hu_seen="" + _HOOK_TEMP_CANDS=() + for __hu_cand in "${TMPDIR:-}" "${TMP:-}" "${TEMP:-}" /tmp /var/tmp; do + [[ -n "$__hu_cand" && -d "$__hu_cand" ]] || continue + case "$__hu_seen" in + *"|$__hu_cand|"*) continue ;; + *) ;; # first sighting of this candidate + esac + __hu_seen="$__hu_seen|$__hu_cand|" + _HOOK_TEMP_CANDS+=("$__hu_cand") + done +} + # True when sits inside one of this host's temp trees. # Both arguments and candidates go through the same canonicalize+normalize # pipeline as the membership comparison, because the same directory has several @@ -483,21 +632,40 @@ hook::physical_path() { # of the identical directory, and `realpath` resolves the Windows form to a # drive path while leaving `/tmp` as `/tmp`. Neither form alone matches a # `file_path` that could arrive in either, so every candidate is compared and a -# match on any one is a match. Duplicates are resolved once. +# match on any one is a match. Duplicates are resolved once, and each +# candidate's physical form is remembered for the process (see the cache +# above), so a second call costs no resolver process. # -# Candidates are the environment's own answer (TMPDIR/TMP/TEMP) plus the POSIX -# defaults, never a hardcoded platform assumption. +# Raw-spelling shortcut: when the caller vouches that is a +# PHYSICAL path (_HOOK_UTR_TARGET_PHYSICAL=1, set only by hook::read_file_path +# after the resolver succeeded), a candidate whose raw normalized spelling is +# already a prefix of the target is a match without resolving it. A physical +# path contains no symlink component, so a candidate that spells one of its +# prefixes names a chain of real directories whose physical form is that same +# prefix; the resolver would answer the same. A candidate that does not match +# raw is still resolved, so a symlinked temp root (macOS /tmp) is found the +# way it always was. Without the vouch the shortcut is off and every candidate +# is resolved, exactly as before. Note that hook::read_file_path primes every +# candidate into the cache with one batched realpath before calling here, so +# on that path the shortcut skips a lookup, not a process; the batch is what +# saves the processes. A direct caller without the batch saves the resolver. # hook::under_temp_root "$norm_path" && ... +_HOOK_UTR_TARGET_PHYSICAL=0 hook::under_temp_root() { - local target="$1" cand norm seen="" - for cand in "${TMPDIR:-}" "${TMP:-}" "${TEMP:-}" /tmp /var/tmp; do - [[ -n "$cand" && -d "$cand" ]] || continue - case "$seen" in - *"|$cand|"*) continue ;; - *) ;; # first sighting of this candidate — resolve it below - esac - seen="$seen|$cand|" - norm=$(hook::normalize_path "$(hook::physical_path "$cand")") + local target="$1" cand norm phys + hook::_temp_root_candidates + for cand in ${_HOOK_TEMP_CANDS[@]+"${_HOOK_TEMP_CANDS[@]}"}; do + if ((_HOOK_UTR_TARGET_PHYSICAL)); then + hook::normalize_path_to norm "$cand" + if [[ "$norm" != / ]]; then + norm="${norm%/}" + if [[ -n "$norm" && ("$target" == "$norm" || "$target" == "$norm"/*) ]]; then + return 0 + fi + fi + fi + hook::_physical_cached_to phys "$cand" || : + hook::normalize_path_to norm "$phys" # The filesystem root as a temp candidate contains every absolute path; # trimming its only slash would empty the candidate and discard it. [[ "$norm" == / ]] && return 0 @@ -523,21 +691,421 @@ hook::in_git_working_tree() { ) >/dev/null 2>&1 } +# --- Builtin JSON helpers ---------------------------------------------------- +# hook::read_file_path and hook::emit_telemetry used to spend a jq process each +# on every hook run. On Windows Git Bash one spawn costs tens of milliseconds, +# so the helpers here handle the well-formed shapes those two functions see in +# practice with shell builtins and hand anything they cannot PROVE to the +# unchanged jq path. The contract is "the bytes jq would produce, or fall +# back", never "close enough". +# +# What the builtin paths rely on: the text is valid JSON. Every hook payload +# reaching hook::read_file_path has passed hook::buffer_stdin's jq validation, +# and every telemetry data object is built with jq. The helpers still verify +# the structure they walk (terminated strings, well-formed tokens, properly +# nested brackets, one root, no raw control bytes inside strings), but they +# are not a full JSON parser and do not claim to reject every malformed text. +# +# Why no scanning: on this repo's Windows hosts bash regex matching and the +# `%%`/`##` pattern operators cost about a microsecond per character, so a +# 60 KB Write payload would cost more than the jq process they replace. The +# only string operations used on the whole payload are literal-substring +# replacement, `[[ == *x* ]]` containment, IFS word splitting and offset +# slicing, all of which run at C speed. + +# hook::_json_split +# Split at every unescaped double quote into the global array +# _HOOK_JSON_PARTS: even indices hold the structural text between strings, odd +# indices hold string bodies. Before splitting, every `\\` and `\"` pair is +# replaced by `@@` (same length), so a quote that survives is a real string +# delimiter and a body's ORIGINAL bytes are ${text:offset:length}. Returns 1 +# when the text is too large, its quotes do not pair up, or it holds more +# strings than the callers' loops are meant to walk. +_HOOK_JSON_PARTS=() +hook::_json_split() { + local __hu_s="$1" __hu_t __hu_q __hu_n + ((${#__hu_s} <= 65536)) || return 1 + __hu_t=${__hu_s//"\\\\"/@@} + __hu_t=${__hu_t//"\\\""/@@} + __hu_q=${__hu_t//\"/} + __hu_n=$((${#__hu_t} - ${#__hu_q})) + ((__hu_n % 2 == 0)) || return 1 + ((__hu_n <= 800)) || return 1 + local __hu_glob=0 + [[ $- == *f* ]] || __hu_glob=1 + set -f + local IFS='"' + # shellcheck disable=SC2206 # splitting on IFS='"' is the whole point + _HOOK_JSON_PARTS=($__hu_t) + ((__hu_glob)) && set +f + ((${#_HOOK_JSON_PARTS[@]} % 2 == 1)) || return 1 + return 0 +} + +# hook::_json_skeleton +# Split (hook::_json_split), record each string body's byte offset in +# _HOOK_JSON_OFF, and build _HOOK_JSON_SK: the structural text with every +# string replaced by a numbered placeholder `"#"` and all JSON +# whitespace removed. The skeleton is small (no string bodies), so regex and +# per-token work on it is cheap. Returns 1 unless the text is exactly one JSON +# value by the JSON grammar (well-formed tokens, whitespace only between them, +# objects as key:value lists, arrays as value lists, brackets nested and +# closed once) whose strings carry no raw control byte, which jq rejects. +_HOOK_JSON_SK="" +_HOOK_JSON_OFF=() +hook::_json_skeleton() { + local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top + hook::_json_split "$__hu_s" || return 1 + __hu_n=${#_HOOK_JSON_PARTS[@]} + _HOOK_JSON_OFF=() + for ((__hu_i = 0; __hu_i < __hu_n; __hu_i++)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + if ((__hu_i % 2 == 0)); then + __hu_sk+=$__hu_part + else + _HOOK_JSON_OFF[__hu_i]=$__hu_off + [[ "$__hu_part" == *[[:cntrl:]]* ]] && return 1 + __hu_sk+="\"#$__hu_i\"" + fi + __hu_off=$((__hu_off + ${#__hu_part} + 1)) + done + # Tokenize the skeleton and run it through the JSON grammar. Each token is + # consumed by one anchored regex on the (small) remainder; whitespace is + # allowed only between tokens, so `tr ue` is two bad tokens, not `true`. + local __hu_re=$'^[ \t\n\r]*(\\{|\\}|\\[|\\]|,|:|"#[0-9]+"|-?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][-+]?[0-9]+)?|true|false|null)' + local __hu_ws=$'^[ \t\n\r]*$' + __hu_rest=$__hu_sk + while [[ -n "$__hu_rest" ]] && ! [[ "$__hu_rest" =~ $__hu_ws ]]; do + [[ "$__hu_rest" =~ $__hu_re ]] || return 1 + __hu_tok=${BASH_REMATCH[1]} + __hu_rest=${__hu_rest:${#BASH_REMATCH[0]}} + __hu_top=${__hu_stack:${#__hu_stack}-1:1} + case "$__hu_expect" in + value | value_or_end) + case "$__hu_tok" in + '{') + __hu_stack+='{' + __hu_expect=key_or_end + continue + ;; + '[') + __hu_stack+='[' + __hu_expect=value_or_end + continue + ;; + ']') + [[ "$__hu_expect" == value_or_end ]] || return 1 + ;; + '}' | ',' | ':') return 1 ;; + *) + # A scalar. Inside a container the next token is , or the close; + # at the top level nothing may follow. + if [[ -n "$__hu_stack" ]]; then __hu_expect=comma_or_end; else __hu_expect=end; fi + continue + ;; + esac + ;; + key_or_end | key) + case "$__hu_tok" in + \"#*) + __hu_expect='colon' + continue + ;; + '}') [[ "$__hu_expect" == key_or_end ]] || return 1 ;; + *) return 1 ;; + esac + ;; + colon) + [[ "$__hu_tok" == ':' ]] || return 1 + __hu_expect=value + continue + ;; + comma_or_end) + case "$__hu_tok" in + ',') + if [[ "$__hu_top" == '{' ]]; then __hu_expect=key; else __hu_expect=value; fi + continue + ;; + '}') [[ "$__hu_top" == '{' ]] || return 1 ;; + ']') [[ "$__hu_top" == '[' ]] || return 1 ;; + *) return 1 ;; + esac + ;; + *) return 1 ;; # `end`: a token after the root value closed + esac + # Reaching here means a container just closed. + [[ -n "$__hu_stack" ]] || return 1 + __hu_stack=${__hu_stack:0:${#__hu_stack}-1} + if [[ -n "$__hu_stack" ]]; then __hu_expect=comma_or_end; else __hu_expect=end; fi + done + [[ "$__hu_expect" == end ]] || return 1 + _HOOK_JSON_SK=${__hu_sk//[$' \t\n\r']/} + return 0 +} + +# hook::json_unescape_to +# Decode a JSON string body (the bytes between the quotes) into . Handles +# \" \\ \/ \b \f \n \r \t and \uXXXX for U+0001 to U+007F. Returns 1 on any +# other escape (a \u outside that range, U+0000 which bash cannot hold, or an +# invalid escape), leaving the caller to fall back to jq. +hook::json_unescape_to() { + local __hu_raw="$2" __hu_out="" __hu_pre __hu_c __hu_hex __hu_oct __hu_ch + while [[ "$__hu_raw" == *\\* ]]; do + __hu_pre=${__hu_raw%%\\*} + __hu_out+=$__hu_pre + __hu_raw=${__hu_raw:${#__hu_pre}+1} + __hu_c=${__hu_raw:0:1} + __hu_raw=${__hu_raw:1} + case "$__hu_c" in + '"') __hu_out+='"' ;; + "\\") __hu_out+="\\" ;; + '/') __hu_out+='/' ;; + b) __hu_out+=$'\b' ;; # portability-ok: bash ANSI-C backspace byte, not a GNU grep word boundary + f) __hu_out+=$'\f' ;; + n) __hu_out+=$'\n' ;; + r) __hu_out+=$'\r' ;; + t) __hu_out+=$'\t' ;; + u) + __hu_hex=${__hu_raw:0:4} + [[ "$__hu_hex" =~ ^00[0-7][0-9a-fA-F]$ ]] || return 1 + [[ "$__hu_hex" == 0000 ]] && return 1 + __hu_raw=${__hu_raw:4} + printf -v __hu_oct '%03o' "$((16#$__hu_hex))" + printf -v __hu_ch '%b' "\\0$__hu_oct" + __hu_out+=$__hu_ch + ;; + *) return 1 ;; + esac + done + __hu_out+=$__hu_raw + printf -v "$1" '%s' "$__hu_out" +} + +# hook::_fast_file_path_to +# The builtin answer to `jq -r '(.tool_input.file_path // empty) | +# gsub("\r";"")'` followed by the `$(...)` capture. Returns +# 0 proven: holds exactly what jq would have printed +# 1 proven absent: jq would have printed nothing (the guard skips) +# 2 not proven: run jq +# Proof, on top of hook::_json_skeleton's structural checks: the root is an +# object; exactly one string in the whole payload decodes to `tool_input` and +# exactly one to `file_path` (so neither a duplicate key nor a same-named key +# in another object can change jq's answer); `tool_input` is a key of the root +# (depth 1, key position) whose value is a flat object (no nested object or +# array, else jq); and inside it `file_path` is a key with a plain string +# value. A payload where `file_path` names a value or sits in another object +# has no `.tool_input.file_path`, which jq reports as nothing. Key strings are +# compared AFTER decoding their escapes, so a key spelled with \u escapes is +# still recognized; a body longer than any escaped spelling of either name is +# skipped without decoding. +hook::_fast_file_path_to() { + local __hu_s="$2" __hu_i __hu_n __hu_part __hu_body __hu_ti=-1 __hu_fp=-1 __hu_m __hu_raw __hu_pre __hu_re __hu_file + hook::_json_skeleton "$__hu_s" || return 2 + [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 + __hu_n=${#_HOOK_JSON_PARTS[@]} + for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + ((${#__hu_part} <= 60)) || continue + __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} + if [[ "$__hu_body" == *\\* ]]; then + hook::json_unescape_to __hu_body "$__hu_body" || continue + fi + case "$__hu_body" in + tool_input) + ((__hu_ti < 0)) || return 2 + __hu_ti=$__hu_i + ;; + file_path) + ((__hu_fp < 0)) || return 2 + __hu_fp=$__hu_i + ;; + *) ;; + esac + done + ((__hu_fp >= 0)) || return 1 + ((__hu_ti >= 0)) || return 1 + __hu_re="(^|[{,])\"#$__hu_ti\":\\{([^][{}]*)\\}" + if [[ "$_HOOK_JSON_SK" =~ $__hu_re ]]; then + __hu_body=${BASH_REMATCH[2]} + __hu_pre=${_HOOK_JSON_SK%%\"#"$__hu_ti"\"*} + # Depth of the key: opening brackets minus closing ones before it must be + # exactly one, i.e. a direct member of the root object. + local __hu_o1=${__hu_pre//\{/} __hu_o2=${__hu_pre//\[/} __hu_c1=${__hu_pre//\}/} __hu_c2=${__hu_pre//\]/} + local __hu_depth=$(((${#__hu_pre} - ${#__hu_o1}) + (${#__hu_pre} - ${#__hu_o2}) - (${#__hu_pre} - ${#__hu_c1}) - (${#__hu_pre} - ${#__hu_c2}))) + ((__hu_depth == 1)) || return 2 + else + return 2 + fi + __hu_re="(^|,)\"#$__hu_fp\":\"#([0-9]+)\"(,|$)" + if [[ "$__hu_body" =~ $__hu_re ]]; then + __hu_m=${BASH_REMATCH[2]} + elif [[ "$__hu_body" == *\"#$__hu_fp\"* ]]; then + return 2 + else + return 1 + fi + __hu_raw=${__hu_s:${_HOOK_JSON_OFF[__hu_m]}:${#_HOOK_JSON_PARTS[__hu_m]}} + if [[ "$__hu_raw" == *\\* ]]; then + hook::json_unescape_to __hu_file "$__hu_raw" || return 2 + else + __hu_file=$__hu_raw + fi + __hu_file=${__hu_file//$'\r'/} + while [[ "$__hu_file" == *$'\n' ]]; do + __hu_file=${__hu_file%$'\n'} + done + printf -v "$1" '%s' "$__hu_file" + return 0 +} + +# hook::dirname_to : dirname with builtins for the resolver's +# answer. Strips the last segment; a bare name lives in `.`, a root-level file +# in `/`. The path comes from realpath, so the trailing-slash and doubled-slash +# spellings dirname also collapses never occur here. +hook::dirname_to() { + local __hu_d="${2%/*}" + [[ "$__hu_d" == "$2" ]] && __hu_d=. + [[ -n "$__hu_d" ]] || __hu_d=/ + printf -v "$1" '%s' "$__hu_d" +} + +# Print the arguments joined by NUL bytes, no trailing NUL: the byte stream +# hook::read_file_path read from stdin, rebuilt for the jq fallback. +hook::_print_nul_joined() { + local __hu_first=1 __hu_c + for __hu_c in "$@"; do + ((__hu_first)) || printf '\0' + __hu_first=0 + printf '%s' "$__hu_c" + done +} + +# hook::json_compact_to +# The bytes `jq -c .` prints for , when that can be proven with +# builtins: JSON whitespace between tokens removed, everything else verbatim. +# jq's own output, compact or pretty, satisfies the proof, which is what every +# telemetry data builder in this marketplace produces (jq -c, jq -n, or a +# compact literal). Returns 1 when the text is not a single object, or a +# string carries something jq would re-encode (a \u or \/ escape, an escape +# outside the JSON set, a raw control byte), or the structure fails +# hook::_json_skeleton; the caller then runs jq. +hook::json_compact_to() { + local __hu_s="$2" __hu_out="" __hu_i __hu_part __hu_n + hook::_json_skeleton "$__hu_s" || return 1 + [[ "$_HOOK_JSON_SK" == \{*\} ]] || return 1 + # Numbers: only short integer literals are passed through. A fraction, an + # exponent or an integer beyond 15 digits is rendered differently by + # different jq releases (1.6 canonicalizes, 1.7+ preserves the literal), so + # the bytes cannot be proven; jq decides. In the skeleton every string is a + # `"#N"` placeholder and letters occur only in true/false/null, so a digit + # followed by `.`, `e` or `E` can only be part of a number. + local __hu_num='[0-9][.eE]|[0-9]{16}' + [[ "$_HOOK_JSON_SK" =~ $__hu_num ]] && return 1 + __hu_n=${#_HOOK_JSON_PARTS[@]} + for ((__hu_i = 0; __hu_i < __hu_n; __hu_i++)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + if ((__hu_i % 2 == 0)); then + __hu_out+=${__hu_part//[$' \t\n\r']/} + else + if [[ "$__hu_part" == *\\* ]]; then + [[ "$__hu_part" == *'\u'* || "$__hu_part" == *'\/'* ]] && return 1 + [[ "$__hu_part" == *\\[!bfnrt]* ]] && return 1 + fi + __hu_out+="\"${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}}\"" + fi + done + printf -v "$1" '%s' "$__hu_out" +} + +# hook::json_escape_jq_to / hook::json_escape_jq +# Escape exactly as jq serializes a string value (without the +# quotes): backslash and double quote, the five short forms \b \f \n \r \t, +# every other byte from 0x01 to 0x1f and 0x7f as lowercase \u00xx, and +# everything else, including non-ASCII, verbatim. This is the escaper the +# telemetry envelope needs for byte parity with the jq envelope it replaced; +# hook::json_escape above is a different tool (it DROPS the residual control +# bytes, which is right for a notice and wrong for parity) and is unchanged. +hook::json_escape_jq_to() { + local __hu_s="$2" __hu_i __hu_c __hu_oct __hu_hex + __hu_s="${__hu_s//\\/\\\\}" + __hu_s="${__hu_s//\"/\\\"}" + if [[ "$__hu_s" == *[[:cntrl:]]* ]]; then + __hu_s="${__hu_s//$'\n'/\\n}" + __hu_s="${__hu_s//$'\r'/\\r}" + __hu_s="${__hu_s//$'\t'/\\t}" + __hu_s="${__hu_s//$'\b'/\\b}" # portability-ok: bash ANSI-C backspace byte and the JSON \b escape, not a GNU grep word boundary + __hu_s="${__hu_s//$'\f'/\\f}" + for __hu_i in 1 2 3 4 5 6 7 11 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 127; do + printf -v __hu_oct '%03o' "$__hu_i" + printf -v __hu_c '%b' "\\0$__hu_oct" + [[ "$__hu_s" == *"$__hu_c"* ]] || continue + printf -v __hu_hex '%04x' "$__hu_i" + __hu_s="${__hu_s//"$__hu_c"/\\u$__hu_hex}" + done + fi + printf -v "$1" '%s' "$__hu_s" +} + +hook::json_escape_jq() { + local __hu_e + hook::json_escape_jq_to __hu_e "$1" + printf '%s' "$__hu_e" +} + # Parse file_path from PostToolUse JSON on stdin; validate existence and (when # CLAUDE_PROJECT_DIR is set) project membership. Both sides of the membership # comparison are canonicalized (symlinks resolved) first, so neither an # escaping symlink nor a project root reached via a symlinked path (e.g. # macOS /tmp) skews the verdict. Outputs the path on success. Returns 1 to skip. # FILE=$(hook::read_file_path) || exit 0 +# +# jq-free on the common shape. The payload is read into the shell and +# hook::_fast_file_path_to takes `.tool_input.file_path` with builtins when it +# can PROVE the answer jq would give (see that helper for what "prove" means); +# every other payload goes to the unchanged jq filter. The path comparison then +# resolves the file, the project root and the temp roots with one realpath +# process instead of one each (hook::_physical_prime), and remembers the +# directories for the process. Same verdict, same emitted path, fewer +# processes: on Windows Git Bash each spawn costs tens of milliseconds and this +# guard runs on every Write and Edit. hook::read_file_path() { - local file - file=$(jq -r '(.tool_input.file_path // empty) | gsub("\r";"")' 2>/dev/null) + local -a chunks=() + local chunk file="" mode=2 + # Builtin read to NUL or EOF. A NUL splits the payload into several chunks; + # the fast path only takes a single-chunk (NUL-free) payload, and the jq + # fallback is fed the chunks NUL-joined, so it sees the bytes jq used to + # read straight from stdin. + while :; do + chunk="" + if IFS= read -r -d '' chunk; then + chunks+=("$chunk") + continue + fi + chunks+=("$chunk") + break + done + if ((${#chunks[@]} == 1)); then + mode=0 + hook::_fast_file_path_to file "${chunks[0]}" || mode=$? + fi + case "$mode" in + 0) ;; # proven: `file` holds jq's answer + 1) return 1 ;; # proven absent + *) + file=$(hook::_print_nul_joined "${chunks[@]}" | jq -r '(.tool_input.file_path // empty) | gsub("\r";"")' 2>/dev/null) + ;; + esac [[ -n "$file" ]] || return 1 [[ -f "$file" ]] || return 1 if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then - local norm_file norm_project - norm_file=$(hook::normalize_path "$(hook::physical_path "$file")") - norm_project=$(hook::normalize_path "$(hook::physical_path "${CLAUDE_PROJECT_DIR}")") + local norm_file norm_project phys_file phys_project file_resolved=0 project_resolved=0 + hook::_temp_root_candidates + hook::_physical_prime "$file" "${CLAUDE_PROJECT_DIR}" ${_HOOK_TEMP_CANDS[@]+"${_HOOK_TEMP_CANDS[@]}"} + hook::_physical_cached_to phys_file "$file" && file_resolved=1 + hook::_phys_cache_forget "$file" + hook::_physical_cached_to phys_project "${CLAUDE_PROJECT_DIR}" && project_resolved=1 + hook::normalize_path_to norm_file "$phys_file" + hook::normalize_path_to norm_project "$phys_project" norm_project="${norm_project%/}" # Anchor on a path-segment boundary: accept the project root itself or a # child under it, but not a sibling whose name merely shares the prefix @@ -558,19 +1126,28 @@ hook::read_file_path() { # built by `mktemp -d`, which is how this repo's own hook suites run), so # the branch must not fire. Only a temp-tree file reached from a project # root OUTSIDE the temp tree is scratch. - if hook::under_temp_root "$norm_file" && ! hook::under_temp_root "$norm_project"; then - return 1 + local file_in_temp=0 + _HOOK_UTR_TARGET_PHYSICAL=$file_resolved + hook::under_temp_root "$norm_file" && file_in_temp=1 + _HOOK_UTR_TARGET_PHYSICAL=0 + if ((file_in_temp)); then + local project_in_temp=0 + _HOOK_UTR_TARGET_PHYSICAL=$project_resolved + hook::under_temp_root "$norm_project" && project_in_temp=1 + _HOOK_UTR_TARGET_PHYSICAL=0 + ((project_in_temp)) || return 1 fi elif command -v git >/dev/null 2>&1; then # When CLAUDE_PROJECT_DIR is unset, scope to git-working-tree membership so # scratch files outside any repository are not mutated by formatter hooks # (#1091 / #972). - local file_physical - file_physical=$(hook::physical_path "$file") + local file_physical file_dir + hook::physical_path_to file_physical "$file" || : if [[ -L "$file" && "$file_physical" == "$file" ]]; then return 1 fi - if ! hook::in_git_working_tree "$(dirname "$file_physical")"; then + hook::dirname_to file_dir "$file_physical" + if ! hook::in_git_working_tree "$file_dir"; then return 1 fi else @@ -1228,7 +1805,19 @@ hook::telemetry_enabled() { # Fire-and-forget: sink is dispatched in the background; the hook never waits # on it and its failure never affects the hook's own exit code or stdout. # Opt-in guard: HOOK_TELEMETRY_SINK unset or empty → return 0 immediately. -# Fail-open: jq absent → return 0 immediately. +# +# The envelope is assembled with shell builtins (hook::json_escape_jq_to for +# the string fields, hook::json_compact_to for the caller's data object) as +# ONE compact line, `jq -c` style: the same document, field order and +# escapes the jq filter below produces, with the data object compacted +# exactly as jq compacts its own output. That path needs no jq, no temp file +# and no subprocess. When the data object cannot be proven jq-idempotent (see +# hook::json_compact_to), the jq path runs, now with -c so both paths write +# the same bytes, and there jq's absence is still fail-open: return 0, no +# envelope. Before this the envelope was jq's default pretty-printed form +# (several lines, and CRLF line endings with a Windows jq); the sink contract +# was always "one JSON document on stdin", and a compact line is also valid +# JSONL for a sink that appends raw envelopes. # # Usage: # hook::emit_telemetry [repo_root] @@ -1251,7 +1840,6 @@ hook::telemetry_enabled() { # NEVER writes to fd1 (the hook's stdout / additionalContext channel). hook::emit_telemetry() { [[ -n "${HOOK_TELEMETRY_SINK:-}" ]] || return 0 - command -v jq >/dev/null 2>&1 || return 0 local hook_id="$1" local hook_event="$2" @@ -1272,35 +1860,59 @@ hook::emit_telemetry() { local e_s="${now%[.,]*}" e_f="${now#*[.,]}" local duration_ms=$(((e_s * 1000000 + 10#$e_f - s_s * 1000000 - 10#$s_f) / 1000)) - # True UTC timestamp (TZ= prefix overrides LC_ALL / local TZ; the Z is not a lie). - local timestamp - timestamp=$(TZ=UTC printf '%(%Y-%m-%dT%H:%M:%SZ)T' -1) - - # Build the envelope. The `data` object is written to a temp file, not passed - # as --argjson, so payloads larger than the Windows 32767-character command-line - # cap are not dropped before jq runs (#1595). /dev/stdin is not used: jq's - # --slurpfile there is not portable on Windows. Redirect jq stderr to - # /dev/null; output goes to a local variable — never to fd1. - local envelope data_file - data_file=$(mktemp) || return 0 - printf '%s' "$data_json" >"$data_file" || { - rm -f "$data_file" - return 0 - } - envelope=$(jq -n \ - --arg schema_version "1.0" \ - --arg timestamp "$timestamp" \ - --arg hook "$hook_id" \ - --arg hook_event "$hook_event" \ - --arg status "$status" \ - --argjson duration_ms "$duration_ms" \ - --slurpfile data "$data_file" \ - '{schema_version:$schema_version,timestamp:$timestamp,hook:$hook,hook_event:$hook_event,status:$status,duration_ms:$duration_ms,data:($data[0] // {})}' \ - 2>/dev/null) || { + # True UTC timestamp (TZ= prefix overrides LC_ALL / local TZ; the Z is not a + # lie). The %()T printf format is Bash 4.2+, and printf -v keeps it in the + # shell (a `$(...)` capture is a fork); an older bash, or a printf that + # binds nothing, falls back to date -u. + local timestamp="" + TZ=UTC printf -v timestamp '%(%Y-%m-%dT%H:%M:%SZ)T' -1 2>/dev/null || timestamp="" + [[ -n "$timestamp" ]] || timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null) || timestamp="" + + # Trailing whitespace after the object (a newline a caller left on the + # value) does not change what jq parses, so it does not block the builtin + # path either. + local envelope="" data="" data_trim="$data_json" + while [[ "$data_trim" == *[$' \t\r\n'] ]]; do + data_trim="${data_trim%?}" + done + if [[ "$data_trim" == \{*\} ]] && hook::json_compact_to data "$data_trim"; then + # Builtin envelope: the same document the jq filter below produces, field + # for field, with jq's string escaping and jq's compact data. + local e_timestamp e_hook e_event e_status + hook::json_escape_jq_to e_timestamp "$timestamp" + hook::json_escape_jq_to e_hook "$hook_id" + hook::json_escape_jq_to e_event "$hook_event" + hook::json_escape_jq_to e_status "$status" + envelope='{"schema_version":"1.0","timestamp":"'"$e_timestamp"'","hook":"'"$e_hook"'","hook_event":"'"$e_event"'","status":"'"$e_status"'","duration_ms":'"$duration_ms"',"data":'"$data"'}' + else + # jq path, for a data object the builtin compactor cannot prove. Fail-open: + # jq absent → return 0. The `data` object is written to a temp file, not + # passed as --argjson, so payloads larger than the Windows 32767-character + # command-line cap are not dropped before jq runs (#1595). /dev/stdin is + # not used: jq's --slurpfile there is not portable on Windows. Redirect jq + # stderr to /dev/null; output goes to a local variable — never to fd1. + command -v jq >/dev/null 2>&1 || return 0 + local data_file + data_file=$(mktemp) || return 0 + printf '%s' "$data_json" >"$data_file" || { + rm -f "$data_file" + return 0 + } + envelope=$(jq -nc \ + --arg schema_version "1.0" \ + --arg timestamp "$timestamp" \ + --arg hook "$hook_id" \ + --arg hook_event "$hook_event" \ + --arg status "$status" \ + --argjson duration_ms "$duration_ms" \ + --slurpfile data "$data_file" \ + '{schema_version:$schema_version,timestamp:$timestamp,hook:$hook,hook_event:$hook_event,status:$status,duration_ms:$duration_ms,data:($data[0] // {})}' \ + 2>/dev/null) || { + rm -f "$data_file" + return 0 + } rm -f "$data_file" - return 0 - } - rm -f "$data_file" + fi # Resolve the sink path. A relative HOOK_TELEMETRY_SINK is joined onto the # consuming repo root (portable, tracked wiring); absolute is used as-is. A diff --git a/plugins/markdown-format/.claude-plugin/plugin.json b/plugins/markdown-format/.claude-plugin/plugin.json index 4a96d77bae..f5f3d427e3 100644 --- a/plugins/markdown-format/.claude-plugin/plugin.json +++ b/plugins/markdown-format/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "markdown-format", - "version": "0.11.38", + "version": "0.11.39", "description": "Auto-format and lint Markdown on edit via markdownlint-cli2 \u2014 only in repos that carry their own markdownlint config.", "author": { "name": "Melodic Software", diff --git a/plugins/markdown-format/CHANGELOG.md b/plugins/markdown-format/CHANGELOG.md index 7063171bd9..b6e97e3ec1 100644 --- a/plugins/markdown-format/CHANGELOG.md +++ b/plugins/markdown-format/CHANGELOG.md @@ -3,6 +3,22 @@ All notable changes to the `markdown-format` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.11.39] + +### Changed + +- **Vendored `hook-utils.sh` builds the telemetry envelope and reads `file_path` + with shell builtins.** `hook::emit_telemetry` no longer spawns two jq + processes, a mktemp and an rm per run: the envelope is assembled in the shell + as one compact line (the same document jq produced, now `jq -c` shaped), with + jq kept only as the fallback for a data object the builtin compactor cannot + prove. `hook::read_file_path` takes `.tool_input.file_path` without jq on the + well-formed payload shape and resolves the file, project root and temp roots + with one batched `realpath` instead of one process each. Same verdicts, same + emitted path, same sink record; phase 4b of the hook-performance program + (#3623). The copy is bumped because `scripts/sync-hook-utils.sh` keeps every + carrying plugin byte-identical. + ## [0.11.38] ### Changed diff --git a/plugins/markdown-format/hooks/hook-utils.sh b/plugins/markdown-format/hooks/hook-utils.sh index 9818a6a73d..18e4dd5745 100644 --- a/plugins/markdown-format/hooks/hook-utils.sh +++ b/plugins/markdown-format/hooks/hook-utils.sh @@ -390,19 +390,34 @@ hook::require_jq_blocking() { # collapse with `/c/repo` and the membership guard would admit a sibling # outside CLAUDE_PROJECT_DIR. The result is used ONLY for comparison; the # emitted path is always the caller's original. -hook::normalize_path() { - local p="${1//\\//}" +# +# Two spellings of every path helper below: `hook::` prints the answer +# (the public, subshell-friendly form every caller already uses) and +# `hook::_to ` stores it in the caller's variable instead. +# The `_to` form is what the hot path uses: a `$(...)` capture is a fork, and +# on Windows Git Bash a fork costs milliseconds, so the file_path guard that +# ran a dozen of them per hook now runs none it does not need. The `_to` +# helpers keep their locals under a `__hu_` prefix so the caller's variable +# name cannot collide with them. +hook::normalize_path_to() { + local __hu_p="${2//\\//}" case "${OSTYPE:-}" in msys* | cygwin* | win32) - if [[ "$p" =~ ^/([a-zA-Z])/ || "$p" =~ ^([a-zA-Z]):/ ]]; then - local rest="${p:2}" - printf '%s' "${BASH_REMATCH[1]^}:${rest,,}" - return + if [[ "$__hu_p" =~ ^/([a-zA-Z])/ || "$__hu_p" =~ ^([a-zA-Z]):/ ]]; then + local __hu_rest="${__hu_p:2}" + printf -v "$1" '%s' "${BASH_REMATCH[1]^}:${__hu_rest,,}" + return 0 fi ;; *) ;; # POSIX hosts: case-sensitive FS, no drive fold — pass through below esac - printf '%s' "$p" + printf -v "$1" '%s' "$__hu_p" +} + +hook::normalize_path() { + local __hu_n + hook::normalize_path_to __hu_n "$1" + printf '%s' "$__hu_n" } # Expand Windows 8.3 short-name components (KYLESE~1 → KyleSexton) on @@ -424,25 +439,31 @@ hook::normalize_path() { # class: cygpath ships with Git Bash (the documented Windows bash), so its # absence or failure keeps the resolver's answer unchanged — degrading to the # pre-expansion comparison, same doctrine as the resolver fallback below. -hook::expand_8dot3() { - local p="$1" +hook::expand_8dot3_to() { + local __hu_p="$2" case "${OSTYPE:-}" in msys* | cygwin* | win32) ;; *) - printf '%s' "$p" - return + printf -v "$1" '%s' "$__hu_p" + return 0 ;; esac - if [[ "$p" == *~* ]] && command -v cygpath >/dev/null 2>&1; then - local plain long - if plain=$(cygpath -m -- "$p" 2>/dev/null) && - long=$(cygpath -l -m -- "$p" 2>/dev/null) && - [[ -n "$long" && "$long" != "$plain" ]]; then - printf '%s' "$long" - return + if [[ "$__hu_p" == *~* ]] && command -v cygpath >/dev/null 2>&1; then + local __hu_plain __hu_long + if __hu_plain=$(cygpath -m -- "$__hu_p" 2>/dev/null) && + __hu_long=$(cygpath -l -m -- "$__hu_p" 2>/dev/null) && + [[ -n "$__hu_long" && "$__hu_long" != "$__hu_plain" ]]; then + printf -v "$1" '%s' "$__hu_long" + return 0 fi fi - printf '%s' "$p" + printf -v "$1" '%s' "$__hu_p" +} + +hook::expand_8dot3() { + local __hu_e + hook::expand_8dot3_to __hu_e "$1" + printf '%s' "$__hu_e" } # Canonicalize to a physical path — symlinks resolved, Windows 8.3 short names @@ -462,20 +483,148 @@ hook::expand_8dot3() { # expansion applies only on the resolver's success path: consumers that fail # closed on an unresolved signature must not see a form-converted path instead. # shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED -hook::physical_path() { - local resolved +hook::physical_path_to() { + local __hu_r HOOK_PHYSICAL_PATH_UNRESOLVED=0 - if resolved=$(realpath -- "$1" 2>/dev/null) || resolved=$(readlink -f -- "$1" 2>/dev/null); then - if [[ -n "$resolved" ]]; then - hook::expand_8dot3 "$resolved" + if __hu_r=$(realpath -- "$2" 2>/dev/null) || __hu_r=$(readlink -f -- "$2" 2>/dev/null); then + if [[ -n "$__hu_r" ]]; then + hook::expand_8dot3_to "$1" "$__hu_r" return 0 fi fi HOOK_PHYSICAL_PATH_UNRESOLVED=1 - printf '%s' "$1" + printf -v "$1" '%s' "$2" + return 1 +} + +# shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED +hook::physical_path() { + local __hu_v __hu_rc=0 + hook::physical_path_to __hu_v "$1" || __hu_rc=$? + printf '%s' "$__hu_v" + return "$__hu_rc" +} + +# --- Per-process physical-path cache -------------------------------------- +# The membership guard resolves the same few directories on every call: the +# project root and this host's temp roots. Their physical form does not change +# within one hook process, so each spelling is resolved once and remembered in +# three parallel indexed arrays (keys, values, resolver status). Plain arrays, +# not an associative array, so the cache runs on Bash 3.2. Only directories are +# meant to live here for a process lifetime; hook::read_file_path forgets the +# edited file's entry as soon as it has read it, so a later call in the same +# process sees the file as it is then. +_HOOK_PHYS_KEYS=() +_HOOK_PHYS_VALS=() +_HOOK_PHYS_RCS=() +_HOOK_PHYS_I=-1 + +# hook::_phys_cache_index : sets _HOOK_PHYS_I to the slot holding , +# returns 1 when it is not cached. A forgotten slot has an empty key, which no +# real path can match. +hook::_phys_cache_index() { + local __hu_i + [[ -n "$1" ]] || return 1 + for ((__hu_i = 0; __hu_i < ${#_HOOK_PHYS_KEYS[@]}; __hu_i++)); do + if [[ "${_HOOK_PHYS_KEYS[__hu_i]}" == "$1" ]]; then + _HOOK_PHYS_I=$__hu_i + return 0 + fi + done return 1 } +hook::_phys_cache_forget() { + hook::_phys_cache_index "$1" && _HOOK_PHYS_KEYS[_HOOK_PHYS_I]="" + return 0 +} + +# hook::_physical_cached_to : hook::physical_path_to through the +# cache. Same value, same return status, same HOOK_PHYSICAL_PATH_UNRESOLVED as +# the uncached call; a miss resolves the one path and stores it. +# shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED +hook::_physical_cached_to() { + if hook::_phys_cache_index "$2"; then + printf -v "$1" '%s' "${_HOOK_PHYS_VALS[_HOOK_PHYS_I]}" + HOOK_PHYSICAL_PATH_UNRESOLVED=${_HOOK_PHYS_RCS[_HOOK_PHYS_I]} + return "${_HOOK_PHYS_RCS[_HOOK_PHYS_I]}" + fi + local __hu_v __hu_rc=0 + hook::physical_path_to __hu_v "$2" || __hu_rc=$? + if [[ -n "$2" ]]; then + _HOOK_PHYS_KEYS+=("$2") + _HOOK_PHYS_VALS+=("$__hu_v") + _HOOK_PHYS_RCS+=("$__hu_rc") + fi + printf -v "$1" '%s' "$__hu_v" + return "$__hu_rc" +} + +# hook::_physical_prime ...: resolve every uncached path with ONE +# realpath process and store the answers. realpath resolves each argument on +# its own and prints one line per argument in argument order, so the batch +# answer for a path is the same string the per-path call returns; the 8.3 +# expansion is applied per answer exactly as hook::physical_path_to does. Any +# doubt (a path carrying a newline, a non-zero exit, a line count that does not +# match) stores nothing and leaves the per-path resolver to answer lazily, so +# the batch can only ever save work, never change an answer. readlink -f hosts +# (no realpath) skip the batch for the same reason. +hook::_physical_prime() { + local -a __hu_todo=() + local __hu_p __hu_out __hu_i __hu_seen="" __hu_v + for __hu_p in "$@"; do + [[ -n "$__hu_p" ]] || continue + [[ "$__hu_p" == *$'\n'* ]] && return 0 + case "$__hu_seen" in + *"|$__hu_p|"*) continue ;; + *) ;; # first sighting + esac + __hu_seen="$__hu_seen|$__hu_p|" + hook::_phys_cache_index "$__hu_p" && continue + __hu_todo+=("$__hu_p") + done + ((${#__hu_todo[@]} > 1)) || return 0 + command -v realpath >/dev/null 2>&1 || return 0 + # portability-ok: realpath with several operands is GNU and BSD alike; a host whose realpath rejects it fails the exit-status check and falls back per path + __hu_out=$(realpath -- "${__hu_todo[@]}" 2>/dev/null) || return 0 + local -a __hu_lines=() + local __hu_glob=0 + [[ $- == *f* ]] || __hu_glob=1 + set -f + local IFS=$'\n' + # shellcheck disable=SC2206 # splitting realpath's one-line-per-operand output is the intent + __hu_lines=($__hu_out) + ((__hu_glob)) && set +f + ((${#__hu_lines[@]} == ${#__hu_todo[@]})) || return 0 + for ((__hu_i = 0; __hu_i < ${#__hu_todo[@]}; __hu_i++)); do + [[ -n "${__hu_lines[__hu_i]}" ]] || return 0 + hook::expand_8dot3_to __hu_v "${__hu_lines[__hu_i]}" + _HOOK_PHYS_KEYS+=("${__hu_todo[__hu_i]}") + _HOOK_PHYS_VALS+=("$__hu_v") + _HOOK_PHYS_RCS+=(0) + done + return 0 +} + +# The temp-root candidates hook::under_temp_root compares against: the +# environment's own answer (TMPDIR/TMP/TEMP) plus the POSIX defaults, never a +# hardcoded platform assumption. Existing directories only, each spelling +# once, in _HOOK_TEMP_CANDS. +_HOOK_TEMP_CANDS=() +hook::_temp_root_candidates() { + local __hu_cand __hu_seen="" + _HOOK_TEMP_CANDS=() + for __hu_cand in "${TMPDIR:-}" "${TMP:-}" "${TEMP:-}" /tmp /var/tmp; do + [[ -n "$__hu_cand" && -d "$__hu_cand" ]] || continue + case "$__hu_seen" in + *"|$__hu_cand|"*) continue ;; + *) ;; # first sighting of this candidate + esac + __hu_seen="$__hu_seen|$__hu_cand|" + _HOOK_TEMP_CANDS+=("$__hu_cand") + done +} + # True when sits inside one of this host's temp trees. # Both arguments and candidates go through the same canonicalize+normalize # pipeline as the membership comparison, because the same directory has several @@ -483,21 +632,40 @@ hook::physical_path() { # of the identical directory, and `realpath` resolves the Windows form to a # drive path while leaving `/tmp` as `/tmp`. Neither form alone matches a # `file_path` that could arrive in either, so every candidate is compared and a -# match on any one is a match. Duplicates are resolved once. +# match on any one is a match. Duplicates are resolved once, and each +# candidate's physical form is remembered for the process (see the cache +# above), so a second call costs no resolver process. # -# Candidates are the environment's own answer (TMPDIR/TMP/TEMP) plus the POSIX -# defaults, never a hardcoded platform assumption. +# Raw-spelling shortcut: when the caller vouches that is a +# PHYSICAL path (_HOOK_UTR_TARGET_PHYSICAL=1, set only by hook::read_file_path +# after the resolver succeeded), a candidate whose raw normalized spelling is +# already a prefix of the target is a match without resolving it. A physical +# path contains no symlink component, so a candidate that spells one of its +# prefixes names a chain of real directories whose physical form is that same +# prefix; the resolver would answer the same. A candidate that does not match +# raw is still resolved, so a symlinked temp root (macOS /tmp) is found the +# way it always was. Without the vouch the shortcut is off and every candidate +# is resolved, exactly as before. Note that hook::read_file_path primes every +# candidate into the cache with one batched realpath before calling here, so +# on that path the shortcut skips a lookup, not a process; the batch is what +# saves the processes. A direct caller without the batch saves the resolver. # hook::under_temp_root "$norm_path" && ... +_HOOK_UTR_TARGET_PHYSICAL=0 hook::under_temp_root() { - local target="$1" cand norm seen="" - for cand in "${TMPDIR:-}" "${TMP:-}" "${TEMP:-}" /tmp /var/tmp; do - [[ -n "$cand" && -d "$cand" ]] || continue - case "$seen" in - *"|$cand|"*) continue ;; - *) ;; # first sighting of this candidate — resolve it below - esac - seen="$seen|$cand|" - norm=$(hook::normalize_path "$(hook::physical_path "$cand")") + local target="$1" cand norm phys + hook::_temp_root_candidates + for cand in ${_HOOK_TEMP_CANDS[@]+"${_HOOK_TEMP_CANDS[@]}"}; do + if ((_HOOK_UTR_TARGET_PHYSICAL)); then + hook::normalize_path_to norm "$cand" + if [[ "$norm" != / ]]; then + norm="${norm%/}" + if [[ -n "$norm" && ("$target" == "$norm" || "$target" == "$norm"/*) ]]; then + return 0 + fi + fi + fi + hook::_physical_cached_to phys "$cand" || : + hook::normalize_path_to norm "$phys" # The filesystem root as a temp candidate contains every absolute path; # trimming its only slash would empty the candidate and discard it. [[ "$norm" == / ]] && return 0 @@ -523,21 +691,421 @@ hook::in_git_working_tree() { ) >/dev/null 2>&1 } +# --- Builtin JSON helpers ---------------------------------------------------- +# hook::read_file_path and hook::emit_telemetry used to spend a jq process each +# on every hook run. On Windows Git Bash one spawn costs tens of milliseconds, +# so the helpers here handle the well-formed shapes those two functions see in +# practice with shell builtins and hand anything they cannot PROVE to the +# unchanged jq path. The contract is "the bytes jq would produce, or fall +# back", never "close enough". +# +# What the builtin paths rely on: the text is valid JSON. Every hook payload +# reaching hook::read_file_path has passed hook::buffer_stdin's jq validation, +# and every telemetry data object is built with jq. The helpers still verify +# the structure they walk (terminated strings, well-formed tokens, properly +# nested brackets, one root, no raw control bytes inside strings), but they +# are not a full JSON parser and do not claim to reject every malformed text. +# +# Why no scanning: on this repo's Windows hosts bash regex matching and the +# `%%`/`##` pattern operators cost about a microsecond per character, so a +# 60 KB Write payload would cost more than the jq process they replace. The +# only string operations used on the whole payload are literal-substring +# replacement, `[[ == *x* ]]` containment, IFS word splitting and offset +# slicing, all of which run at C speed. + +# hook::_json_split +# Split at every unescaped double quote into the global array +# _HOOK_JSON_PARTS: even indices hold the structural text between strings, odd +# indices hold string bodies. Before splitting, every `\\` and `\"` pair is +# replaced by `@@` (same length), so a quote that survives is a real string +# delimiter and a body's ORIGINAL bytes are ${text:offset:length}. Returns 1 +# when the text is too large, its quotes do not pair up, or it holds more +# strings than the callers' loops are meant to walk. +_HOOK_JSON_PARTS=() +hook::_json_split() { + local __hu_s="$1" __hu_t __hu_q __hu_n + ((${#__hu_s} <= 65536)) || return 1 + __hu_t=${__hu_s//"\\\\"/@@} + __hu_t=${__hu_t//"\\\""/@@} + __hu_q=${__hu_t//\"/} + __hu_n=$((${#__hu_t} - ${#__hu_q})) + ((__hu_n % 2 == 0)) || return 1 + ((__hu_n <= 800)) || return 1 + local __hu_glob=0 + [[ $- == *f* ]] || __hu_glob=1 + set -f + local IFS='"' + # shellcheck disable=SC2206 # splitting on IFS='"' is the whole point + _HOOK_JSON_PARTS=($__hu_t) + ((__hu_glob)) && set +f + ((${#_HOOK_JSON_PARTS[@]} % 2 == 1)) || return 1 + return 0 +} + +# hook::_json_skeleton +# Split (hook::_json_split), record each string body's byte offset in +# _HOOK_JSON_OFF, and build _HOOK_JSON_SK: the structural text with every +# string replaced by a numbered placeholder `"#"` and all JSON +# whitespace removed. The skeleton is small (no string bodies), so regex and +# per-token work on it is cheap. Returns 1 unless the text is exactly one JSON +# value by the JSON grammar (well-formed tokens, whitespace only between them, +# objects as key:value lists, arrays as value lists, brackets nested and +# closed once) whose strings carry no raw control byte, which jq rejects. +_HOOK_JSON_SK="" +_HOOK_JSON_OFF=() +hook::_json_skeleton() { + local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top + hook::_json_split "$__hu_s" || return 1 + __hu_n=${#_HOOK_JSON_PARTS[@]} + _HOOK_JSON_OFF=() + for ((__hu_i = 0; __hu_i < __hu_n; __hu_i++)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + if ((__hu_i % 2 == 0)); then + __hu_sk+=$__hu_part + else + _HOOK_JSON_OFF[__hu_i]=$__hu_off + [[ "$__hu_part" == *[[:cntrl:]]* ]] && return 1 + __hu_sk+="\"#$__hu_i\"" + fi + __hu_off=$((__hu_off + ${#__hu_part} + 1)) + done + # Tokenize the skeleton and run it through the JSON grammar. Each token is + # consumed by one anchored regex on the (small) remainder; whitespace is + # allowed only between tokens, so `tr ue` is two bad tokens, not `true`. + local __hu_re=$'^[ \t\n\r]*(\\{|\\}|\\[|\\]|,|:|"#[0-9]+"|-?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][-+]?[0-9]+)?|true|false|null)' + local __hu_ws=$'^[ \t\n\r]*$' + __hu_rest=$__hu_sk + while [[ -n "$__hu_rest" ]] && ! [[ "$__hu_rest" =~ $__hu_ws ]]; do + [[ "$__hu_rest" =~ $__hu_re ]] || return 1 + __hu_tok=${BASH_REMATCH[1]} + __hu_rest=${__hu_rest:${#BASH_REMATCH[0]}} + __hu_top=${__hu_stack:${#__hu_stack}-1:1} + case "$__hu_expect" in + value | value_or_end) + case "$__hu_tok" in + '{') + __hu_stack+='{' + __hu_expect=key_or_end + continue + ;; + '[') + __hu_stack+='[' + __hu_expect=value_or_end + continue + ;; + ']') + [[ "$__hu_expect" == value_or_end ]] || return 1 + ;; + '}' | ',' | ':') return 1 ;; + *) + # A scalar. Inside a container the next token is , or the close; + # at the top level nothing may follow. + if [[ -n "$__hu_stack" ]]; then __hu_expect=comma_or_end; else __hu_expect=end; fi + continue + ;; + esac + ;; + key_or_end | key) + case "$__hu_tok" in + \"#*) + __hu_expect='colon' + continue + ;; + '}') [[ "$__hu_expect" == key_or_end ]] || return 1 ;; + *) return 1 ;; + esac + ;; + colon) + [[ "$__hu_tok" == ':' ]] || return 1 + __hu_expect=value + continue + ;; + comma_or_end) + case "$__hu_tok" in + ',') + if [[ "$__hu_top" == '{' ]]; then __hu_expect=key; else __hu_expect=value; fi + continue + ;; + '}') [[ "$__hu_top" == '{' ]] || return 1 ;; + ']') [[ "$__hu_top" == '[' ]] || return 1 ;; + *) return 1 ;; + esac + ;; + *) return 1 ;; # `end`: a token after the root value closed + esac + # Reaching here means a container just closed. + [[ -n "$__hu_stack" ]] || return 1 + __hu_stack=${__hu_stack:0:${#__hu_stack}-1} + if [[ -n "$__hu_stack" ]]; then __hu_expect=comma_or_end; else __hu_expect=end; fi + done + [[ "$__hu_expect" == end ]] || return 1 + _HOOK_JSON_SK=${__hu_sk//[$' \t\n\r']/} + return 0 +} + +# hook::json_unescape_to +# Decode a JSON string body (the bytes between the quotes) into . Handles +# \" \\ \/ \b \f \n \r \t and \uXXXX for U+0001 to U+007F. Returns 1 on any +# other escape (a \u outside that range, U+0000 which bash cannot hold, or an +# invalid escape), leaving the caller to fall back to jq. +hook::json_unescape_to() { + local __hu_raw="$2" __hu_out="" __hu_pre __hu_c __hu_hex __hu_oct __hu_ch + while [[ "$__hu_raw" == *\\* ]]; do + __hu_pre=${__hu_raw%%\\*} + __hu_out+=$__hu_pre + __hu_raw=${__hu_raw:${#__hu_pre}+1} + __hu_c=${__hu_raw:0:1} + __hu_raw=${__hu_raw:1} + case "$__hu_c" in + '"') __hu_out+='"' ;; + "\\") __hu_out+="\\" ;; + '/') __hu_out+='/' ;; + b) __hu_out+=$'\b' ;; # portability-ok: bash ANSI-C backspace byte, not a GNU grep word boundary + f) __hu_out+=$'\f' ;; + n) __hu_out+=$'\n' ;; + r) __hu_out+=$'\r' ;; + t) __hu_out+=$'\t' ;; + u) + __hu_hex=${__hu_raw:0:4} + [[ "$__hu_hex" =~ ^00[0-7][0-9a-fA-F]$ ]] || return 1 + [[ "$__hu_hex" == 0000 ]] && return 1 + __hu_raw=${__hu_raw:4} + printf -v __hu_oct '%03o' "$((16#$__hu_hex))" + printf -v __hu_ch '%b' "\\0$__hu_oct" + __hu_out+=$__hu_ch + ;; + *) return 1 ;; + esac + done + __hu_out+=$__hu_raw + printf -v "$1" '%s' "$__hu_out" +} + +# hook::_fast_file_path_to +# The builtin answer to `jq -r '(.tool_input.file_path // empty) | +# gsub("\r";"")'` followed by the `$(...)` capture. Returns +# 0 proven: holds exactly what jq would have printed +# 1 proven absent: jq would have printed nothing (the guard skips) +# 2 not proven: run jq +# Proof, on top of hook::_json_skeleton's structural checks: the root is an +# object; exactly one string in the whole payload decodes to `tool_input` and +# exactly one to `file_path` (so neither a duplicate key nor a same-named key +# in another object can change jq's answer); `tool_input` is a key of the root +# (depth 1, key position) whose value is a flat object (no nested object or +# array, else jq); and inside it `file_path` is a key with a plain string +# value. A payload where `file_path` names a value or sits in another object +# has no `.tool_input.file_path`, which jq reports as nothing. Key strings are +# compared AFTER decoding their escapes, so a key spelled with \u escapes is +# still recognized; a body longer than any escaped spelling of either name is +# skipped without decoding. +hook::_fast_file_path_to() { + local __hu_s="$2" __hu_i __hu_n __hu_part __hu_body __hu_ti=-1 __hu_fp=-1 __hu_m __hu_raw __hu_pre __hu_re __hu_file + hook::_json_skeleton "$__hu_s" || return 2 + [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 + __hu_n=${#_HOOK_JSON_PARTS[@]} + for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + ((${#__hu_part} <= 60)) || continue + __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} + if [[ "$__hu_body" == *\\* ]]; then + hook::json_unescape_to __hu_body "$__hu_body" || continue + fi + case "$__hu_body" in + tool_input) + ((__hu_ti < 0)) || return 2 + __hu_ti=$__hu_i + ;; + file_path) + ((__hu_fp < 0)) || return 2 + __hu_fp=$__hu_i + ;; + *) ;; + esac + done + ((__hu_fp >= 0)) || return 1 + ((__hu_ti >= 0)) || return 1 + __hu_re="(^|[{,])\"#$__hu_ti\":\\{([^][{}]*)\\}" + if [[ "$_HOOK_JSON_SK" =~ $__hu_re ]]; then + __hu_body=${BASH_REMATCH[2]} + __hu_pre=${_HOOK_JSON_SK%%\"#"$__hu_ti"\"*} + # Depth of the key: opening brackets minus closing ones before it must be + # exactly one, i.e. a direct member of the root object. + local __hu_o1=${__hu_pre//\{/} __hu_o2=${__hu_pre//\[/} __hu_c1=${__hu_pre//\}/} __hu_c2=${__hu_pre//\]/} + local __hu_depth=$(((${#__hu_pre} - ${#__hu_o1}) + (${#__hu_pre} - ${#__hu_o2}) - (${#__hu_pre} - ${#__hu_c1}) - (${#__hu_pre} - ${#__hu_c2}))) + ((__hu_depth == 1)) || return 2 + else + return 2 + fi + __hu_re="(^|,)\"#$__hu_fp\":\"#([0-9]+)\"(,|$)" + if [[ "$__hu_body" =~ $__hu_re ]]; then + __hu_m=${BASH_REMATCH[2]} + elif [[ "$__hu_body" == *\"#$__hu_fp\"* ]]; then + return 2 + else + return 1 + fi + __hu_raw=${__hu_s:${_HOOK_JSON_OFF[__hu_m]}:${#_HOOK_JSON_PARTS[__hu_m]}} + if [[ "$__hu_raw" == *\\* ]]; then + hook::json_unescape_to __hu_file "$__hu_raw" || return 2 + else + __hu_file=$__hu_raw + fi + __hu_file=${__hu_file//$'\r'/} + while [[ "$__hu_file" == *$'\n' ]]; do + __hu_file=${__hu_file%$'\n'} + done + printf -v "$1" '%s' "$__hu_file" + return 0 +} + +# hook::dirname_to : dirname with builtins for the resolver's +# answer. Strips the last segment; a bare name lives in `.`, a root-level file +# in `/`. The path comes from realpath, so the trailing-slash and doubled-slash +# spellings dirname also collapses never occur here. +hook::dirname_to() { + local __hu_d="${2%/*}" + [[ "$__hu_d" == "$2" ]] && __hu_d=. + [[ -n "$__hu_d" ]] || __hu_d=/ + printf -v "$1" '%s' "$__hu_d" +} + +# Print the arguments joined by NUL bytes, no trailing NUL: the byte stream +# hook::read_file_path read from stdin, rebuilt for the jq fallback. +hook::_print_nul_joined() { + local __hu_first=1 __hu_c + for __hu_c in "$@"; do + ((__hu_first)) || printf '\0' + __hu_first=0 + printf '%s' "$__hu_c" + done +} + +# hook::json_compact_to +# The bytes `jq -c .` prints for , when that can be proven with +# builtins: JSON whitespace between tokens removed, everything else verbatim. +# jq's own output, compact or pretty, satisfies the proof, which is what every +# telemetry data builder in this marketplace produces (jq -c, jq -n, or a +# compact literal). Returns 1 when the text is not a single object, or a +# string carries something jq would re-encode (a \u or \/ escape, an escape +# outside the JSON set, a raw control byte), or the structure fails +# hook::_json_skeleton; the caller then runs jq. +hook::json_compact_to() { + local __hu_s="$2" __hu_out="" __hu_i __hu_part __hu_n + hook::_json_skeleton "$__hu_s" || return 1 + [[ "$_HOOK_JSON_SK" == \{*\} ]] || return 1 + # Numbers: only short integer literals are passed through. A fraction, an + # exponent or an integer beyond 15 digits is rendered differently by + # different jq releases (1.6 canonicalizes, 1.7+ preserves the literal), so + # the bytes cannot be proven; jq decides. In the skeleton every string is a + # `"#N"` placeholder and letters occur only in true/false/null, so a digit + # followed by `.`, `e` or `E` can only be part of a number. + local __hu_num='[0-9][.eE]|[0-9]{16}' + [[ "$_HOOK_JSON_SK" =~ $__hu_num ]] && return 1 + __hu_n=${#_HOOK_JSON_PARTS[@]} + for ((__hu_i = 0; __hu_i < __hu_n; __hu_i++)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + if ((__hu_i % 2 == 0)); then + __hu_out+=${__hu_part//[$' \t\n\r']/} + else + if [[ "$__hu_part" == *\\* ]]; then + [[ "$__hu_part" == *'\u'* || "$__hu_part" == *'\/'* ]] && return 1 + [[ "$__hu_part" == *\\[!bfnrt]* ]] && return 1 + fi + __hu_out+="\"${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}}\"" + fi + done + printf -v "$1" '%s' "$__hu_out" +} + +# hook::json_escape_jq_to / hook::json_escape_jq +# Escape exactly as jq serializes a string value (without the +# quotes): backslash and double quote, the five short forms \b \f \n \r \t, +# every other byte from 0x01 to 0x1f and 0x7f as lowercase \u00xx, and +# everything else, including non-ASCII, verbatim. This is the escaper the +# telemetry envelope needs for byte parity with the jq envelope it replaced; +# hook::json_escape above is a different tool (it DROPS the residual control +# bytes, which is right for a notice and wrong for parity) and is unchanged. +hook::json_escape_jq_to() { + local __hu_s="$2" __hu_i __hu_c __hu_oct __hu_hex + __hu_s="${__hu_s//\\/\\\\}" + __hu_s="${__hu_s//\"/\\\"}" + if [[ "$__hu_s" == *[[:cntrl:]]* ]]; then + __hu_s="${__hu_s//$'\n'/\\n}" + __hu_s="${__hu_s//$'\r'/\\r}" + __hu_s="${__hu_s//$'\t'/\\t}" + __hu_s="${__hu_s//$'\b'/\\b}" # portability-ok: bash ANSI-C backspace byte and the JSON \b escape, not a GNU grep word boundary + __hu_s="${__hu_s//$'\f'/\\f}" + for __hu_i in 1 2 3 4 5 6 7 11 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 127; do + printf -v __hu_oct '%03o' "$__hu_i" + printf -v __hu_c '%b' "\\0$__hu_oct" + [[ "$__hu_s" == *"$__hu_c"* ]] || continue + printf -v __hu_hex '%04x' "$__hu_i" + __hu_s="${__hu_s//"$__hu_c"/\\u$__hu_hex}" + done + fi + printf -v "$1" '%s' "$__hu_s" +} + +hook::json_escape_jq() { + local __hu_e + hook::json_escape_jq_to __hu_e "$1" + printf '%s' "$__hu_e" +} + # Parse file_path from PostToolUse JSON on stdin; validate existence and (when # CLAUDE_PROJECT_DIR is set) project membership. Both sides of the membership # comparison are canonicalized (symlinks resolved) first, so neither an # escaping symlink nor a project root reached via a symlinked path (e.g. # macOS /tmp) skews the verdict. Outputs the path on success. Returns 1 to skip. # FILE=$(hook::read_file_path) || exit 0 +# +# jq-free on the common shape. The payload is read into the shell and +# hook::_fast_file_path_to takes `.tool_input.file_path` with builtins when it +# can PROVE the answer jq would give (see that helper for what "prove" means); +# every other payload goes to the unchanged jq filter. The path comparison then +# resolves the file, the project root and the temp roots with one realpath +# process instead of one each (hook::_physical_prime), and remembers the +# directories for the process. Same verdict, same emitted path, fewer +# processes: on Windows Git Bash each spawn costs tens of milliseconds and this +# guard runs on every Write and Edit. hook::read_file_path() { - local file - file=$(jq -r '(.tool_input.file_path // empty) | gsub("\r";"")' 2>/dev/null) + local -a chunks=() + local chunk file="" mode=2 + # Builtin read to NUL or EOF. A NUL splits the payload into several chunks; + # the fast path only takes a single-chunk (NUL-free) payload, and the jq + # fallback is fed the chunks NUL-joined, so it sees the bytes jq used to + # read straight from stdin. + while :; do + chunk="" + if IFS= read -r -d '' chunk; then + chunks+=("$chunk") + continue + fi + chunks+=("$chunk") + break + done + if ((${#chunks[@]} == 1)); then + mode=0 + hook::_fast_file_path_to file "${chunks[0]}" || mode=$? + fi + case "$mode" in + 0) ;; # proven: `file` holds jq's answer + 1) return 1 ;; # proven absent + *) + file=$(hook::_print_nul_joined "${chunks[@]}" | jq -r '(.tool_input.file_path // empty) | gsub("\r";"")' 2>/dev/null) + ;; + esac [[ -n "$file" ]] || return 1 [[ -f "$file" ]] || return 1 if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then - local norm_file norm_project - norm_file=$(hook::normalize_path "$(hook::physical_path "$file")") - norm_project=$(hook::normalize_path "$(hook::physical_path "${CLAUDE_PROJECT_DIR}")") + local norm_file norm_project phys_file phys_project file_resolved=0 project_resolved=0 + hook::_temp_root_candidates + hook::_physical_prime "$file" "${CLAUDE_PROJECT_DIR}" ${_HOOK_TEMP_CANDS[@]+"${_HOOK_TEMP_CANDS[@]}"} + hook::_physical_cached_to phys_file "$file" && file_resolved=1 + hook::_phys_cache_forget "$file" + hook::_physical_cached_to phys_project "${CLAUDE_PROJECT_DIR}" && project_resolved=1 + hook::normalize_path_to norm_file "$phys_file" + hook::normalize_path_to norm_project "$phys_project" norm_project="${norm_project%/}" # Anchor on a path-segment boundary: accept the project root itself or a # child under it, but not a sibling whose name merely shares the prefix @@ -558,19 +1126,28 @@ hook::read_file_path() { # built by `mktemp -d`, which is how this repo's own hook suites run), so # the branch must not fire. Only a temp-tree file reached from a project # root OUTSIDE the temp tree is scratch. - if hook::under_temp_root "$norm_file" && ! hook::under_temp_root "$norm_project"; then - return 1 + local file_in_temp=0 + _HOOK_UTR_TARGET_PHYSICAL=$file_resolved + hook::under_temp_root "$norm_file" && file_in_temp=1 + _HOOK_UTR_TARGET_PHYSICAL=0 + if ((file_in_temp)); then + local project_in_temp=0 + _HOOK_UTR_TARGET_PHYSICAL=$project_resolved + hook::under_temp_root "$norm_project" && project_in_temp=1 + _HOOK_UTR_TARGET_PHYSICAL=0 + ((project_in_temp)) || return 1 fi elif command -v git >/dev/null 2>&1; then # When CLAUDE_PROJECT_DIR is unset, scope to git-working-tree membership so # scratch files outside any repository are not mutated by formatter hooks # (#1091 / #972). - local file_physical - file_physical=$(hook::physical_path "$file") + local file_physical file_dir + hook::physical_path_to file_physical "$file" || : if [[ -L "$file" && "$file_physical" == "$file" ]]; then return 1 fi - if ! hook::in_git_working_tree "$(dirname "$file_physical")"; then + hook::dirname_to file_dir "$file_physical" + if ! hook::in_git_working_tree "$file_dir"; then return 1 fi else @@ -1228,7 +1805,19 @@ hook::telemetry_enabled() { # Fire-and-forget: sink is dispatched in the background; the hook never waits # on it and its failure never affects the hook's own exit code or stdout. # Opt-in guard: HOOK_TELEMETRY_SINK unset or empty → return 0 immediately. -# Fail-open: jq absent → return 0 immediately. +# +# The envelope is assembled with shell builtins (hook::json_escape_jq_to for +# the string fields, hook::json_compact_to for the caller's data object) as +# ONE compact line, `jq -c` style: the same document, field order and +# escapes the jq filter below produces, with the data object compacted +# exactly as jq compacts its own output. That path needs no jq, no temp file +# and no subprocess. When the data object cannot be proven jq-idempotent (see +# hook::json_compact_to), the jq path runs, now with -c so both paths write +# the same bytes, and there jq's absence is still fail-open: return 0, no +# envelope. Before this the envelope was jq's default pretty-printed form +# (several lines, and CRLF line endings with a Windows jq); the sink contract +# was always "one JSON document on stdin", and a compact line is also valid +# JSONL for a sink that appends raw envelopes. # # Usage: # hook::emit_telemetry [repo_root] @@ -1251,7 +1840,6 @@ hook::telemetry_enabled() { # NEVER writes to fd1 (the hook's stdout / additionalContext channel). hook::emit_telemetry() { [[ -n "${HOOK_TELEMETRY_SINK:-}" ]] || return 0 - command -v jq >/dev/null 2>&1 || return 0 local hook_id="$1" local hook_event="$2" @@ -1272,35 +1860,59 @@ hook::emit_telemetry() { local e_s="${now%[.,]*}" e_f="${now#*[.,]}" local duration_ms=$(((e_s * 1000000 + 10#$e_f - s_s * 1000000 - 10#$s_f) / 1000)) - # True UTC timestamp (TZ= prefix overrides LC_ALL / local TZ; the Z is not a lie). - local timestamp - timestamp=$(TZ=UTC printf '%(%Y-%m-%dT%H:%M:%SZ)T' -1) - - # Build the envelope. The `data` object is written to a temp file, not passed - # as --argjson, so payloads larger than the Windows 32767-character command-line - # cap are not dropped before jq runs (#1595). /dev/stdin is not used: jq's - # --slurpfile there is not portable on Windows. Redirect jq stderr to - # /dev/null; output goes to a local variable — never to fd1. - local envelope data_file - data_file=$(mktemp) || return 0 - printf '%s' "$data_json" >"$data_file" || { - rm -f "$data_file" - return 0 - } - envelope=$(jq -n \ - --arg schema_version "1.0" \ - --arg timestamp "$timestamp" \ - --arg hook "$hook_id" \ - --arg hook_event "$hook_event" \ - --arg status "$status" \ - --argjson duration_ms "$duration_ms" \ - --slurpfile data "$data_file" \ - '{schema_version:$schema_version,timestamp:$timestamp,hook:$hook,hook_event:$hook_event,status:$status,duration_ms:$duration_ms,data:($data[0] // {})}' \ - 2>/dev/null) || { + # True UTC timestamp (TZ= prefix overrides LC_ALL / local TZ; the Z is not a + # lie). The %()T printf format is Bash 4.2+, and printf -v keeps it in the + # shell (a `$(...)` capture is a fork); an older bash, or a printf that + # binds nothing, falls back to date -u. + local timestamp="" + TZ=UTC printf -v timestamp '%(%Y-%m-%dT%H:%M:%SZ)T' -1 2>/dev/null || timestamp="" + [[ -n "$timestamp" ]] || timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null) || timestamp="" + + # Trailing whitespace after the object (a newline a caller left on the + # value) does not change what jq parses, so it does not block the builtin + # path either. + local envelope="" data="" data_trim="$data_json" + while [[ "$data_trim" == *[$' \t\r\n'] ]]; do + data_trim="${data_trim%?}" + done + if [[ "$data_trim" == \{*\} ]] && hook::json_compact_to data "$data_trim"; then + # Builtin envelope: the same document the jq filter below produces, field + # for field, with jq's string escaping and jq's compact data. + local e_timestamp e_hook e_event e_status + hook::json_escape_jq_to e_timestamp "$timestamp" + hook::json_escape_jq_to e_hook "$hook_id" + hook::json_escape_jq_to e_event "$hook_event" + hook::json_escape_jq_to e_status "$status" + envelope='{"schema_version":"1.0","timestamp":"'"$e_timestamp"'","hook":"'"$e_hook"'","hook_event":"'"$e_event"'","status":"'"$e_status"'","duration_ms":'"$duration_ms"',"data":'"$data"'}' + else + # jq path, for a data object the builtin compactor cannot prove. Fail-open: + # jq absent → return 0. The `data` object is written to a temp file, not + # passed as --argjson, so payloads larger than the Windows 32767-character + # command-line cap are not dropped before jq runs (#1595). /dev/stdin is + # not used: jq's --slurpfile there is not portable on Windows. Redirect jq + # stderr to /dev/null; output goes to a local variable — never to fd1. + command -v jq >/dev/null 2>&1 || return 0 + local data_file + data_file=$(mktemp) || return 0 + printf '%s' "$data_json" >"$data_file" || { + rm -f "$data_file" + return 0 + } + envelope=$(jq -nc \ + --arg schema_version "1.0" \ + --arg timestamp "$timestamp" \ + --arg hook "$hook_id" \ + --arg hook_event "$hook_event" \ + --arg status "$status" \ + --argjson duration_ms "$duration_ms" \ + --slurpfile data "$data_file" \ + '{schema_version:$schema_version,timestamp:$timestamp,hook:$hook,hook_event:$hook_event,status:$status,duration_ms:$duration_ms,data:($data[0] // {})}' \ + 2>/dev/null) || { + rm -f "$data_file" + return 0 + } rm -f "$data_file" - return 0 - } - rm -f "$data_file" + fi # Resolve the sink path. A relative HOOK_TELEMETRY_SINK is joined onto the # consuming repo root (portable, tracked wiring); absolute is used as-is. A diff --git a/plugins/powershell-format/.claude-plugin/plugin.json b/plugins/powershell-format/.claude-plugin/plugin.json index 3f0867a667..4dcc4b9ebf 100644 --- a/plugins/powershell-format/.claude-plugin/plugin.json +++ b/plugins/powershell-format/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "powershell-format", - "version": "0.7.32", + "version": "0.7.33", "description": "Auto-format and lint PowerShell on edit via PSScriptAnalyzer, only when a PSScriptAnalyzerSettings.psd1 governs the repo \u2014 using the consuming repo's own analyzer settings.", "author": { "name": "Melodic Software", diff --git a/plugins/powershell-format/CHANGELOG.md b/plugins/powershell-format/CHANGELOG.md index ae421e477b..9f045164c2 100644 --- a/plugins/powershell-format/CHANGELOG.md +++ b/plugins/powershell-format/CHANGELOG.md @@ -3,6 +3,22 @@ All notable changes to the `powershell-format` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.7.33] + +### Changed + +- **Vendored `hook-utils.sh` builds the telemetry envelope and reads `file_path` + with shell builtins.** `hook::emit_telemetry` no longer spawns two jq + processes, a mktemp and an rm per run: the envelope is assembled in the shell + as one compact line (the same document jq produced, now `jq -c` shaped), with + jq kept only as the fallback for a data object the builtin compactor cannot + prove. `hook::read_file_path` takes `.tool_input.file_path` without jq on the + well-formed payload shape and resolves the file, project root and temp roots + with one batched `realpath` instead of one process each. Same verdicts, same + emitted path, same sink record; phase 4b of the hook-performance program + (#3623). The copy is bumped because `scripts/sync-hook-utils.sh` keeps every + carrying plugin byte-identical. + ## [0.7.32] ### Changed diff --git a/plugins/powershell-format/hooks/hook-utils.sh b/plugins/powershell-format/hooks/hook-utils.sh index 9818a6a73d..18e4dd5745 100644 --- a/plugins/powershell-format/hooks/hook-utils.sh +++ b/plugins/powershell-format/hooks/hook-utils.sh @@ -390,19 +390,34 @@ hook::require_jq_blocking() { # collapse with `/c/repo` and the membership guard would admit a sibling # outside CLAUDE_PROJECT_DIR. The result is used ONLY for comparison; the # emitted path is always the caller's original. -hook::normalize_path() { - local p="${1//\\//}" +# +# Two spellings of every path helper below: `hook::` prints the answer +# (the public, subshell-friendly form every caller already uses) and +# `hook::_to ` stores it in the caller's variable instead. +# The `_to` form is what the hot path uses: a `$(...)` capture is a fork, and +# on Windows Git Bash a fork costs milliseconds, so the file_path guard that +# ran a dozen of them per hook now runs none it does not need. The `_to` +# helpers keep their locals under a `__hu_` prefix so the caller's variable +# name cannot collide with them. +hook::normalize_path_to() { + local __hu_p="${2//\\//}" case "${OSTYPE:-}" in msys* | cygwin* | win32) - if [[ "$p" =~ ^/([a-zA-Z])/ || "$p" =~ ^([a-zA-Z]):/ ]]; then - local rest="${p:2}" - printf '%s' "${BASH_REMATCH[1]^}:${rest,,}" - return + if [[ "$__hu_p" =~ ^/([a-zA-Z])/ || "$__hu_p" =~ ^([a-zA-Z]):/ ]]; then + local __hu_rest="${__hu_p:2}" + printf -v "$1" '%s' "${BASH_REMATCH[1]^}:${__hu_rest,,}" + return 0 fi ;; *) ;; # POSIX hosts: case-sensitive FS, no drive fold — pass through below esac - printf '%s' "$p" + printf -v "$1" '%s' "$__hu_p" +} + +hook::normalize_path() { + local __hu_n + hook::normalize_path_to __hu_n "$1" + printf '%s' "$__hu_n" } # Expand Windows 8.3 short-name components (KYLESE~1 → KyleSexton) on @@ -424,25 +439,31 @@ hook::normalize_path() { # class: cygpath ships with Git Bash (the documented Windows bash), so its # absence or failure keeps the resolver's answer unchanged — degrading to the # pre-expansion comparison, same doctrine as the resolver fallback below. -hook::expand_8dot3() { - local p="$1" +hook::expand_8dot3_to() { + local __hu_p="$2" case "${OSTYPE:-}" in msys* | cygwin* | win32) ;; *) - printf '%s' "$p" - return + printf -v "$1" '%s' "$__hu_p" + return 0 ;; esac - if [[ "$p" == *~* ]] && command -v cygpath >/dev/null 2>&1; then - local plain long - if plain=$(cygpath -m -- "$p" 2>/dev/null) && - long=$(cygpath -l -m -- "$p" 2>/dev/null) && - [[ -n "$long" && "$long" != "$plain" ]]; then - printf '%s' "$long" - return + if [[ "$__hu_p" == *~* ]] && command -v cygpath >/dev/null 2>&1; then + local __hu_plain __hu_long + if __hu_plain=$(cygpath -m -- "$__hu_p" 2>/dev/null) && + __hu_long=$(cygpath -l -m -- "$__hu_p" 2>/dev/null) && + [[ -n "$__hu_long" && "$__hu_long" != "$__hu_plain" ]]; then + printf -v "$1" '%s' "$__hu_long" + return 0 fi fi - printf '%s' "$p" + printf -v "$1" '%s' "$__hu_p" +} + +hook::expand_8dot3() { + local __hu_e + hook::expand_8dot3_to __hu_e "$1" + printf '%s' "$__hu_e" } # Canonicalize to a physical path — symlinks resolved, Windows 8.3 short names @@ -462,20 +483,148 @@ hook::expand_8dot3() { # expansion applies only on the resolver's success path: consumers that fail # closed on an unresolved signature must not see a form-converted path instead. # shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED -hook::physical_path() { - local resolved +hook::physical_path_to() { + local __hu_r HOOK_PHYSICAL_PATH_UNRESOLVED=0 - if resolved=$(realpath -- "$1" 2>/dev/null) || resolved=$(readlink -f -- "$1" 2>/dev/null); then - if [[ -n "$resolved" ]]; then - hook::expand_8dot3 "$resolved" + if __hu_r=$(realpath -- "$2" 2>/dev/null) || __hu_r=$(readlink -f -- "$2" 2>/dev/null); then + if [[ -n "$__hu_r" ]]; then + hook::expand_8dot3_to "$1" "$__hu_r" return 0 fi fi HOOK_PHYSICAL_PATH_UNRESOLVED=1 - printf '%s' "$1" + printf -v "$1" '%s' "$2" + return 1 +} + +# shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED +hook::physical_path() { + local __hu_v __hu_rc=0 + hook::physical_path_to __hu_v "$1" || __hu_rc=$? + printf '%s' "$__hu_v" + return "$__hu_rc" +} + +# --- Per-process physical-path cache -------------------------------------- +# The membership guard resolves the same few directories on every call: the +# project root and this host's temp roots. Their physical form does not change +# within one hook process, so each spelling is resolved once and remembered in +# three parallel indexed arrays (keys, values, resolver status). Plain arrays, +# not an associative array, so the cache runs on Bash 3.2. Only directories are +# meant to live here for a process lifetime; hook::read_file_path forgets the +# edited file's entry as soon as it has read it, so a later call in the same +# process sees the file as it is then. +_HOOK_PHYS_KEYS=() +_HOOK_PHYS_VALS=() +_HOOK_PHYS_RCS=() +_HOOK_PHYS_I=-1 + +# hook::_phys_cache_index : sets _HOOK_PHYS_I to the slot holding , +# returns 1 when it is not cached. A forgotten slot has an empty key, which no +# real path can match. +hook::_phys_cache_index() { + local __hu_i + [[ -n "$1" ]] || return 1 + for ((__hu_i = 0; __hu_i < ${#_HOOK_PHYS_KEYS[@]}; __hu_i++)); do + if [[ "${_HOOK_PHYS_KEYS[__hu_i]}" == "$1" ]]; then + _HOOK_PHYS_I=$__hu_i + return 0 + fi + done return 1 } +hook::_phys_cache_forget() { + hook::_phys_cache_index "$1" && _HOOK_PHYS_KEYS[_HOOK_PHYS_I]="" + return 0 +} + +# hook::_physical_cached_to : hook::physical_path_to through the +# cache. Same value, same return status, same HOOK_PHYSICAL_PATH_UNRESOLVED as +# the uncached call; a miss resolves the one path and stores it. +# shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED +hook::_physical_cached_to() { + if hook::_phys_cache_index "$2"; then + printf -v "$1" '%s' "${_HOOK_PHYS_VALS[_HOOK_PHYS_I]}" + HOOK_PHYSICAL_PATH_UNRESOLVED=${_HOOK_PHYS_RCS[_HOOK_PHYS_I]} + return "${_HOOK_PHYS_RCS[_HOOK_PHYS_I]}" + fi + local __hu_v __hu_rc=0 + hook::physical_path_to __hu_v "$2" || __hu_rc=$? + if [[ -n "$2" ]]; then + _HOOK_PHYS_KEYS+=("$2") + _HOOK_PHYS_VALS+=("$__hu_v") + _HOOK_PHYS_RCS+=("$__hu_rc") + fi + printf -v "$1" '%s' "$__hu_v" + return "$__hu_rc" +} + +# hook::_physical_prime ...: resolve every uncached path with ONE +# realpath process and store the answers. realpath resolves each argument on +# its own and prints one line per argument in argument order, so the batch +# answer for a path is the same string the per-path call returns; the 8.3 +# expansion is applied per answer exactly as hook::physical_path_to does. Any +# doubt (a path carrying a newline, a non-zero exit, a line count that does not +# match) stores nothing and leaves the per-path resolver to answer lazily, so +# the batch can only ever save work, never change an answer. readlink -f hosts +# (no realpath) skip the batch for the same reason. +hook::_physical_prime() { + local -a __hu_todo=() + local __hu_p __hu_out __hu_i __hu_seen="" __hu_v + for __hu_p in "$@"; do + [[ -n "$__hu_p" ]] || continue + [[ "$__hu_p" == *$'\n'* ]] && return 0 + case "$__hu_seen" in + *"|$__hu_p|"*) continue ;; + *) ;; # first sighting + esac + __hu_seen="$__hu_seen|$__hu_p|" + hook::_phys_cache_index "$__hu_p" && continue + __hu_todo+=("$__hu_p") + done + ((${#__hu_todo[@]} > 1)) || return 0 + command -v realpath >/dev/null 2>&1 || return 0 + # portability-ok: realpath with several operands is GNU and BSD alike; a host whose realpath rejects it fails the exit-status check and falls back per path + __hu_out=$(realpath -- "${__hu_todo[@]}" 2>/dev/null) || return 0 + local -a __hu_lines=() + local __hu_glob=0 + [[ $- == *f* ]] || __hu_glob=1 + set -f + local IFS=$'\n' + # shellcheck disable=SC2206 # splitting realpath's one-line-per-operand output is the intent + __hu_lines=($__hu_out) + ((__hu_glob)) && set +f + ((${#__hu_lines[@]} == ${#__hu_todo[@]})) || return 0 + for ((__hu_i = 0; __hu_i < ${#__hu_todo[@]}; __hu_i++)); do + [[ -n "${__hu_lines[__hu_i]}" ]] || return 0 + hook::expand_8dot3_to __hu_v "${__hu_lines[__hu_i]}" + _HOOK_PHYS_KEYS+=("${__hu_todo[__hu_i]}") + _HOOK_PHYS_VALS+=("$__hu_v") + _HOOK_PHYS_RCS+=(0) + done + return 0 +} + +# The temp-root candidates hook::under_temp_root compares against: the +# environment's own answer (TMPDIR/TMP/TEMP) plus the POSIX defaults, never a +# hardcoded platform assumption. Existing directories only, each spelling +# once, in _HOOK_TEMP_CANDS. +_HOOK_TEMP_CANDS=() +hook::_temp_root_candidates() { + local __hu_cand __hu_seen="" + _HOOK_TEMP_CANDS=() + for __hu_cand in "${TMPDIR:-}" "${TMP:-}" "${TEMP:-}" /tmp /var/tmp; do + [[ -n "$__hu_cand" && -d "$__hu_cand" ]] || continue + case "$__hu_seen" in + *"|$__hu_cand|"*) continue ;; + *) ;; # first sighting of this candidate + esac + __hu_seen="$__hu_seen|$__hu_cand|" + _HOOK_TEMP_CANDS+=("$__hu_cand") + done +} + # True when sits inside one of this host's temp trees. # Both arguments and candidates go through the same canonicalize+normalize # pipeline as the membership comparison, because the same directory has several @@ -483,21 +632,40 @@ hook::physical_path() { # of the identical directory, and `realpath` resolves the Windows form to a # drive path while leaving `/tmp` as `/tmp`. Neither form alone matches a # `file_path` that could arrive in either, so every candidate is compared and a -# match on any one is a match. Duplicates are resolved once. +# match on any one is a match. Duplicates are resolved once, and each +# candidate's physical form is remembered for the process (see the cache +# above), so a second call costs no resolver process. # -# Candidates are the environment's own answer (TMPDIR/TMP/TEMP) plus the POSIX -# defaults, never a hardcoded platform assumption. +# Raw-spelling shortcut: when the caller vouches that is a +# PHYSICAL path (_HOOK_UTR_TARGET_PHYSICAL=1, set only by hook::read_file_path +# after the resolver succeeded), a candidate whose raw normalized spelling is +# already a prefix of the target is a match without resolving it. A physical +# path contains no symlink component, so a candidate that spells one of its +# prefixes names a chain of real directories whose physical form is that same +# prefix; the resolver would answer the same. A candidate that does not match +# raw is still resolved, so a symlinked temp root (macOS /tmp) is found the +# way it always was. Without the vouch the shortcut is off and every candidate +# is resolved, exactly as before. Note that hook::read_file_path primes every +# candidate into the cache with one batched realpath before calling here, so +# on that path the shortcut skips a lookup, not a process; the batch is what +# saves the processes. A direct caller without the batch saves the resolver. # hook::under_temp_root "$norm_path" && ... +_HOOK_UTR_TARGET_PHYSICAL=0 hook::under_temp_root() { - local target="$1" cand norm seen="" - for cand in "${TMPDIR:-}" "${TMP:-}" "${TEMP:-}" /tmp /var/tmp; do - [[ -n "$cand" && -d "$cand" ]] || continue - case "$seen" in - *"|$cand|"*) continue ;; - *) ;; # first sighting of this candidate — resolve it below - esac - seen="$seen|$cand|" - norm=$(hook::normalize_path "$(hook::physical_path "$cand")") + local target="$1" cand norm phys + hook::_temp_root_candidates + for cand in ${_HOOK_TEMP_CANDS[@]+"${_HOOK_TEMP_CANDS[@]}"}; do + if ((_HOOK_UTR_TARGET_PHYSICAL)); then + hook::normalize_path_to norm "$cand" + if [[ "$norm" != / ]]; then + norm="${norm%/}" + if [[ -n "$norm" && ("$target" == "$norm" || "$target" == "$norm"/*) ]]; then + return 0 + fi + fi + fi + hook::_physical_cached_to phys "$cand" || : + hook::normalize_path_to norm "$phys" # The filesystem root as a temp candidate contains every absolute path; # trimming its only slash would empty the candidate and discard it. [[ "$norm" == / ]] && return 0 @@ -523,21 +691,421 @@ hook::in_git_working_tree() { ) >/dev/null 2>&1 } +# --- Builtin JSON helpers ---------------------------------------------------- +# hook::read_file_path and hook::emit_telemetry used to spend a jq process each +# on every hook run. On Windows Git Bash one spawn costs tens of milliseconds, +# so the helpers here handle the well-formed shapes those two functions see in +# practice with shell builtins and hand anything they cannot PROVE to the +# unchanged jq path. The contract is "the bytes jq would produce, or fall +# back", never "close enough". +# +# What the builtin paths rely on: the text is valid JSON. Every hook payload +# reaching hook::read_file_path has passed hook::buffer_stdin's jq validation, +# and every telemetry data object is built with jq. The helpers still verify +# the structure they walk (terminated strings, well-formed tokens, properly +# nested brackets, one root, no raw control bytes inside strings), but they +# are not a full JSON parser and do not claim to reject every malformed text. +# +# Why no scanning: on this repo's Windows hosts bash regex matching and the +# `%%`/`##` pattern operators cost about a microsecond per character, so a +# 60 KB Write payload would cost more than the jq process they replace. The +# only string operations used on the whole payload are literal-substring +# replacement, `[[ == *x* ]]` containment, IFS word splitting and offset +# slicing, all of which run at C speed. + +# hook::_json_split +# Split at every unescaped double quote into the global array +# _HOOK_JSON_PARTS: even indices hold the structural text between strings, odd +# indices hold string bodies. Before splitting, every `\\` and `\"` pair is +# replaced by `@@` (same length), so a quote that survives is a real string +# delimiter and a body's ORIGINAL bytes are ${text:offset:length}. Returns 1 +# when the text is too large, its quotes do not pair up, or it holds more +# strings than the callers' loops are meant to walk. +_HOOK_JSON_PARTS=() +hook::_json_split() { + local __hu_s="$1" __hu_t __hu_q __hu_n + ((${#__hu_s} <= 65536)) || return 1 + __hu_t=${__hu_s//"\\\\"/@@} + __hu_t=${__hu_t//"\\\""/@@} + __hu_q=${__hu_t//\"/} + __hu_n=$((${#__hu_t} - ${#__hu_q})) + ((__hu_n % 2 == 0)) || return 1 + ((__hu_n <= 800)) || return 1 + local __hu_glob=0 + [[ $- == *f* ]] || __hu_glob=1 + set -f + local IFS='"' + # shellcheck disable=SC2206 # splitting on IFS='"' is the whole point + _HOOK_JSON_PARTS=($__hu_t) + ((__hu_glob)) && set +f + ((${#_HOOK_JSON_PARTS[@]} % 2 == 1)) || return 1 + return 0 +} + +# hook::_json_skeleton +# Split (hook::_json_split), record each string body's byte offset in +# _HOOK_JSON_OFF, and build _HOOK_JSON_SK: the structural text with every +# string replaced by a numbered placeholder `"#"` and all JSON +# whitespace removed. The skeleton is small (no string bodies), so regex and +# per-token work on it is cheap. Returns 1 unless the text is exactly one JSON +# value by the JSON grammar (well-formed tokens, whitespace only between them, +# objects as key:value lists, arrays as value lists, brackets nested and +# closed once) whose strings carry no raw control byte, which jq rejects. +_HOOK_JSON_SK="" +_HOOK_JSON_OFF=() +hook::_json_skeleton() { + local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top + hook::_json_split "$__hu_s" || return 1 + __hu_n=${#_HOOK_JSON_PARTS[@]} + _HOOK_JSON_OFF=() + for ((__hu_i = 0; __hu_i < __hu_n; __hu_i++)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + if ((__hu_i % 2 == 0)); then + __hu_sk+=$__hu_part + else + _HOOK_JSON_OFF[__hu_i]=$__hu_off + [[ "$__hu_part" == *[[:cntrl:]]* ]] && return 1 + __hu_sk+="\"#$__hu_i\"" + fi + __hu_off=$((__hu_off + ${#__hu_part} + 1)) + done + # Tokenize the skeleton and run it through the JSON grammar. Each token is + # consumed by one anchored regex on the (small) remainder; whitespace is + # allowed only between tokens, so `tr ue` is two bad tokens, not `true`. + local __hu_re=$'^[ \t\n\r]*(\\{|\\}|\\[|\\]|,|:|"#[0-9]+"|-?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][-+]?[0-9]+)?|true|false|null)' + local __hu_ws=$'^[ \t\n\r]*$' + __hu_rest=$__hu_sk + while [[ -n "$__hu_rest" ]] && ! [[ "$__hu_rest" =~ $__hu_ws ]]; do + [[ "$__hu_rest" =~ $__hu_re ]] || return 1 + __hu_tok=${BASH_REMATCH[1]} + __hu_rest=${__hu_rest:${#BASH_REMATCH[0]}} + __hu_top=${__hu_stack:${#__hu_stack}-1:1} + case "$__hu_expect" in + value | value_or_end) + case "$__hu_tok" in + '{') + __hu_stack+='{' + __hu_expect=key_or_end + continue + ;; + '[') + __hu_stack+='[' + __hu_expect=value_or_end + continue + ;; + ']') + [[ "$__hu_expect" == value_or_end ]] || return 1 + ;; + '}' | ',' | ':') return 1 ;; + *) + # A scalar. Inside a container the next token is , or the close; + # at the top level nothing may follow. + if [[ -n "$__hu_stack" ]]; then __hu_expect=comma_or_end; else __hu_expect=end; fi + continue + ;; + esac + ;; + key_or_end | key) + case "$__hu_tok" in + \"#*) + __hu_expect='colon' + continue + ;; + '}') [[ "$__hu_expect" == key_or_end ]] || return 1 ;; + *) return 1 ;; + esac + ;; + colon) + [[ "$__hu_tok" == ':' ]] || return 1 + __hu_expect=value + continue + ;; + comma_or_end) + case "$__hu_tok" in + ',') + if [[ "$__hu_top" == '{' ]]; then __hu_expect=key; else __hu_expect=value; fi + continue + ;; + '}') [[ "$__hu_top" == '{' ]] || return 1 ;; + ']') [[ "$__hu_top" == '[' ]] || return 1 ;; + *) return 1 ;; + esac + ;; + *) return 1 ;; # `end`: a token after the root value closed + esac + # Reaching here means a container just closed. + [[ -n "$__hu_stack" ]] || return 1 + __hu_stack=${__hu_stack:0:${#__hu_stack}-1} + if [[ -n "$__hu_stack" ]]; then __hu_expect=comma_or_end; else __hu_expect=end; fi + done + [[ "$__hu_expect" == end ]] || return 1 + _HOOK_JSON_SK=${__hu_sk//[$' \t\n\r']/} + return 0 +} + +# hook::json_unescape_to +# Decode a JSON string body (the bytes between the quotes) into . Handles +# \" \\ \/ \b \f \n \r \t and \uXXXX for U+0001 to U+007F. Returns 1 on any +# other escape (a \u outside that range, U+0000 which bash cannot hold, or an +# invalid escape), leaving the caller to fall back to jq. +hook::json_unescape_to() { + local __hu_raw="$2" __hu_out="" __hu_pre __hu_c __hu_hex __hu_oct __hu_ch + while [[ "$__hu_raw" == *\\* ]]; do + __hu_pre=${__hu_raw%%\\*} + __hu_out+=$__hu_pre + __hu_raw=${__hu_raw:${#__hu_pre}+1} + __hu_c=${__hu_raw:0:1} + __hu_raw=${__hu_raw:1} + case "$__hu_c" in + '"') __hu_out+='"' ;; + "\\") __hu_out+="\\" ;; + '/') __hu_out+='/' ;; + b) __hu_out+=$'\b' ;; # portability-ok: bash ANSI-C backspace byte, not a GNU grep word boundary + f) __hu_out+=$'\f' ;; + n) __hu_out+=$'\n' ;; + r) __hu_out+=$'\r' ;; + t) __hu_out+=$'\t' ;; + u) + __hu_hex=${__hu_raw:0:4} + [[ "$__hu_hex" =~ ^00[0-7][0-9a-fA-F]$ ]] || return 1 + [[ "$__hu_hex" == 0000 ]] && return 1 + __hu_raw=${__hu_raw:4} + printf -v __hu_oct '%03o' "$((16#$__hu_hex))" + printf -v __hu_ch '%b' "\\0$__hu_oct" + __hu_out+=$__hu_ch + ;; + *) return 1 ;; + esac + done + __hu_out+=$__hu_raw + printf -v "$1" '%s' "$__hu_out" +} + +# hook::_fast_file_path_to +# The builtin answer to `jq -r '(.tool_input.file_path // empty) | +# gsub("\r";"")'` followed by the `$(...)` capture. Returns +# 0 proven: holds exactly what jq would have printed +# 1 proven absent: jq would have printed nothing (the guard skips) +# 2 not proven: run jq +# Proof, on top of hook::_json_skeleton's structural checks: the root is an +# object; exactly one string in the whole payload decodes to `tool_input` and +# exactly one to `file_path` (so neither a duplicate key nor a same-named key +# in another object can change jq's answer); `tool_input` is a key of the root +# (depth 1, key position) whose value is a flat object (no nested object or +# array, else jq); and inside it `file_path` is a key with a plain string +# value. A payload where `file_path` names a value or sits in another object +# has no `.tool_input.file_path`, which jq reports as nothing. Key strings are +# compared AFTER decoding their escapes, so a key spelled with \u escapes is +# still recognized; a body longer than any escaped spelling of either name is +# skipped without decoding. +hook::_fast_file_path_to() { + local __hu_s="$2" __hu_i __hu_n __hu_part __hu_body __hu_ti=-1 __hu_fp=-1 __hu_m __hu_raw __hu_pre __hu_re __hu_file + hook::_json_skeleton "$__hu_s" || return 2 + [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 + __hu_n=${#_HOOK_JSON_PARTS[@]} + for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + ((${#__hu_part} <= 60)) || continue + __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} + if [[ "$__hu_body" == *\\* ]]; then + hook::json_unescape_to __hu_body "$__hu_body" || continue + fi + case "$__hu_body" in + tool_input) + ((__hu_ti < 0)) || return 2 + __hu_ti=$__hu_i + ;; + file_path) + ((__hu_fp < 0)) || return 2 + __hu_fp=$__hu_i + ;; + *) ;; + esac + done + ((__hu_fp >= 0)) || return 1 + ((__hu_ti >= 0)) || return 1 + __hu_re="(^|[{,])\"#$__hu_ti\":\\{([^][{}]*)\\}" + if [[ "$_HOOK_JSON_SK" =~ $__hu_re ]]; then + __hu_body=${BASH_REMATCH[2]} + __hu_pre=${_HOOK_JSON_SK%%\"#"$__hu_ti"\"*} + # Depth of the key: opening brackets minus closing ones before it must be + # exactly one, i.e. a direct member of the root object. + local __hu_o1=${__hu_pre//\{/} __hu_o2=${__hu_pre//\[/} __hu_c1=${__hu_pre//\}/} __hu_c2=${__hu_pre//\]/} + local __hu_depth=$(((${#__hu_pre} - ${#__hu_o1}) + (${#__hu_pre} - ${#__hu_o2}) - (${#__hu_pre} - ${#__hu_c1}) - (${#__hu_pre} - ${#__hu_c2}))) + ((__hu_depth == 1)) || return 2 + else + return 2 + fi + __hu_re="(^|,)\"#$__hu_fp\":\"#([0-9]+)\"(,|$)" + if [[ "$__hu_body" =~ $__hu_re ]]; then + __hu_m=${BASH_REMATCH[2]} + elif [[ "$__hu_body" == *\"#$__hu_fp\"* ]]; then + return 2 + else + return 1 + fi + __hu_raw=${__hu_s:${_HOOK_JSON_OFF[__hu_m]}:${#_HOOK_JSON_PARTS[__hu_m]}} + if [[ "$__hu_raw" == *\\* ]]; then + hook::json_unescape_to __hu_file "$__hu_raw" || return 2 + else + __hu_file=$__hu_raw + fi + __hu_file=${__hu_file//$'\r'/} + while [[ "$__hu_file" == *$'\n' ]]; do + __hu_file=${__hu_file%$'\n'} + done + printf -v "$1" '%s' "$__hu_file" + return 0 +} + +# hook::dirname_to : dirname with builtins for the resolver's +# answer. Strips the last segment; a bare name lives in `.`, a root-level file +# in `/`. The path comes from realpath, so the trailing-slash and doubled-slash +# spellings dirname also collapses never occur here. +hook::dirname_to() { + local __hu_d="${2%/*}" + [[ "$__hu_d" == "$2" ]] && __hu_d=. + [[ -n "$__hu_d" ]] || __hu_d=/ + printf -v "$1" '%s' "$__hu_d" +} + +# Print the arguments joined by NUL bytes, no trailing NUL: the byte stream +# hook::read_file_path read from stdin, rebuilt for the jq fallback. +hook::_print_nul_joined() { + local __hu_first=1 __hu_c + for __hu_c in "$@"; do + ((__hu_first)) || printf '\0' + __hu_first=0 + printf '%s' "$__hu_c" + done +} + +# hook::json_compact_to +# The bytes `jq -c .` prints for , when that can be proven with +# builtins: JSON whitespace between tokens removed, everything else verbatim. +# jq's own output, compact or pretty, satisfies the proof, which is what every +# telemetry data builder in this marketplace produces (jq -c, jq -n, or a +# compact literal). Returns 1 when the text is not a single object, or a +# string carries something jq would re-encode (a \u or \/ escape, an escape +# outside the JSON set, a raw control byte), or the structure fails +# hook::_json_skeleton; the caller then runs jq. +hook::json_compact_to() { + local __hu_s="$2" __hu_out="" __hu_i __hu_part __hu_n + hook::_json_skeleton "$__hu_s" || return 1 + [[ "$_HOOK_JSON_SK" == \{*\} ]] || return 1 + # Numbers: only short integer literals are passed through. A fraction, an + # exponent or an integer beyond 15 digits is rendered differently by + # different jq releases (1.6 canonicalizes, 1.7+ preserves the literal), so + # the bytes cannot be proven; jq decides. In the skeleton every string is a + # `"#N"` placeholder and letters occur only in true/false/null, so a digit + # followed by `.`, `e` or `E` can only be part of a number. + local __hu_num='[0-9][.eE]|[0-9]{16}' + [[ "$_HOOK_JSON_SK" =~ $__hu_num ]] && return 1 + __hu_n=${#_HOOK_JSON_PARTS[@]} + for ((__hu_i = 0; __hu_i < __hu_n; __hu_i++)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + if ((__hu_i % 2 == 0)); then + __hu_out+=${__hu_part//[$' \t\n\r']/} + else + if [[ "$__hu_part" == *\\* ]]; then + [[ "$__hu_part" == *'\u'* || "$__hu_part" == *'\/'* ]] && return 1 + [[ "$__hu_part" == *\\[!bfnrt]* ]] && return 1 + fi + __hu_out+="\"${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}}\"" + fi + done + printf -v "$1" '%s' "$__hu_out" +} + +# hook::json_escape_jq_to / hook::json_escape_jq +# Escape exactly as jq serializes a string value (without the +# quotes): backslash and double quote, the five short forms \b \f \n \r \t, +# every other byte from 0x01 to 0x1f and 0x7f as lowercase \u00xx, and +# everything else, including non-ASCII, verbatim. This is the escaper the +# telemetry envelope needs for byte parity with the jq envelope it replaced; +# hook::json_escape above is a different tool (it DROPS the residual control +# bytes, which is right for a notice and wrong for parity) and is unchanged. +hook::json_escape_jq_to() { + local __hu_s="$2" __hu_i __hu_c __hu_oct __hu_hex + __hu_s="${__hu_s//\\/\\\\}" + __hu_s="${__hu_s//\"/\\\"}" + if [[ "$__hu_s" == *[[:cntrl:]]* ]]; then + __hu_s="${__hu_s//$'\n'/\\n}" + __hu_s="${__hu_s//$'\r'/\\r}" + __hu_s="${__hu_s//$'\t'/\\t}" + __hu_s="${__hu_s//$'\b'/\\b}" # portability-ok: bash ANSI-C backspace byte and the JSON \b escape, not a GNU grep word boundary + __hu_s="${__hu_s//$'\f'/\\f}" + for __hu_i in 1 2 3 4 5 6 7 11 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 127; do + printf -v __hu_oct '%03o' "$__hu_i" + printf -v __hu_c '%b' "\\0$__hu_oct" + [[ "$__hu_s" == *"$__hu_c"* ]] || continue + printf -v __hu_hex '%04x' "$__hu_i" + __hu_s="${__hu_s//"$__hu_c"/\\u$__hu_hex}" + done + fi + printf -v "$1" '%s' "$__hu_s" +} + +hook::json_escape_jq() { + local __hu_e + hook::json_escape_jq_to __hu_e "$1" + printf '%s' "$__hu_e" +} + # Parse file_path from PostToolUse JSON on stdin; validate existence and (when # CLAUDE_PROJECT_DIR is set) project membership. Both sides of the membership # comparison are canonicalized (symlinks resolved) first, so neither an # escaping symlink nor a project root reached via a symlinked path (e.g. # macOS /tmp) skews the verdict. Outputs the path on success. Returns 1 to skip. # FILE=$(hook::read_file_path) || exit 0 +# +# jq-free on the common shape. The payload is read into the shell and +# hook::_fast_file_path_to takes `.tool_input.file_path` with builtins when it +# can PROVE the answer jq would give (see that helper for what "prove" means); +# every other payload goes to the unchanged jq filter. The path comparison then +# resolves the file, the project root and the temp roots with one realpath +# process instead of one each (hook::_physical_prime), and remembers the +# directories for the process. Same verdict, same emitted path, fewer +# processes: on Windows Git Bash each spawn costs tens of milliseconds and this +# guard runs on every Write and Edit. hook::read_file_path() { - local file - file=$(jq -r '(.tool_input.file_path // empty) | gsub("\r";"")' 2>/dev/null) + local -a chunks=() + local chunk file="" mode=2 + # Builtin read to NUL or EOF. A NUL splits the payload into several chunks; + # the fast path only takes a single-chunk (NUL-free) payload, and the jq + # fallback is fed the chunks NUL-joined, so it sees the bytes jq used to + # read straight from stdin. + while :; do + chunk="" + if IFS= read -r -d '' chunk; then + chunks+=("$chunk") + continue + fi + chunks+=("$chunk") + break + done + if ((${#chunks[@]} == 1)); then + mode=0 + hook::_fast_file_path_to file "${chunks[0]}" || mode=$? + fi + case "$mode" in + 0) ;; # proven: `file` holds jq's answer + 1) return 1 ;; # proven absent + *) + file=$(hook::_print_nul_joined "${chunks[@]}" | jq -r '(.tool_input.file_path // empty) | gsub("\r";"")' 2>/dev/null) + ;; + esac [[ -n "$file" ]] || return 1 [[ -f "$file" ]] || return 1 if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then - local norm_file norm_project - norm_file=$(hook::normalize_path "$(hook::physical_path "$file")") - norm_project=$(hook::normalize_path "$(hook::physical_path "${CLAUDE_PROJECT_DIR}")") + local norm_file norm_project phys_file phys_project file_resolved=0 project_resolved=0 + hook::_temp_root_candidates + hook::_physical_prime "$file" "${CLAUDE_PROJECT_DIR}" ${_HOOK_TEMP_CANDS[@]+"${_HOOK_TEMP_CANDS[@]}"} + hook::_physical_cached_to phys_file "$file" && file_resolved=1 + hook::_phys_cache_forget "$file" + hook::_physical_cached_to phys_project "${CLAUDE_PROJECT_DIR}" && project_resolved=1 + hook::normalize_path_to norm_file "$phys_file" + hook::normalize_path_to norm_project "$phys_project" norm_project="${norm_project%/}" # Anchor on a path-segment boundary: accept the project root itself or a # child under it, but not a sibling whose name merely shares the prefix @@ -558,19 +1126,28 @@ hook::read_file_path() { # built by `mktemp -d`, which is how this repo's own hook suites run), so # the branch must not fire. Only a temp-tree file reached from a project # root OUTSIDE the temp tree is scratch. - if hook::under_temp_root "$norm_file" && ! hook::under_temp_root "$norm_project"; then - return 1 + local file_in_temp=0 + _HOOK_UTR_TARGET_PHYSICAL=$file_resolved + hook::under_temp_root "$norm_file" && file_in_temp=1 + _HOOK_UTR_TARGET_PHYSICAL=0 + if ((file_in_temp)); then + local project_in_temp=0 + _HOOK_UTR_TARGET_PHYSICAL=$project_resolved + hook::under_temp_root "$norm_project" && project_in_temp=1 + _HOOK_UTR_TARGET_PHYSICAL=0 + ((project_in_temp)) || return 1 fi elif command -v git >/dev/null 2>&1; then # When CLAUDE_PROJECT_DIR is unset, scope to git-working-tree membership so # scratch files outside any repository are not mutated by formatter hooks # (#1091 / #972). - local file_physical - file_physical=$(hook::physical_path "$file") + local file_physical file_dir + hook::physical_path_to file_physical "$file" || : if [[ -L "$file" && "$file_physical" == "$file" ]]; then return 1 fi - if ! hook::in_git_working_tree "$(dirname "$file_physical")"; then + hook::dirname_to file_dir "$file_physical" + if ! hook::in_git_working_tree "$file_dir"; then return 1 fi else @@ -1228,7 +1805,19 @@ hook::telemetry_enabled() { # Fire-and-forget: sink is dispatched in the background; the hook never waits # on it and its failure never affects the hook's own exit code or stdout. # Opt-in guard: HOOK_TELEMETRY_SINK unset or empty → return 0 immediately. -# Fail-open: jq absent → return 0 immediately. +# +# The envelope is assembled with shell builtins (hook::json_escape_jq_to for +# the string fields, hook::json_compact_to for the caller's data object) as +# ONE compact line, `jq -c` style: the same document, field order and +# escapes the jq filter below produces, with the data object compacted +# exactly as jq compacts its own output. That path needs no jq, no temp file +# and no subprocess. When the data object cannot be proven jq-idempotent (see +# hook::json_compact_to), the jq path runs, now with -c so both paths write +# the same bytes, and there jq's absence is still fail-open: return 0, no +# envelope. Before this the envelope was jq's default pretty-printed form +# (several lines, and CRLF line endings with a Windows jq); the sink contract +# was always "one JSON document on stdin", and a compact line is also valid +# JSONL for a sink that appends raw envelopes. # # Usage: # hook::emit_telemetry [repo_root] @@ -1251,7 +1840,6 @@ hook::telemetry_enabled() { # NEVER writes to fd1 (the hook's stdout / additionalContext channel). hook::emit_telemetry() { [[ -n "${HOOK_TELEMETRY_SINK:-}" ]] || return 0 - command -v jq >/dev/null 2>&1 || return 0 local hook_id="$1" local hook_event="$2" @@ -1272,35 +1860,59 @@ hook::emit_telemetry() { local e_s="${now%[.,]*}" e_f="${now#*[.,]}" local duration_ms=$(((e_s * 1000000 + 10#$e_f - s_s * 1000000 - 10#$s_f) / 1000)) - # True UTC timestamp (TZ= prefix overrides LC_ALL / local TZ; the Z is not a lie). - local timestamp - timestamp=$(TZ=UTC printf '%(%Y-%m-%dT%H:%M:%SZ)T' -1) - - # Build the envelope. The `data` object is written to a temp file, not passed - # as --argjson, so payloads larger than the Windows 32767-character command-line - # cap are not dropped before jq runs (#1595). /dev/stdin is not used: jq's - # --slurpfile there is not portable on Windows. Redirect jq stderr to - # /dev/null; output goes to a local variable — never to fd1. - local envelope data_file - data_file=$(mktemp) || return 0 - printf '%s' "$data_json" >"$data_file" || { - rm -f "$data_file" - return 0 - } - envelope=$(jq -n \ - --arg schema_version "1.0" \ - --arg timestamp "$timestamp" \ - --arg hook "$hook_id" \ - --arg hook_event "$hook_event" \ - --arg status "$status" \ - --argjson duration_ms "$duration_ms" \ - --slurpfile data "$data_file" \ - '{schema_version:$schema_version,timestamp:$timestamp,hook:$hook,hook_event:$hook_event,status:$status,duration_ms:$duration_ms,data:($data[0] // {})}' \ - 2>/dev/null) || { + # True UTC timestamp (TZ= prefix overrides LC_ALL / local TZ; the Z is not a + # lie). The %()T printf format is Bash 4.2+, and printf -v keeps it in the + # shell (a `$(...)` capture is a fork); an older bash, or a printf that + # binds nothing, falls back to date -u. + local timestamp="" + TZ=UTC printf -v timestamp '%(%Y-%m-%dT%H:%M:%SZ)T' -1 2>/dev/null || timestamp="" + [[ -n "$timestamp" ]] || timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null) || timestamp="" + + # Trailing whitespace after the object (a newline a caller left on the + # value) does not change what jq parses, so it does not block the builtin + # path either. + local envelope="" data="" data_trim="$data_json" + while [[ "$data_trim" == *[$' \t\r\n'] ]]; do + data_trim="${data_trim%?}" + done + if [[ "$data_trim" == \{*\} ]] && hook::json_compact_to data "$data_trim"; then + # Builtin envelope: the same document the jq filter below produces, field + # for field, with jq's string escaping and jq's compact data. + local e_timestamp e_hook e_event e_status + hook::json_escape_jq_to e_timestamp "$timestamp" + hook::json_escape_jq_to e_hook "$hook_id" + hook::json_escape_jq_to e_event "$hook_event" + hook::json_escape_jq_to e_status "$status" + envelope='{"schema_version":"1.0","timestamp":"'"$e_timestamp"'","hook":"'"$e_hook"'","hook_event":"'"$e_event"'","status":"'"$e_status"'","duration_ms":'"$duration_ms"',"data":'"$data"'}' + else + # jq path, for a data object the builtin compactor cannot prove. Fail-open: + # jq absent → return 0. The `data` object is written to a temp file, not + # passed as --argjson, so payloads larger than the Windows 32767-character + # command-line cap are not dropped before jq runs (#1595). /dev/stdin is + # not used: jq's --slurpfile there is not portable on Windows. Redirect jq + # stderr to /dev/null; output goes to a local variable — never to fd1. + command -v jq >/dev/null 2>&1 || return 0 + local data_file + data_file=$(mktemp) || return 0 + printf '%s' "$data_json" >"$data_file" || { + rm -f "$data_file" + return 0 + } + envelope=$(jq -nc \ + --arg schema_version "1.0" \ + --arg timestamp "$timestamp" \ + --arg hook "$hook_id" \ + --arg hook_event "$hook_event" \ + --arg status "$status" \ + --argjson duration_ms "$duration_ms" \ + --slurpfile data "$data_file" \ + '{schema_version:$schema_version,timestamp:$timestamp,hook:$hook,hook_event:$hook_event,status:$status,duration_ms:$duration_ms,data:($data[0] // {})}' \ + 2>/dev/null) || { + rm -f "$data_file" + return 0 + } rm -f "$data_file" - return 0 - } - rm -f "$data_file" + fi # Resolve the sink path. A relative HOOK_TELEMETRY_SINK is joined onto the # consuming repo root (portable, tracked wiring); absolute is used as-is. A diff --git a/plugins/rate-limit-guard/.claude-plugin/plugin.json b/plugins/rate-limit-guard/.claude-plugin/plugin.json index 7cd67a1289..36729e421e 100644 --- a/plugins/rate-limit-guard/.claude-plugin/plugin.json +++ b/plugins/rate-limit-guard/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "rate-limit-guard", - "version": "0.7.27", + "version": "0.7.28", "description": "Shared rate-limit guard for loop lanes: a statusline wrapper tees the subscription rate-limit windows to a fixed machine-scope file, a StopFailure hook records rate-limit stops reactively, and a reader contract fixes how consuming sessions pause and resume.", "author": { "name": "Melodic Software", diff --git a/plugins/rate-limit-guard/CHANGELOG.md b/plugins/rate-limit-guard/CHANGELOG.md index df734c868a..40f979479e 100644 --- a/plugins/rate-limit-guard/CHANGELOG.md +++ b/plugins/rate-limit-guard/CHANGELOG.md @@ -3,6 +3,22 @@ All notable changes to the `rate-limit-guard` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.7.28] + +### Changed + +- **Vendored `hook-utils.sh` builds the telemetry envelope and reads `file_path` + with shell builtins.** `hook::emit_telemetry` no longer spawns two jq + processes, a mktemp and an rm per run: the envelope is assembled in the shell + as one compact line (the same document jq produced, now `jq -c` shaped), with + jq kept only as the fallback for a data object the builtin compactor cannot + prove. `hook::read_file_path` takes `.tool_input.file_path` without jq on the + well-formed payload shape and resolves the file, project root and temp roots + with one batched `realpath` instead of one process each. Same verdicts, same + emitted path, same sink record; phase 4b of the hook-performance program + (#3623). The copy is bumped because `scripts/sync-hook-utils.sh` keeps every + carrying plugin byte-identical. + ## [0.7.27] ### Changed diff --git a/plugins/rate-limit-guard/hooks/hook-utils.sh b/plugins/rate-limit-guard/hooks/hook-utils.sh index 9818a6a73d..18e4dd5745 100755 --- a/plugins/rate-limit-guard/hooks/hook-utils.sh +++ b/plugins/rate-limit-guard/hooks/hook-utils.sh @@ -390,19 +390,34 @@ hook::require_jq_blocking() { # collapse with `/c/repo` and the membership guard would admit a sibling # outside CLAUDE_PROJECT_DIR. The result is used ONLY for comparison; the # emitted path is always the caller's original. -hook::normalize_path() { - local p="${1//\\//}" +# +# Two spellings of every path helper below: `hook::` prints the answer +# (the public, subshell-friendly form every caller already uses) and +# `hook::_to ` stores it in the caller's variable instead. +# The `_to` form is what the hot path uses: a `$(...)` capture is a fork, and +# on Windows Git Bash a fork costs milliseconds, so the file_path guard that +# ran a dozen of them per hook now runs none it does not need. The `_to` +# helpers keep their locals under a `__hu_` prefix so the caller's variable +# name cannot collide with them. +hook::normalize_path_to() { + local __hu_p="${2//\\//}" case "${OSTYPE:-}" in msys* | cygwin* | win32) - if [[ "$p" =~ ^/([a-zA-Z])/ || "$p" =~ ^([a-zA-Z]):/ ]]; then - local rest="${p:2}" - printf '%s' "${BASH_REMATCH[1]^}:${rest,,}" - return + if [[ "$__hu_p" =~ ^/([a-zA-Z])/ || "$__hu_p" =~ ^([a-zA-Z]):/ ]]; then + local __hu_rest="${__hu_p:2}" + printf -v "$1" '%s' "${BASH_REMATCH[1]^}:${__hu_rest,,}" + return 0 fi ;; *) ;; # POSIX hosts: case-sensitive FS, no drive fold — pass through below esac - printf '%s' "$p" + printf -v "$1" '%s' "$__hu_p" +} + +hook::normalize_path() { + local __hu_n + hook::normalize_path_to __hu_n "$1" + printf '%s' "$__hu_n" } # Expand Windows 8.3 short-name components (KYLESE~1 → KyleSexton) on @@ -424,25 +439,31 @@ hook::normalize_path() { # class: cygpath ships with Git Bash (the documented Windows bash), so its # absence or failure keeps the resolver's answer unchanged — degrading to the # pre-expansion comparison, same doctrine as the resolver fallback below. -hook::expand_8dot3() { - local p="$1" +hook::expand_8dot3_to() { + local __hu_p="$2" case "${OSTYPE:-}" in msys* | cygwin* | win32) ;; *) - printf '%s' "$p" - return + printf -v "$1" '%s' "$__hu_p" + return 0 ;; esac - if [[ "$p" == *~* ]] && command -v cygpath >/dev/null 2>&1; then - local plain long - if plain=$(cygpath -m -- "$p" 2>/dev/null) && - long=$(cygpath -l -m -- "$p" 2>/dev/null) && - [[ -n "$long" && "$long" != "$plain" ]]; then - printf '%s' "$long" - return + if [[ "$__hu_p" == *~* ]] && command -v cygpath >/dev/null 2>&1; then + local __hu_plain __hu_long + if __hu_plain=$(cygpath -m -- "$__hu_p" 2>/dev/null) && + __hu_long=$(cygpath -l -m -- "$__hu_p" 2>/dev/null) && + [[ -n "$__hu_long" && "$__hu_long" != "$__hu_plain" ]]; then + printf -v "$1" '%s' "$__hu_long" + return 0 fi fi - printf '%s' "$p" + printf -v "$1" '%s' "$__hu_p" +} + +hook::expand_8dot3() { + local __hu_e + hook::expand_8dot3_to __hu_e "$1" + printf '%s' "$__hu_e" } # Canonicalize to a physical path — symlinks resolved, Windows 8.3 short names @@ -462,20 +483,148 @@ hook::expand_8dot3() { # expansion applies only on the resolver's success path: consumers that fail # closed on an unresolved signature must not see a form-converted path instead. # shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED -hook::physical_path() { - local resolved +hook::physical_path_to() { + local __hu_r HOOK_PHYSICAL_PATH_UNRESOLVED=0 - if resolved=$(realpath -- "$1" 2>/dev/null) || resolved=$(readlink -f -- "$1" 2>/dev/null); then - if [[ -n "$resolved" ]]; then - hook::expand_8dot3 "$resolved" + if __hu_r=$(realpath -- "$2" 2>/dev/null) || __hu_r=$(readlink -f -- "$2" 2>/dev/null); then + if [[ -n "$__hu_r" ]]; then + hook::expand_8dot3_to "$1" "$__hu_r" return 0 fi fi HOOK_PHYSICAL_PATH_UNRESOLVED=1 - printf '%s' "$1" + printf -v "$1" '%s' "$2" + return 1 +} + +# shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED +hook::physical_path() { + local __hu_v __hu_rc=0 + hook::physical_path_to __hu_v "$1" || __hu_rc=$? + printf '%s' "$__hu_v" + return "$__hu_rc" +} + +# --- Per-process physical-path cache -------------------------------------- +# The membership guard resolves the same few directories on every call: the +# project root and this host's temp roots. Their physical form does not change +# within one hook process, so each spelling is resolved once and remembered in +# three parallel indexed arrays (keys, values, resolver status). Plain arrays, +# not an associative array, so the cache runs on Bash 3.2. Only directories are +# meant to live here for a process lifetime; hook::read_file_path forgets the +# edited file's entry as soon as it has read it, so a later call in the same +# process sees the file as it is then. +_HOOK_PHYS_KEYS=() +_HOOK_PHYS_VALS=() +_HOOK_PHYS_RCS=() +_HOOK_PHYS_I=-1 + +# hook::_phys_cache_index : sets _HOOK_PHYS_I to the slot holding , +# returns 1 when it is not cached. A forgotten slot has an empty key, which no +# real path can match. +hook::_phys_cache_index() { + local __hu_i + [[ -n "$1" ]] || return 1 + for ((__hu_i = 0; __hu_i < ${#_HOOK_PHYS_KEYS[@]}; __hu_i++)); do + if [[ "${_HOOK_PHYS_KEYS[__hu_i]}" == "$1" ]]; then + _HOOK_PHYS_I=$__hu_i + return 0 + fi + done return 1 } +hook::_phys_cache_forget() { + hook::_phys_cache_index "$1" && _HOOK_PHYS_KEYS[_HOOK_PHYS_I]="" + return 0 +} + +# hook::_physical_cached_to : hook::physical_path_to through the +# cache. Same value, same return status, same HOOK_PHYSICAL_PATH_UNRESOLVED as +# the uncached call; a miss resolves the one path and stores it. +# shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED +hook::_physical_cached_to() { + if hook::_phys_cache_index "$2"; then + printf -v "$1" '%s' "${_HOOK_PHYS_VALS[_HOOK_PHYS_I]}" + HOOK_PHYSICAL_PATH_UNRESOLVED=${_HOOK_PHYS_RCS[_HOOK_PHYS_I]} + return "${_HOOK_PHYS_RCS[_HOOK_PHYS_I]}" + fi + local __hu_v __hu_rc=0 + hook::physical_path_to __hu_v "$2" || __hu_rc=$? + if [[ -n "$2" ]]; then + _HOOK_PHYS_KEYS+=("$2") + _HOOK_PHYS_VALS+=("$__hu_v") + _HOOK_PHYS_RCS+=("$__hu_rc") + fi + printf -v "$1" '%s' "$__hu_v" + return "$__hu_rc" +} + +# hook::_physical_prime ...: resolve every uncached path with ONE +# realpath process and store the answers. realpath resolves each argument on +# its own and prints one line per argument in argument order, so the batch +# answer for a path is the same string the per-path call returns; the 8.3 +# expansion is applied per answer exactly as hook::physical_path_to does. Any +# doubt (a path carrying a newline, a non-zero exit, a line count that does not +# match) stores nothing and leaves the per-path resolver to answer lazily, so +# the batch can only ever save work, never change an answer. readlink -f hosts +# (no realpath) skip the batch for the same reason. +hook::_physical_prime() { + local -a __hu_todo=() + local __hu_p __hu_out __hu_i __hu_seen="" __hu_v + for __hu_p in "$@"; do + [[ -n "$__hu_p" ]] || continue + [[ "$__hu_p" == *$'\n'* ]] && return 0 + case "$__hu_seen" in + *"|$__hu_p|"*) continue ;; + *) ;; # first sighting + esac + __hu_seen="$__hu_seen|$__hu_p|" + hook::_phys_cache_index "$__hu_p" && continue + __hu_todo+=("$__hu_p") + done + ((${#__hu_todo[@]} > 1)) || return 0 + command -v realpath >/dev/null 2>&1 || return 0 + # portability-ok: realpath with several operands is GNU and BSD alike; a host whose realpath rejects it fails the exit-status check and falls back per path + __hu_out=$(realpath -- "${__hu_todo[@]}" 2>/dev/null) || return 0 + local -a __hu_lines=() + local __hu_glob=0 + [[ $- == *f* ]] || __hu_glob=1 + set -f + local IFS=$'\n' + # shellcheck disable=SC2206 # splitting realpath's one-line-per-operand output is the intent + __hu_lines=($__hu_out) + ((__hu_glob)) && set +f + ((${#__hu_lines[@]} == ${#__hu_todo[@]})) || return 0 + for ((__hu_i = 0; __hu_i < ${#__hu_todo[@]}; __hu_i++)); do + [[ -n "${__hu_lines[__hu_i]}" ]] || return 0 + hook::expand_8dot3_to __hu_v "${__hu_lines[__hu_i]}" + _HOOK_PHYS_KEYS+=("${__hu_todo[__hu_i]}") + _HOOK_PHYS_VALS+=("$__hu_v") + _HOOK_PHYS_RCS+=(0) + done + return 0 +} + +# The temp-root candidates hook::under_temp_root compares against: the +# environment's own answer (TMPDIR/TMP/TEMP) plus the POSIX defaults, never a +# hardcoded platform assumption. Existing directories only, each spelling +# once, in _HOOK_TEMP_CANDS. +_HOOK_TEMP_CANDS=() +hook::_temp_root_candidates() { + local __hu_cand __hu_seen="" + _HOOK_TEMP_CANDS=() + for __hu_cand in "${TMPDIR:-}" "${TMP:-}" "${TEMP:-}" /tmp /var/tmp; do + [[ -n "$__hu_cand" && -d "$__hu_cand" ]] || continue + case "$__hu_seen" in + *"|$__hu_cand|"*) continue ;; + *) ;; # first sighting of this candidate + esac + __hu_seen="$__hu_seen|$__hu_cand|" + _HOOK_TEMP_CANDS+=("$__hu_cand") + done +} + # True when sits inside one of this host's temp trees. # Both arguments and candidates go through the same canonicalize+normalize # pipeline as the membership comparison, because the same directory has several @@ -483,21 +632,40 @@ hook::physical_path() { # of the identical directory, and `realpath` resolves the Windows form to a # drive path while leaving `/tmp` as `/tmp`. Neither form alone matches a # `file_path` that could arrive in either, so every candidate is compared and a -# match on any one is a match. Duplicates are resolved once. +# match on any one is a match. Duplicates are resolved once, and each +# candidate's physical form is remembered for the process (see the cache +# above), so a second call costs no resolver process. # -# Candidates are the environment's own answer (TMPDIR/TMP/TEMP) plus the POSIX -# defaults, never a hardcoded platform assumption. +# Raw-spelling shortcut: when the caller vouches that is a +# PHYSICAL path (_HOOK_UTR_TARGET_PHYSICAL=1, set only by hook::read_file_path +# after the resolver succeeded), a candidate whose raw normalized spelling is +# already a prefix of the target is a match without resolving it. A physical +# path contains no symlink component, so a candidate that spells one of its +# prefixes names a chain of real directories whose physical form is that same +# prefix; the resolver would answer the same. A candidate that does not match +# raw is still resolved, so a symlinked temp root (macOS /tmp) is found the +# way it always was. Without the vouch the shortcut is off and every candidate +# is resolved, exactly as before. Note that hook::read_file_path primes every +# candidate into the cache with one batched realpath before calling here, so +# on that path the shortcut skips a lookup, not a process; the batch is what +# saves the processes. A direct caller without the batch saves the resolver. # hook::under_temp_root "$norm_path" && ... +_HOOK_UTR_TARGET_PHYSICAL=0 hook::under_temp_root() { - local target="$1" cand norm seen="" - for cand in "${TMPDIR:-}" "${TMP:-}" "${TEMP:-}" /tmp /var/tmp; do - [[ -n "$cand" && -d "$cand" ]] || continue - case "$seen" in - *"|$cand|"*) continue ;; - *) ;; # first sighting of this candidate — resolve it below - esac - seen="$seen|$cand|" - norm=$(hook::normalize_path "$(hook::physical_path "$cand")") + local target="$1" cand norm phys + hook::_temp_root_candidates + for cand in ${_HOOK_TEMP_CANDS[@]+"${_HOOK_TEMP_CANDS[@]}"}; do + if ((_HOOK_UTR_TARGET_PHYSICAL)); then + hook::normalize_path_to norm "$cand" + if [[ "$norm" != / ]]; then + norm="${norm%/}" + if [[ -n "$norm" && ("$target" == "$norm" || "$target" == "$norm"/*) ]]; then + return 0 + fi + fi + fi + hook::_physical_cached_to phys "$cand" || : + hook::normalize_path_to norm "$phys" # The filesystem root as a temp candidate contains every absolute path; # trimming its only slash would empty the candidate and discard it. [[ "$norm" == / ]] && return 0 @@ -523,21 +691,421 @@ hook::in_git_working_tree() { ) >/dev/null 2>&1 } +# --- Builtin JSON helpers ---------------------------------------------------- +# hook::read_file_path and hook::emit_telemetry used to spend a jq process each +# on every hook run. On Windows Git Bash one spawn costs tens of milliseconds, +# so the helpers here handle the well-formed shapes those two functions see in +# practice with shell builtins and hand anything they cannot PROVE to the +# unchanged jq path. The contract is "the bytes jq would produce, or fall +# back", never "close enough". +# +# What the builtin paths rely on: the text is valid JSON. Every hook payload +# reaching hook::read_file_path has passed hook::buffer_stdin's jq validation, +# and every telemetry data object is built with jq. The helpers still verify +# the structure they walk (terminated strings, well-formed tokens, properly +# nested brackets, one root, no raw control bytes inside strings), but they +# are not a full JSON parser and do not claim to reject every malformed text. +# +# Why no scanning: on this repo's Windows hosts bash regex matching and the +# `%%`/`##` pattern operators cost about a microsecond per character, so a +# 60 KB Write payload would cost more than the jq process they replace. The +# only string operations used on the whole payload are literal-substring +# replacement, `[[ == *x* ]]` containment, IFS word splitting and offset +# slicing, all of which run at C speed. + +# hook::_json_split +# Split at every unescaped double quote into the global array +# _HOOK_JSON_PARTS: even indices hold the structural text between strings, odd +# indices hold string bodies. Before splitting, every `\\` and `\"` pair is +# replaced by `@@` (same length), so a quote that survives is a real string +# delimiter and a body's ORIGINAL bytes are ${text:offset:length}. Returns 1 +# when the text is too large, its quotes do not pair up, or it holds more +# strings than the callers' loops are meant to walk. +_HOOK_JSON_PARTS=() +hook::_json_split() { + local __hu_s="$1" __hu_t __hu_q __hu_n + ((${#__hu_s} <= 65536)) || return 1 + __hu_t=${__hu_s//"\\\\"/@@} + __hu_t=${__hu_t//"\\\""/@@} + __hu_q=${__hu_t//\"/} + __hu_n=$((${#__hu_t} - ${#__hu_q})) + ((__hu_n % 2 == 0)) || return 1 + ((__hu_n <= 800)) || return 1 + local __hu_glob=0 + [[ $- == *f* ]] || __hu_glob=1 + set -f + local IFS='"' + # shellcheck disable=SC2206 # splitting on IFS='"' is the whole point + _HOOK_JSON_PARTS=($__hu_t) + ((__hu_glob)) && set +f + ((${#_HOOK_JSON_PARTS[@]} % 2 == 1)) || return 1 + return 0 +} + +# hook::_json_skeleton +# Split (hook::_json_split), record each string body's byte offset in +# _HOOK_JSON_OFF, and build _HOOK_JSON_SK: the structural text with every +# string replaced by a numbered placeholder `"#"` and all JSON +# whitespace removed. The skeleton is small (no string bodies), so regex and +# per-token work on it is cheap. Returns 1 unless the text is exactly one JSON +# value by the JSON grammar (well-formed tokens, whitespace only between them, +# objects as key:value lists, arrays as value lists, brackets nested and +# closed once) whose strings carry no raw control byte, which jq rejects. +_HOOK_JSON_SK="" +_HOOK_JSON_OFF=() +hook::_json_skeleton() { + local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top + hook::_json_split "$__hu_s" || return 1 + __hu_n=${#_HOOK_JSON_PARTS[@]} + _HOOK_JSON_OFF=() + for ((__hu_i = 0; __hu_i < __hu_n; __hu_i++)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + if ((__hu_i % 2 == 0)); then + __hu_sk+=$__hu_part + else + _HOOK_JSON_OFF[__hu_i]=$__hu_off + [[ "$__hu_part" == *[[:cntrl:]]* ]] && return 1 + __hu_sk+="\"#$__hu_i\"" + fi + __hu_off=$((__hu_off + ${#__hu_part} + 1)) + done + # Tokenize the skeleton and run it through the JSON grammar. Each token is + # consumed by one anchored regex on the (small) remainder; whitespace is + # allowed only between tokens, so `tr ue` is two bad tokens, not `true`. + local __hu_re=$'^[ \t\n\r]*(\\{|\\}|\\[|\\]|,|:|"#[0-9]+"|-?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][-+]?[0-9]+)?|true|false|null)' + local __hu_ws=$'^[ \t\n\r]*$' + __hu_rest=$__hu_sk + while [[ -n "$__hu_rest" ]] && ! [[ "$__hu_rest" =~ $__hu_ws ]]; do + [[ "$__hu_rest" =~ $__hu_re ]] || return 1 + __hu_tok=${BASH_REMATCH[1]} + __hu_rest=${__hu_rest:${#BASH_REMATCH[0]}} + __hu_top=${__hu_stack:${#__hu_stack}-1:1} + case "$__hu_expect" in + value | value_or_end) + case "$__hu_tok" in + '{') + __hu_stack+='{' + __hu_expect=key_or_end + continue + ;; + '[') + __hu_stack+='[' + __hu_expect=value_or_end + continue + ;; + ']') + [[ "$__hu_expect" == value_or_end ]] || return 1 + ;; + '}' | ',' | ':') return 1 ;; + *) + # A scalar. Inside a container the next token is , or the close; + # at the top level nothing may follow. + if [[ -n "$__hu_stack" ]]; then __hu_expect=comma_or_end; else __hu_expect=end; fi + continue + ;; + esac + ;; + key_or_end | key) + case "$__hu_tok" in + \"#*) + __hu_expect='colon' + continue + ;; + '}') [[ "$__hu_expect" == key_or_end ]] || return 1 ;; + *) return 1 ;; + esac + ;; + colon) + [[ "$__hu_tok" == ':' ]] || return 1 + __hu_expect=value + continue + ;; + comma_or_end) + case "$__hu_tok" in + ',') + if [[ "$__hu_top" == '{' ]]; then __hu_expect=key; else __hu_expect=value; fi + continue + ;; + '}') [[ "$__hu_top" == '{' ]] || return 1 ;; + ']') [[ "$__hu_top" == '[' ]] || return 1 ;; + *) return 1 ;; + esac + ;; + *) return 1 ;; # `end`: a token after the root value closed + esac + # Reaching here means a container just closed. + [[ -n "$__hu_stack" ]] || return 1 + __hu_stack=${__hu_stack:0:${#__hu_stack}-1} + if [[ -n "$__hu_stack" ]]; then __hu_expect=comma_or_end; else __hu_expect=end; fi + done + [[ "$__hu_expect" == end ]] || return 1 + _HOOK_JSON_SK=${__hu_sk//[$' \t\n\r']/} + return 0 +} + +# hook::json_unescape_to +# Decode a JSON string body (the bytes between the quotes) into . Handles +# \" \\ \/ \b \f \n \r \t and \uXXXX for U+0001 to U+007F. Returns 1 on any +# other escape (a \u outside that range, U+0000 which bash cannot hold, or an +# invalid escape), leaving the caller to fall back to jq. +hook::json_unescape_to() { + local __hu_raw="$2" __hu_out="" __hu_pre __hu_c __hu_hex __hu_oct __hu_ch + while [[ "$__hu_raw" == *\\* ]]; do + __hu_pre=${__hu_raw%%\\*} + __hu_out+=$__hu_pre + __hu_raw=${__hu_raw:${#__hu_pre}+1} + __hu_c=${__hu_raw:0:1} + __hu_raw=${__hu_raw:1} + case "$__hu_c" in + '"') __hu_out+='"' ;; + "\\") __hu_out+="\\" ;; + '/') __hu_out+='/' ;; + b) __hu_out+=$'\b' ;; # portability-ok: bash ANSI-C backspace byte, not a GNU grep word boundary + f) __hu_out+=$'\f' ;; + n) __hu_out+=$'\n' ;; + r) __hu_out+=$'\r' ;; + t) __hu_out+=$'\t' ;; + u) + __hu_hex=${__hu_raw:0:4} + [[ "$__hu_hex" =~ ^00[0-7][0-9a-fA-F]$ ]] || return 1 + [[ "$__hu_hex" == 0000 ]] && return 1 + __hu_raw=${__hu_raw:4} + printf -v __hu_oct '%03o' "$((16#$__hu_hex))" + printf -v __hu_ch '%b' "\\0$__hu_oct" + __hu_out+=$__hu_ch + ;; + *) return 1 ;; + esac + done + __hu_out+=$__hu_raw + printf -v "$1" '%s' "$__hu_out" +} + +# hook::_fast_file_path_to +# The builtin answer to `jq -r '(.tool_input.file_path // empty) | +# gsub("\r";"")'` followed by the `$(...)` capture. Returns +# 0 proven: holds exactly what jq would have printed +# 1 proven absent: jq would have printed nothing (the guard skips) +# 2 not proven: run jq +# Proof, on top of hook::_json_skeleton's structural checks: the root is an +# object; exactly one string in the whole payload decodes to `tool_input` and +# exactly one to `file_path` (so neither a duplicate key nor a same-named key +# in another object can change jq's answer); `tool_input` is a key of the root +# (depth 1, key position) whose value is a flat object (no nested object or +# array, else jq); and inside it `file_path` is a key with a plain string +# value. A payload where `file_path` names a value or sits in another object +# has no `.tool_input.file_path`, which jq reports as nothing. Key strings are +# compared AFTER decoding their escapes, so a key spelled with \u escapes is +# still recognized; a body longer than any escaped spelling of either name is +# skipped without decoding. +hook::_fast_file_path_to() { + local __hu_s="$2" __hu_i __hu_n __hu_part __hu_body __hu_ti=-1 __hu_fp=-1 __hu_m __hu_raw __hu_pre __hu_re __hu_file + hook::_json_skeleton "$__hu_s" || return 2 + [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 + __hu_n=${#_HOOK_JSON_PARTS[@]} + for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + ((${#__hu_part} <= 60)) || continue + __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} + if [[ "$__hu_body" == *\\* ]]; then + hook::json_unescape_to __hu_body "$__hu_body" || continue + fi + case "$__hu_body" in + tool_input) + ((__hu_ti < 0)) || return 2 + __hu_ti=$__hu_i + ;; + file_path) + ((__hu_fp < 0)) || return 2 + __hu_fp=$__hu_i + ;; + *) ;; + esac + done + ((__hu_fp >= 0)) || return 1 + ((__hu_ti >= 0)) || return 1 + __hu_re="(^|[{,])\"#$__hu_ti\":\\{([^][{}]*)\\}" + if [[ "$_HOOK_JSON_SK" =~ $__hu_re ]]; then + __hu_body=${BASH_REMATCH[2]} + __hu_pre=${_HOOK_JSON_SK%%\"#"$__hu_ti"\"*} + # Depth of the key: opening brackets minus closing ones before it must be + # exactly one, i.e. a direct member of the root object. + local __hu_o1=${__hu_pre//\{/} __hu_o2=${__hu_pre//\[/} __hu_c1=${__hu_pre//\}/} __hu_c2=${__hu_pre//\]/} + local __hu_depth=$(((${#__hu_pre} - ${#__hu_o1}) + (${#__hu_pre} - ${#__hu_o2}) - (${#__hu_pre} - ${#__hu_c1}) - (${#__hu_pre} - ${#__hu_c2}))) + ((__hu_depth == 1)) || return 2 + else + return 2 + fi + __hu_re="(^|,)\"#$__hu_fp\":\"#([0-9]+)\"(,|$)" + if [[ "$__hu_body" =~ $__hu_re ]]; then + __hu_m=${BASH_REMATCH[2]} + elif [[ "$__hu_body" == *\"#$__hu_fp\"* ]]; then + return 2 + else + return 1 + fi + __hu_raw=${__hu_s:${_HOOK_JSON_OFF[__hu_m]}:${#_HOOK_JSON_PARTS[__hu_m]}} + if [[ "$__hu_raw" == *\\* ]]; then + hook::json_unescape_to __hu_file "$__hu_raw" || return 2 + else + __hu_file=$__hu_raw + fi + __hu_file=${__hu_file//$'\r'/} + while [[ "$__hu_file" == *$'\n' ]]; do + __hu_file=${__hu_file%$'\n'} + done + printf -v "$1" '%s' "$__hu_file" + return 0 +} + +# hook::dirname_to : dirname with builtins for the resolver's +# answer. Strips the last segment; a bare name lives in `.`, a root-level file +# in `/`. The path comes from realpath, so the trailing-slash and doubled-slash +# spellings dirname also collapses never occur here. +hook::dirname_to() { + local __hu_d="${2%/*}" + [[ "$__hu_d" == "$2" ]] && __hu_d=. + [[ -n "$__hu_d" ]] || __hu_d=/ + printf -v "$1" '%s' "$__hu_d" +} + +# Print the arguments joined by NUL bytes, no trailing NUL: the byte stream +# hook::read_file_path read from stdin, rebuilt for the jq fallback. +hook::_print_nul_joined() { + local __hu_first=1 __hu_c + for __hu_c in "$@"; do + ((__hu_first)) || printf '\0' + __hu_first=0 + printf '%s' "$__hu_c" + done +} + +# hook::json_compact_to +# The bytes `jq -c .` prints for , when that can be proven with +# builtins: JSON whitespace between tokens removed, everything else verbatim. +# jq's own output, compact or pretty, satisfies the proof, which is what every +# telemetry data builder in this marketplace produces (jq -c, jq -n, or a +# compact literal). Returns 1 when the text is not a single object, or a +# string carries something jq would re-encode (a \u or \/ escape, an escape +# outside the JSON set, a raw control byte), or the structure fails +# hook::_json_skeleton; the caller then runs jq. +hook::json_compact_to() { + local __hu_s="$2" __hu_out="" __hu_i __hu_part __hu_n + hook::_json_skeleton "$__hu_s" || return 1 + [[ "$_HOOK_JSON_SK" == \{*\} ]] || return 1 + # Numbers: only short integer literals are passed through. A fraction, an + # exponent or an integer beyond 15 digits is rendered differently by + # different jq releases (1.6 canonicalizes, 1.7+ preserves the literal), so + # the bytes cannot be proven; jq decides. In the skeleton every string is a + # `"#N"` placeholder and letters occur only in true/false/null, so a digit + # followed by `.`, `e` or `E` can only be part of a number. + local __hu_num='[0-9][.eE]|[0-9]{16}' + [[ "$_HOOK_JSON_SK" =~ $__hu_num ]] && return 1 + __hu_n=${#_HOOK_JSON_PARTS[@]} + for ((__hu_i = 0; __hu_i < __hu_n; __hu_i++)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + if ((__hu_i % 2 == 0)); then + __hu_out+=${__hu_part//[$' \t\n\r']/} + else + if [[ "$__hu_part" == *\\* ]]; then + [[ "$__hu_part" == *'\u'* || "$__hu_part" == *'\/'* ]] && return 1 + [[ "$__hu_part" == *\\[!bfnrt]* ]] && return 1 + fi + __hu_out+="\"${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}}\"" + fi + done + printf -v "$1" '%s' "$__hu_out" +} + +# hook::json_escape_jq_to / hook::json_escape_jq +# Escape exactly as jq serializes a string value (without the +# quotes): backslash and double quote, the five short forms \b \f \n \r \t, +# every other byte from 0x01 to 0x1f and 0x7f as lowercase \u00xx, and +# everything else, including non-ASCII, verbatim. This is the escaper the +# telemetry envelope needs for byte parity with the jq envelope it replaced; +# hook::json_escape above is a different tool (it DROPS the residual control +# bytes, which is right for a notice and wrong for parity) and is unchanged. +hook::json_escape_jq_to() { + local __hu_s="$2" __hu_i __hu_c __hu_oct __hu_hex + __hu_s="${__hu_s//\\/\\\\}" + __hu_s="${__hu_s//\"/\\\"}" + if [[ "$__hu_s" == *[[:cntrl:]]* ]]; then + __hu_s="${__hu_s//$'\n'/\\n}" + __hu_s="${__hu_s//$'\r'/\\r}" + __hu_s="${__hu_s//$'\t'/\\t}" + __hu_s="${__hu_s//$'\b'/\\b}" # portability-ok: bash ANSI-C backspace byte and the JSON \b escape, not a GNU grep word boundary + __hu_s="${__hu_s//$'\f'/\\f}" + for __hu_i in 1 2 3 4 5 6 7 11 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 127; do + printf -v __hu_oct '%03o' "$__hu_i" + printf -v __hu_c '%b' "\\0$__hu_oct" + [[ "$__hu_s" == *"$__hu_c"* ]] || continue + printf -v __hu_hex '%04x' "$__hu_i" + __hu_s="${__hu_s//"$__hu_c"/\\u$__hu_hex}" + done + fi + printf -v "$1" '%s' "$__hu_s" +} + +hook::json_escape_jq() { + local __hu_e + hook::json_escape_jq_to __hu_e "$1" + printf '%s' "$__hu_e" +} + # Parse file_path from PostToolUse JSON on stdin; validate existence and (when # CLAUDE_PROJECT_DIR is set) project membership. Both sides of the membership # comparison are canonicalized (symlinks resolved) first, so neither an # escaping symlink nor a project root reached via a symlinked path (e.g. # macOS /tmp) skews the verdict. Outputs the path on success. Returns 1 to skip. # FILE=$(hook::read_file_path) || exit 0 +# +# jq-free on the common shape. The payload is read into the shell and +# hook::_fast_file_path_to takes `.tool_input.file_path` with builtins when it +# can PROVE the answer jq would give (see that helper for what "prove" means); +# every other payload goes to the unchanged jq filter. The path comparison then +# resolves the file, the project root and the temp roots with one realpath +# process instead of one each (hook::_physical_prime), and remembers the +# directories for the process. Same verdict, same emitted path, fewer +# processes: on Windows Git Bash each spawn costs tens of milliseconds and this +# guard runs on every Write and Edit. hook::read_file_path() { - local file - file=$(jq -r '(.tool_input.file_path // empty) | gsub("\r";"")' 2>/dev/null) + local -a chunks=() + local chunk file="" mode=2 + # Builtin read to NUL or EOF. A NUL splits the payload into several chunks; + # the fast path only takes a single-chunk (NUL-free) payload, and the jq + # fallback is fed the chunks NUL-joined, so it sees the bytes jq used to + # read straight from stdin. + while :; do + chunk="" + if IFS= read -r -d '' chunk; then + chunks+=("$chunk") + continue + fi + chunks+=("$chunk") + break + done + if ((${#chunks[@]} == 1)); then + mode=0 + hook::_fast_file_path_to file "${chunks[0]}" || mode=$? + fi + case "$mode" in + 0) ;; # proven: `file` holds jq's answer + 1) return 1 ;; # proven absent + *) + file=$(hook::_print_nul_joined "${chunks[@]}" | jq -r '(.tool_input.file_path // empty) | gsub("\r";"")' 2>/dev/null) + ;; + esac [[ -n "$file" ]] || return 1 [[ -f "$file" ]] || return 1 if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then - local norm_file norm_project - norm_file=$(hook::normalize_path "$(hook::physical_path "$file")") - norm_project=$(hook::normalize_path "$(hook::physical_path "${CLAUDE_PROJECT_DIR}")") + local norm_file norm_project phys_file phys_project file_resolved=0 project_resolved=0 + hook::_temp_root_candidates + hook::_physical_prime "$file" "${CLAUDE_PROJECT_DIR}" ${_HOOK_TEMP_CANDS[@]+"${_HOOK_TEMP_CANDS[@]}"} + hook::_physical_cached_to phys_file "$file" && file_resolved=1 + hook::_phys_cache_forget "$file" + hook::_physical_cached_to phys_project "${CLAUDE_PROJECT_DIR}" && project_resolved=1 + hook::normalize_path_to norm_file "$phys_file" + hook::normalize_path_to norm_project "$phys_project" norm_project="${norm_project%/}" # Anchor on a path-segment boundary: accept the project root itself or a # child under it, but not a sibling whose name merely shares the prefix @@ -558,19 +1126,28 @@ hook::read_file_path() { # built by `mktemp -d`, which is how this repo's own hook suites run), so # the branch must not fire. Only a temp-tree file reached from a project # root OUTSIDE the temp tree is scratch. - if hook::under_temp_root "$norm_file" && ! hook::under_temp_root "$norm_project"; then - return 1 + local file_in_temp=0 + _HOOK_UTR_TARGET_PHYSICAL=$file_resolved + hook::under_temp_root "$norm_file" && file_in_temp=1 + _HOOK_UTR_TARGET_PHYSICAL=0 + if ((file_in_temp)); then + local project_in_temp=0 + _HOOK_UTR_TARGET_PHYSICAL=$project_resolved + hook::under_temp_root "$norm_project" && project_in_temp=1 + _HOOK_UTR_TARGET_PHYSICAL=0 + ((project_in_temp)) || return 1 fi elif command -v git >/dev/null 2>&1; then # When CLAUDE_PROJECT_DIR is unset, scope to git-working-tree membership so # scratch files outside any repository are not mutated by formatter hooks # (#1091 / #972). - local file_physical - file_physical=$(hook::physical_path "$file") + local file_physical file_dir + hook::physical_path_to file_physical "$file" || : if [[ -L "$file" && "$file_physical" == "$file" ]]; then return 1 fi - if ! hook::in_git_working_tree "$(dirname "$file_physical")"; then + hook::dirname_to file_dir "$file_physical" + if ! hook::in_git_working_tree "$file_dir"; then return 1 fi else @@ -1228,7 +1805,19 @@ hook::telemetry_enabled() { # Fire-and-forget: sink is dispatched in the background; the hook never waits # on it and its failure never affects the hook's own exit code or stdout. # Opt-in guard: HOOK_TELEMETRY_SINK unset or empty → return 0 immediately. -# Fail-open: jq absent → return 0 immediately. +# +# The envelope is assembled with shell builtins (hook::json_escape_jq_to for +# the string fields, hook::json_compact_to for the caller's data object) as +# ONE compact line, `jq -c` style: the same document, field order and +# escapes the jq filter below produces, with the data object compacted +# exactly as jq compacts its own output. That path needs no jq, no temp file +# and no subprocess. When the data object cannot be proven jq-idempotent (see +# hook::json_compact_to), the jq path runs, now with -c so both paths write +# the same bytes, and there jq's absence is still fail-open: return 0, no +# envelope. Before this the envelope was jq's default pretty-printed form +# (several lines, and CRLF line endings with a Windows jq); the sink contract +# was always "one JSON document on stdin", and a compact line is also valid +# JSONL for a sink that appends raw envelopes. # # Usage: # hook::emit_telemetry [repo_root] @@ -1251,7 +1840,6 @@ hook::telemetry_enabled() { # NEVER writes to fd1 (the hook's stdout / additionalContext channel). hook::emit_telemetry() { [[ -n "${HOOK_TELEMETRY_SINK:-}" ]] || return 0 - command -v jq >/dev/null 2>&1 || return 0 local hook_id="$1" local hook_event="$2" @@ -1272,35 +1860,59 @@ hook::emit_telemetry() { local e_s="${now%[.,]*}" e_f="${now#*[.,]}" local duration_ms=$(((e_s * 1000000 + 10#$e_f - s_s * 1000000 - 10#$s_f) / 1000)) - # True UTC timestamp (TZ= prefix overrides LC_ALL / local TZ; the Z is not a lie). - local timestamp - timestamp=$(TZ=UTC printf '%(%Y-%m-%dT%H:%M:%SZ)T' -1) - - # Build the envelope. The `data` object is written to a temp file, not passed - # as --argjson, so payloads larger than the Windows 32767-character command-line - # cap are not dropped before jq runs (#1595). /dev/stdin is not used: jq's - # --slurpfile there is not portable on Windows. Redirect jq stderr to - # /dev/null; output goes to a local variable — never to fd1. - local envelope data_file - data_file=$(mktemp) || return 0 - printf '%s' "$data_json" >"$data_file" || { - rm -f "$data_file" - return 0 - } - envelope=$(jq -n \ - --arg schema_version "1.0" \ - --arg timestamp "$timestamp" \ - --arg hook "$hook_id" \ - --arg hook_event "$hook_event" \ - --arg status "$status" \ - --argjson duration_ms "$duration_ms" \ - --slurpfile data "$data_file" \ - '{schema_version:$schema_version,timestamp:$timestamp,hook:$hook,hook_event:$hook_event,status:$status,duration_ms:$duration_ms,data:($data[0] // {})}' \ - 2>/dev/null) || { + # True UTC timestamp (TZ= prefix overrides LC_ALL / local TZ; the Z is not a + # lie). The %()T printf format is Bash 4.2+, and printf -v keeps it in the + # shell (a `$(...)` capture is a fork); an older bash, or a printf that + # binds nothing, falls back to date -u. + local timestamp="" + TZ=UTC printf -v timestamp '%(%Y-%m-%dT%H:%M:%SZ)T' -1 2>/dev/null || timestamp="" + [[ -n "$timestamp" ]] || timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null) || timestamp="" + + # Trailing whitespace after the object (a newline a caller left on the + # value) does not change what jq parses, so it does not block the builtin + # path either. + local envelope="" data="" data_trim="$data_json" + while [[ "$data_trim" == *[$' \t\r\n'] ]]; do + data_trim="${data_trim%?}" + done + if [[ "$data_trim" == \{*\} ]] && hook::json_compact_to data "$data_trim"; then + # Builtin envelope: the same document the jq filter below produces, field + # for field, with jq's string escaping and jq's compact data. + local e_timestamp e_hook e_event e_status + hook::json_escape_jq_to e_timestamp "$timestamp" + hook::json_escape_jq_to e_hook "$hook_id" + hook::json_escape_jq_to e_event "$hook_event" + hook::json_escape_jq_to e_status "$status" + envelope='{"schema_version":"1.0","timestamp":"'"$e_timestamp"'","hook":"'"$e_hook"'","hook_event":"'"$e_event"'","status":"'"$e_status"'","duration_ms":'"$duration_ms"',"data":'"$data"'}' + else + # jq path, for a data object the builtin compactor cannot prove. Fail-open: + # jq absent → return 0. The `data` object is written to a temp file, not + # passed as --argjson, so payloads larger than the Windows 32767-character + # command-line cap are not dropped before jq runs (#1595). /dev/stdin is + # not used: jq's --slurpfile there is not portable on Windows. Redirect jq + # stderr to /dev/null; output goes to a local variable — never to fd1. + command -v jq >/dev/null 2>&1 || return 0 + local data_file + data_file=$(mktemp) || return 0 + printf '%s' "$data_json" >"$data_file" || { + rm -f "$data_file" + return 0 + } + envelope=$(jq -nc \ + --arg schema_version "1.0" \ + --arg timestamp "$timestamp" \ + --arg hook "$hook_id" \ + --arg hook_event "$hook_event" \ + --arg status "$status" \ + --argjson duration_ms "$duration_ms" \ + --slurpfile data "$data_file" \ + '{schema_version:$schema_version,timestamp:$timestamp,hook:$hook,hook_event:$hook_event,status:$status,duration_ms:$duration_ms,data:($data[0] // {})}' \ + 2>/dev/null) || { + rm -f "$data_file" + return 0 + } rm -f "$data_file" - return 0 - } - rm -f "$data_file" + fi # Resolve the sink path. A relative HOOK_TELEMETRY_SINK is joined onto the # consuming repo root (portable, tracked wiring); absolute is used as-is. A diff --git a/plugins/ruff-format/.claude-plugin/plugin.json b/plugins/ruff-format/.claude-plugin/plugin.json index c384eee7c5..4614086981 100644 --- a/plugins/ruff-format/.claude-plugin/plugin.json +++ b/plugins/ruff-format/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "ruff-format", - "version": "0.6.30", + "version": "0.6.31", "description": "Auto-format and lint Python on edit via Ruff, only when a Ruff config governs the repo \u2014 using the consuming repo's own Ruff config.", "author": { "name": "Melodic Software", diff --git a/plugins/ruff-format/CHANGELOG.md b/plugins/ruff-format/CHANGELOG.md index f43d7fe851..d93d394bd7 100644 --- a/plugins/ruff-format/CHANGELOG.md +++ b/plugins/ruff-format/CHANGELOG.md @@ -3,6 +3,22 @@ All notable changes to the `ruff-format` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.6.31] + +### Changed + +- **Vendored `hook-utils.sh` builds the telemetry envelope and reads `file_path` + with shell builtins.** `hook::emit_telemetry` no longer spawns two jq + processes, a mktemp and an rm per run: the envelope is assembled in the shell + as one compact line (the same document jq produced, now `jq -c` shaped), with + jq kept only as the fallback for a data object the builtin compactor cannot + prove. `hook::read_file_path` takes `.tool_input.file_path` without jq on the + well-formed payload shape and resolves the file, project root and temp roots + with one batched `realpath` instead of one process each. Same verdicts, same + emitted path, same sink record; phase 4b of the hook-performance program + (#3623). The copy is bumped because `scripts/sync-hook-utils.sh` keeps every + carrying plugin byte-identical. + ## [0.6.30] ### Changed diff --git a/plugins/ruff-format/hooks/hook-utils.sh b/plugins/ruff-format/hooks/hook-utils.sh index 9818a6a73d..18e4dd5745 100644 --- a/plugins/ruff-format/hooks/hook-utils.sh +++ b/plugins/ruff-format/hooks/hook-utils.sh @@ -390,19 +390,34 @@ hook::require_jq_blocking() { # collapse with `/c/repo` and the membership guard would admit a sibling # outside CLAUDE_PROJECT_DIR. The result is used ONLY for comparison; the # emitted path is always the caller's original. -hook::normalize_path() { - local p="${1//\\//}" +# +# Two spellings of every path helper below: `hook::` prints the answer +# (the public, subshell-friendly form every caller already uses) and +# `hook::_to ` stores it in the caller's variable instead. +# The `_to` form is what the hot path uses: a `$(...)` capture is a fork, and +# on Windows Git Bash a fork costs milliseconds, so the file_path guard that +# ran a dozen of them per hook now runs none it does not need. The `_to` +# helpers keep their locals under a `__hu_` prefix so the caller's variable +# name cannot collide with them. +hook::normalize_path_to() { + local __hu_p="${2//\\//}" case "${OSTYPE:-}" in msys* | cygwin* | win32) - if [[ "$p" =~ ^/([a-zA-Z])/ || "$p" =~ ^([a-zA-Z]):/ ]]; then - local rest="${p:2}" - printf '%s' "${BASH_REMATCH[1]^}:${rest,,}" - return + if [[ "$__hu_p" =~ ^/([a-zA-Z])/ || "$__hu_p" =~ ^([a-zA-Z]):/ ]]; then + local __hu_rest="${__hu_p:2}" + printf -v "$1" '%s' "${BASH_REMATCH[1]^}:${__hu_rest,,}" + return 0 fi ;; *) ;; # POSIX hosts: case-sensitive FS, no drive fold — pass through below esac - printf '%s' "$p" + printf -v "$1" '%s' "$__hu_p" +} + +hook::normalize_path() { + local __hu_n + hook::normalize_path_to __hu_n "$1" + printf '%s' "$__hu_n" } # Expand Windows 8.3 short-name components (KYLESE~1 → KyleSexton) on @@ -424,25 +439,31 @@ hook::normalize_path() { # class: cygpath ships with Git Bash (the documented Windows bash), so its # absence or failure keeps the resolver's answer unchanged — degrading to the # pre-expansion comparison, same doctrine as the resolver fallback below. -hook::expand_8dot3() { - local p="$1" +hook::expand_8dot3_to() { + local __hu_p="$2" case "${OSTYPE:-}" in msys* | cygwin* | win32) ;; *) - printf '%s' "$p" - return + printf -v "$1" '%s' "$__hu_p" + return 0 ;; esac - if [[ "$p" == *~* ]] && command -v cygpath >/dev/null 2>&1; then - local plain long - if plain=$(cygpath -m -- "$p" 2>/dev/null) && - long=$(cygpath -l -m -- "$p" 2>/dev/null) && - [[ -n "$long" && "$long" != "$plain" ]]; then - printf '%s' "$long" - return + if [[ "$__hu_p" == *~* ]] && command -v cygpath >/dev/null 2>&1; then + local __hu_plain __hu_long + if __hu_plain=$(cygpath -m -- "$__hu_p" 2>/dev/null) && + __hu_long=$(cygpath -l -m -- "$__hu_p" 2>/dev/null) && + [[ -n "$__hu_long" && "$__hu_long" != "$__hu_plain" ]]; then + printf -v "$1" '%s' "$__hu_long" + return 0 fi fi - printf '%s' "$p" + printf -v "$1" '%s' "$__hu_p" +} + +hook::expand_8dot3() { + local __hu_e + hook::expand_8dot3_to __hu_e "$1" + printf '%s' "$__hu_e" } # Canonicalize to a physical path — symlinks resolved, Windows 8.3 short names @@ -462,20 +483,148 @@ hook::expand_8dot3() { # expansion applies only on the resolver's success path: consumers that fail # closed on an unresolved signature must not see a form-converted path instead. # shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED -hook::physical_path() { - local resolved +hook::physical_path_to() { + local __hu_r HOOK_PHYSICAL_PATH_UNRESOLVED=0 - if resolved=$(realpath -- "$1" 2>/dev/null) || resolved=$(readlink -f -- "$1" 2>/dev/null); then - if [[ -n "$resolved" ]]; then - hook::expand_8dot3 "$resolved" + if __hu_r=$(realpath -- "$2" 2>/dev/null) || __hu_r=$(readlink -f -- "$2" 2>/dev/null); then + if [[ -n "$__hu_r" ]]; then + hook::expand_8dot3_to "$1" "$__hu_r" return 0 fi fi HOOK_PHYSICAL_PATH_UNRESOLVED=1 - printf '%s' "$1" + printf -v "$1" '%s' "$2" + return 1 +} + +# shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED +hook::physical_path() { + local __hu_v __hu_rc=0 + hook::physical_path_to __hu_v "$1" || __hu_rc=$? + printf '%s' "$__hu_v" + return "$__hu_rc" +} + +# --- Per-process physical-path cache -------------------------------------- +# The membership guard resolves the same few directories on every call: the +# project root and this host's temp roots. Their physical form does not change +# within one hook process, so each spelling is resolved once and remembered in +# three parallel indexed arrays (keys, values, resolver status). Plain arrays, +# not an associative array, so the cache runs on Bash 3.2. Only directories are +# meant to live here for a process lifetime; hook::read_file_path forgets the +# edited file's entry as soon as it has read it, so a later call in the same +# process sees the file as it is then. +_HOOK_PHYS_KEYS=() +_HOOK_PHYS_VALS=() +_HOOK_PHYS_RCS=() +_HOOK_PHYS_I=-1 + +# hook::_phys_cache_index : sets _HOOK_PHYS_I to the slot holding , +# returns 1 when it is not cached. A forgotten slot has an empty key, which no +# real path can match. +hook::_phys_cache_index() { + local __hu_i + [[ -n "$1" ]] || return 1 + for ((__hu_i = 0; __hu_i < ${#_HOOK_PHYS_KEYS[@]}; __hu_i++)); do + if [[ "${_HOOK_PHYS_KEYS[__hu_i]}" == "$1" ]]; then + _HOOK_PHYS_I=$__hu_i + return 0 + fi + done return 1 } +hook::_phys_cache_forget() { + hook::_phys_cache_index "$1" && _HOOK_PHYS_KEYS[_HOOK_PHYS_I]="" + return 0 +} + +# hook::_physical_cached_to : hook::physical_path_to through the +# cache. Same value, same return status, same HOOK_PHYSICAL_PATH_UNRESOLVED as +# the uncached call; a miss resolves the one path and stores it. +# shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED +hook::_physical_cached_to() { + if hook::_phys_cache_index "$2"; then + printf -v "$1" '%s' "${_HOOK_PHYS_VALS[_HOOK_PHYS_I]}" + HOOK_PHYSICAL_PATH_UNRESOLVED=${_HOOK_PHYS_RCS[_HOOK_PHYS_I]} + return "${_HOOK_PHYS_RCS[_HOOK_PHYS_I]}" + fi + local __hu_v __hu_rc=0 + hook::physical_path_to __hu_v "$2" || __hu_rc=$? + if [[ -n "$2" ]]; then + _HOOK_PHYS_KEYS+=("$2") + _HOOK_PHYS_VALS+=("$__hu_v") + _HOOK_PHYS_RCS+=("$__hu_rc") + fi + printf -v "$1" '%s' "$__hu_v" + return "$__hu_rc" +} + +# hook::_physical_prime ...: resolve every uncached path with ONE +# realpath process and store the answers. realpath resolves each argument on +# its own and prints one line per argument in argument order, so the batch +# answer for a path is the same string the per-path call returns; the 8.3 +# expansion is applied per answer exactly as hook::physical_path_to does. Any +# doubt (a path carrying a newline, a non-zero exit, a line count that does not +# match) stores nothing and leaves the per-path resolver to answer lazily, so +# the batch can only ever save work, never change an answer. readlink -f hosts +# (no realpath) skip the batch for the same reason. +hook::_physical_prime() { + local -a __hu_todo=() + local __hu_p __hu_out __hu_i __hu_seen="" __hu_v + for __hu_p in "$@"; do + [[ -n "$__hu_p" ]] || continue + [[ "$__hu_p" == *$'\n'* ]] && return 0 + case "$__hu_seen" in + *"|$__hu_p|"*) continue ;; + *) ;; # first sighting + esac + __hu_seen="$__hu_seen|$__hu_p|" + hook::_phys_cache_index "$__hu_p" && continue + __hu_todo+=("$__hu_p") + done + ((${#__hu_todo[@]} > 1)) || return 0 + command -v realpath >/dev/null 2>&1 || return 0 + # portability-ok: realpath with several operands is GNU and BSD alike; a host whose realpath rejects it fails the exit-status check and falls back per path + __hu_out=$(realpath -- "${__hu_todo[@]}" 2>/dev/null) || return 0 + local -a __hu_lines=() + local __hu_glob=0 + [[ $- == *f* ]] || __hu_glob=1 + set -f + local IFS=$'\n' + # shellcheck disable=SC2206 # splitting realpath's one-line-per-operand output is the intent + __hu_lines=($__hu_out) + ((__hu_glob)) && set +f + ((${#__hu_lines[@]} == ${#__hu_todo[@]})) || return 0 + for ((__hu_i = 0; __hu_i < ${#__hu_todo[@]}; __hu_i++)); do + [[ -n "${__hu_lines[__hu_i]}" ]] || return 0 + hook::expand_8dot3_to __hu_v "${__hu_lines[__hu_i]}" + _HOOK_PHYS_KEYS+=("${__hu_todo[__hu_i]}") + _HOOK_PHYS_VALS+=("$__hu_v") + _HOOK_PHYS_RCS+=(0) + done + return 0 +} + +# The temp-root candidates hook::under_temp_root compares against: the +# environment's own answer (TMPDIR/TMP/TEMP) plus the POSIX defaults, never a +# hardcoded platform assumption. Existing directories only, each spelling +# once, in _HOOK_TEMP_CANDS. +_HOOK_TEMP_CANDS=() +hook::_temp_root_candidates() { + local __hu_cand __hu_seen="" + _HOOK_TEMP_CANDS=() + for __hu_cand in "${TMPDIR:-}" "${TMP:-}" "${TEMP:-}" /tmp /var/tmp; do + [[ -n "$__hu_cand" && -d "$__hu_cand" ]] || continue + case "$__hu_seen" in + *"|$__hu_cand|"*) continue ;; + *) ;; # first sighting of this candidate + esac + __hu_seen="$__hu_seen|$__hu_cand|" + _HOOK_TEMP_CANDS+=("$__hu_cand") + done +} + # True when sits inside one of this host's temp trees. # Both arguments and candidates go through the same canonicalize+normalize # pipeline as the membership comparison, because the same directory has several @@ -483,21 +632,40 @@ hook::physical_path() { # of the identical directory, and `realpath` resolves the Windows form to a # drive path while leaving `/tmp` as `/tmp`. Neither form alone matches a # `file_path` that could arrive in either, so every candidate is compared and a -# match on any one is a match. Duplicates are resolved once. +# match on any one is a match. Duplicates are resolved once, and each +# candidate's physical form is remembered for the process (see the cache +# above), so a second call costs no resolver process. # -# Candidates are the environment's own answer (TMPDIR/TMP/TEMP) plus the POSIX -# defaults, never a hardcoded platform assumption. +# Raw-spelling shortcut: when the caller vouches that is a +# PHYSICAL path (_HOOK_UTR_TARGET_PHYSICAL=1, set only by hook::read_file_path +# after the resolver succeeded), a candidate whose raw normalized spelling is +# already a prefix of the target is a match without resolving it. A physical +# path contains no symlink component, so a candidate that spells one of its +# prefixes names a chain of real directories whose physical form is that same +# prefix; the resolver would answer the same. A candidate that does not match +# raw is still resolved, so a symlinked temp root (macOS /tmp) is found the +# way it always was. Without the vouch the shortcut is off and every candidate +# is resolved, exactly as before. Note that hook::read_file_path primes every +# candidate into the cache with one batched realpath before calling here, so +# on that path the shortcut skips a lookup, not a process; the batch is what +# saves the processes. A direct caller without the batch saves the resolver. # hook::under_temp_root "$norm_path" && ... +_HOOK_UTR_TARGET_PHYSICAL=0 hook::under_temp_root() { - local target="$1" cand norm seen="" - for cand in "${TMPDIR:-}" "${TMP:-}" "${TEMP:-}" /tmp /var/tmp; do - [[ -n "$cand" && -d "$cand" ]] || continue - case "$seen" in - *"|$cand|"*) continue ;; - *) ;; # first sighting of this candidate — resolve it below - esac - seen="$seen|$cand|" - norm=$(hook::normalize_path "$(hook::physical_path "$cand")") + local target="$1" cand norm phys + hook::_temp_root_candidates + for cand in ${_HOOK_TEMP_CANDS[@]+"${_HOOK_TEMP_CANDS[@]}"}; do + if ((_HOOK_UTR_TARGET_PHYSICAL)); then + hook::normalize_path_to norm "$cand" + if [[ "$norm" != / ]]; then + norm="${norm%/}" + if [[ -n "$norm" && ("$target" == "$norm" || "$target" == "$norm"/*) ]]; then + return 0 + fi + fi + fi + hook::_physical_cached_to phys "$cand" || : + hook::normalize_path_to norm "$phys" # The filesystem root as a temp candidate contains every absolute path; # trimming its only slash would empty the candidate and discard it. [[ "$norm" == / ]] && return 0 @@ -523,21 +691,421 @@ hook::in_git_working_tree() { ) >/dev/null 2>&1 } +# --- Builtin JSON helpers ---------------------------------------------------- +# hook::read_file_path and hook::emit_telemetry used to spend a jq process each +# on every hook run. On Windows Git Bash one spawn costs tens of milliseconds, +# so the helpers here handle the well-formed shapes those two functions see in +# practice with shell builtins and hand anything they cannot PROVE to the +# unchanged jq path. The contract is "the bytes jq would produce, or fall +# back", never "close enough". +# +# What the builtin paths rely on: the text is valid JSON. Every hook payload +# reaching hook::read_file_path has passed hook::buffer_stdin's jq validation, +# and every telemetry data object is built with jq. The helpers still verify +# the structure they walk (terminated strings, well-formed tokens, properly +# nested brackets, one root, no raw control bytes inside strings), but they +# are not a full JSON parser and do not claim to reject every malformed text. +# +# Why no scanning: on this repo's Windows hosts bash regex matching and the +# `%%`/`##` pattern operators cost about a microsecond per character, so a +# 60 KB Write payload would cost more than the jq process they replace. The +# only string operations used on the whole payload are literal-substring +# replacement, `[[ == *x* ]]` containment, IFS word splitting and offset +# slicing, all of which run at C speed. + +# hook::_json_split +# Split at every unescaped double quote into the global array +# _HOOK_JSON_PARTS: even indices hold the structural text between strings, odd +# indices hold string bodies. Before splitting, every `\\` and `\"` pair is +# replaced by `@@` (same length), so a quote that survives is a real string +# delimiter and a body's ORIGINAL bytes are ${text:offset:length}. Returns 1 +# when the text is too large, its quotes do not pair up, or it holds more +# strings than the callers' loops are meant to walk. +_HOOK_JSON_PARTS=() +hook::_json_split() { + local __hu_s="$1" __hu_t __hu_q __hu_n + ((${#__hu_s} <= 65536)) || return 1 + __hu_t=${__hu_s//"\\\\"/@@} + __hu_t=${__hu_t//"\\\""/@@} + __hu_q=${__hu_t//\"/} + __hu_n=$((${#__hu_t} - ${#__hu_q})) + ((__hu_n % 2 == 0)) || return 1 + ((__hu_n <= 800)) || return 1 + local __hu_glob=0 + [[ $- == *f* ]] || __hu_glob=1 + set -f + local IFS='"' + # shellcheck disable=SC2206 # splitting on IFS='"' is the whole point + _HOOK_JSON_PARTS=($__hu_t) + ((__hu_glob)) && set +f + ((${#_HOOK_JSON_PARTS[@]} % 2 == 1)) || return 1 + return 0 +} + +# hook::_json_skeleton +# Split (hook::_json_split), record each string body's byte offset in +# _HOOK_JSON_OFF, and build _HOOK_JSON_SK: the structural text with every +# string replaced by a numbered placeholder `"#"` and all JSON +# whitespace removed. The skeleton is small (no string bodies), so regex and +# per-token work on it is cheap. Returns 1 unless the text is exactly one JSON +# value by the JSON grammar (well-formed tokens, whitespace only between them, +# objects as key:value lists, arrays as value lists, brackets nested and +# closed once) whose strings carry no raw control byte, which jq rejects. +_HOOK_JSON_SK="" +_HOOK_JSON_OFF=() +hook::_json_skeleton() { + local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top + hook::_json_split "$__hu_s" || return 1 + __hu_n=${#_HOOK_JSON_PARTS[@]} + _HOOK_JSON_OFF=() + for ((__hu_i = 0; __hu_i < __hu_n; __hu_i++)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + if ((__hu_i % 2 == 0)); then + __hu_sk+=$__hu_part + else + _HOOK_JSON_OFF[__hu_i]=$__hu_off + [[ "$__hu_part" == *[[:cntrl:]]* ]] && return 1 + __hu_sk+="\"#$__hu_i\"" + fi + __hu_off=$((__hu_off + ${#__hu_part} + 1)) + done + # Tokenize the skeleton and run it through the JSON grammar. Each token is + # consumed by one anchored regex on the (small) remainder; whitespace is + # allowed only between tokens, so `tr ue` is two bad tokens, not `true`. + local __hu_re=$'^[ \t\n\r]*(\\{|\\}|\\[|\\]|,|:|"#[0-9]+"|-?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][-+]?[0-9]+)?|true|false|null)' + local __hu_ws=$'^[ \t\n\r]*$' + __hu_rest=$__hu_sk + while [[ -n "$__hu_rest" ]] && ! [[ "$__hu_rest" =~ $__hu_ws ]]; do + [[ "$__hu_rest" =~ $__hu_re ]] || return 1 + __hu_tok=${BASH_REMATCH[1]} + __hu_rest=${__hu_rest:${#BASH_REMATCH[0]}} + __hu_top=${__hu_stack:${#__hu_stack}-1:1} + case "$__hu_expect" in + value | value_or_end) + case "$__hu_tok" in + '{') + __hu_stack+='{' + __hu_expect=key_or_end + continue + ;; + '[') + __hu_stack+='[' + __hu_expect=value_or_end + continue + ;; + ']') + [[ "$__hu_expect" == value_or_end ]] || return 1 + ;; + '}' | ',' | ':') return 1 ;; + *) + # A scalar. Inside a container the next token is , or the close; + # at the top level nothing may follow. + if [[ -n "$__hu_stack" ]]; then __hu_expect=comma_or_end; else __hu_expect=end; fi + continue + ;; + esac + ;; + key_or_end | key) + case "$__hu_tok" in + \"#*) + __hu_expect='colon' + continue + ;; + '}') [[ "$__hu_expect" == key_or_end ]] || return 1 ;; + *) return 1 ;; + esac + ;; + colon) + [[ "$__hu_tok" == ':' ]] || return 1 + __hu_expect=value + continue + ;; + comma_or_end) + case "$__hu_tok" in + ',') + if [[ "$__hu_top" == '{' ]]; then __hu_expect=key; else __hu_expect=value; fi + continue + ;; + '}') [[ "$__hu_top" == '{' ]] || return 1 ;; + ']') [[ "$__hu_top" == '[' ]] || return 1 ;; + *) return 1 ;; + esac + ;; + *) return 1 ;; # `end`: a token after the root value closed + esac + # Reaching here means a container just closed. + [[ -n "$__hu_stack" ]] || return 1 + __hu_stack=${__hu_stack:0:${#__hu_stack}-1} + if [[ -n "$__hu_stack" ]]; then __hu_expect=comma_or_end; else __hu_expect=end; fi + done + [[ "$__hu_expect" == end ]] || return 1 + _HOOK_JSON_SK=${__hu_sk//[$' \t\n\r']/} + return 0 +} + +# hook::json_unescape_to +# Decode a JSON string body (the bytes between the quotes) into . Handles +# \" \\ \/ \b \f \n \r \t and \uXXXX for U+0001 to U+007F. Returns 1 on any +# other escape (a \u outside that range, U+0000 which bash cannot hold, or an +# invalid escape), leaving the caller to fall back to jq. +hook::json_unescape_to() { + local __hu_raw="$2" __hu_out="" __hu_pre __hu_c __hu_hex __hu_oct __hu_ch + while [[ "$__hu_raw" == *\\* ]]; do + __hu_pre=${__hu_raw%%\\*} + __hu_out+=$__hu_pre + __hu_raw=${__hu_raw:${#__hu_pre}+1} + __hu_c=${__hu_raw:0:1} + __hu_raw=${__hu_raw:1} + case "$__hu_c" in + '"') __hu_out+='"' ;; + "\\") __hu_out+="\\" ;; + '/') __hu_out+='/' ;; + b) __hu_out+=$'\b' ;; # portability-ok: bash ANSI-C backspace byte, not a GNU grep word boundary + f) __hu_out+=$'\f' ;; + n) __hu_out+=$'\n' ;; + r) __hu_out+=$'\r' ;; + t) __hu_out+=$'\t' ;; + u) + __hu_hex=${__hu_raw:0:4} + [[ "$__hu_hex" =~ ^00[0-7][0-9a-fA-F]$ ]] || return 1 + [[ "$__hu_hex" == 0000 ]] && return 1 + __hu_raw=${__hu_raw:4} + printf -v __hu_oct '%03o' "$((16#$__hu_hex))" + printf -v __hu_ch '%b' "\\0$__hu_oct" + __hu_out+=$__hu_ch + ;; + *) return 1 ;; + esac + done + __hu_out+=$__hu_raw + printf -v "$1" '%s' "$__hu_out" +} + +# hook::_fast_file_path_to +# The builtin answer to `jq -r '(.tool_input.file_path // empty) | +# gsub("\r";"")'` followed by the `$(...)` capture. Returns +# 0 proven: holds exactly what jq would have printed +# 1 proven absent: jq would have printed nothing (the guard skips) +# 2 not proven: run jq +# Proof, on top of hook::_json_skeleton's structural checks: the root is an +# object; exactly one string in the whole payload decodes to `tool_input` and +# exactly one to `file_path` (so neither a duplicate key nor a same-named key +# in another object can change jq's answer); `tool_input` is a key of the root +# (depth 1, key position) whose value is a flat object (no nested object or +# array, else jq); and inside it `file_path` is a key with a plain string +# value. A payload where `file_path` names a value or sits in another object +# has no `.tool_input.file_path`, which jq reports as nothing. Key strings are +# compared AFTER decoding their escapes, so a key spelled with \u escapes is +# still recognized; a body longer than any escaped spelling of either name is +# skipped without decoding. +hook::_fast_file_path_to() { + local __hu_s="$2" __hu_i __hu_n __hu_part __hu_body __hu_ti=-1 __hu_fp=-1 __hu_m __hu_raw __hu_pre __hu_re __hu_file + hook::_json_skeleton "$__hu_s" || return 2 + [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 + __hu_n=${#_HOOK_JSON_PARTS[@]} + for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + ((${#__hu_part} <= 60)) || continue + __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} + if [[ "$__hu_body" == *\\* ]]; then + hook::json_unescape_to __hu_body "$__hu_body" || continue + fi + case "$__hu_body" in + tool_input) + ((__hu_ti < 0)) || return 2 + __hu_ti=$__hu_i + ;; + file_path) + ((__hu_fp < 0)) || return 2 + __hu_fp=$__hu_i + ;; + *) ;; + esac + done + ((__hu_fp >= 0)) || return 1 + ((__hu_ti >= 0)) || return 1 + __hu_re="(^|[{,])\"#$__hu_ti\":\\{([^][{}]*)\\}" + if [[ "$_HOOK_JSON_SK" =~ $__hu_re ]]; then + __hu_body=${BASH_REMATCH[2]} + __hu_pre=${_HOOK_JSON_SK%%\"#"$__hu_ti"\"*} + # Depth of the key: opening brackets minus closing ones before it must be + # exactly one, i.e. a direct member of the root object. + local __hu_o1=${__hu_pre//\{/} __hu_o2=${__hu_pre//\[/} __hu_c1=${__hu_pre//\}/} __hu_c2=${__hu_pre//\]/} + local __hu_depth=$(((${#__hu_pre} - ${#__hu_o1}) + (${#__hu_pre} - ${#__hu_o2}) - (${#__hu_pre} - ${#__hu_c1}) - (${#__hu_pre} - ${#__hu_c2}))) + ((__hu_depth == 1)) || return 2 + else + return 2 + fi + __hu_re="(^|,)\"#$__hu_fp\":\"#([0-9]+)\"(,|$)" + if [[ "$__hu_body" =~ $__hu_re ]]; then + __hu_m=${BASH_REMATCH[2]} + elif [[ "$__hu_body" == *\"#$__hu_fp\"* ]]; then + return 2 + else + return 1 + fi + __hu_raw=${__hu_s:${_HOOK_JSON_OFF[__hu_m]}:${#_HOOK_JSON_PARTS[__hu_m]}} + if [[ "$__hu_raw" == *\\* ]]; then + hook::json_unescape_to __hu_file "$__hu_raw" || return 2 + else + __hu_file=$__hu_raw + fi + __hu_file=${__hu_file//$'\r'/} + while [[ "$__hu_file" == *$'\n' ]]; do + __hu_file=${__hu_file%$'\n'} + done + printf -v "$1" '%s' "$__hu_file" + return 0 +} + +# hook::dirname_to : dirname with builtins for the resolver's +# answer. Strips the last segment; a bare name lives in `.`, a root-level file +# in `/`. The path comes from realpath, so the trailing-slash and doubled-slash +# spellings dirname also collapses never occur here. +hook::dirname_to() { + local __hu_d="${2%/*}" + [[ "$__hu_d" == "$2" ]] && __hu_d=. + [[ -n "$__hu_d" ]] || __hu_d=/ + printf -v "$1" '%s' "$__hu_d" +} + +# Print the arguments joined by NUL bytes, no trailing NUL: the byte stream +# hook::read_file_path read from stdin, rebuilt for the jq fallback. +hook::_print_nul_joined() { + local __hu_first=1 __hu_c + for __hu_c in "$@"; do + ((__hu_first)) || printf '\0' + __hu_first=0 + printf '%s' "$__hu_c" + done +} + +# hook::json_compact_to +# The bytes `jq -c .` prints for , when that can be proven with +# builtins: JSON whitespace between tokens removed, everything else verbatim. +# jq's own output, compact or pretty, satisfies the proof, which is what every +# telemetry data builder in this marketplace produces (jq -c, jq -n, or a +# compact literal). Returns 1 when the text is not a single object, or a +# string carries something jq would re-encode (a \u or \/ escape, an escape +# outside the JSON set, a raw control byte), or the structure fails +# hook::_json_skeleton; the caller then runs jq. +hook::json_compact_to() { + local __hu_s="$2" __hu_out="" __hu_i __hu_part __hu_n + hook::_json_skeleton "$__hu_s" || return 1 + [[ "$_HOOK_JSON_SK" == \{*\} ]] || return 1 + # Numbers: only short integer literals are passed through. A fraction, an + # exponent or an integer beyond 15 digits is rendered differently by + # different jq releases (1.6 canonicalizes, 1.7+ preserves the literal), so + # the bytes cannot be proven; jq decides. In the skeleton every string is a + # `"#N"` placeholder and letters occur only in true/false/null, so a digit + # followed by `.`, `e` or `E` can only be part of a number. + local __hu_num='[0-9][.eE]|[0-9]{16}' + [[ "$_HOOK_JSON_SK" =~ $__hu_num ]] && return 1 + __hu_n=${#_HOOK_JSON_PARTS[@]} + for ((__hu_i = 0; __hu_i < __hu_n; __hu_i++)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + if ((__hu_i % 2 == 0)); then + __hu_out+=${__hu_part//[$' \t\n\r']/} + else + if [[ "$__hu_part" == *\\* ]]; then + [[ "$__hu_part" == *'\u'* || "$__hu_part" == *'\/'* ]] && return 1 + [[ "$__hu_part" == *\\[!bfnrt]* ]] && return 1 + fi + __hu_out+="\"${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}}\"" + fi + done + printf -v "$1" '%s' "$__hu_out" +} + +# hook::json_escape_jq_to / hook::json_escape_jq +# Escape exactly as jq serializes a string value (without the +# quotes): backslash and double quote, the five short forms \b \f \n \r \t, +# every other byte from 0x01 to 0x1f and 0x7f as lowercase \u00xx, and +# everything else, including non-ASCII, verbatim. This is the escaper the +# telemetry envelope needs for byte parity with the jq envelope it replaced; +# hook::json_escape above is a different tool (it DROPS the residual control +# bytes, which is right for a notice and wrong for parity) and is unchanged. +hook::json_escape_jq_to() { + local __hu_s="$2" __hu_i __hu_c __hu_oct __hu_hex + __hu_s="${__hu_s//\\/\\\\}" + __hu_s="${__hu_s//\"/\\\"}" + if [[ "$__hu_s" == *[[:cntrl:]]* ]]; then + __hu_s="${__hu_s//$'\n'/\\n}" + __hu_s="${__hu_s//$'\r'/\\r}" + __hu_s="${__hu_s//$'\t'/\\t}" + __hu_s="${__hu_s//$'\b'/\\b}" # portability-ok: bash ANSI-C backspace byte and the JSON \b escape, not a GNU grep word boundary + __hu_s="${__hu_s//$'\f'/\\f}" + for __hu_i in 1 2 3 4 5 6 7 11 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 127; do + printf -v __hu_oct '%03o' "$__hu_i" + printf -v __hu_c '%b' "\\0$__hu_oct" + [[ "$__hu_s" == *"$__hu_c"* ]] || continue + printf -v __hu_hex '%04x' "$__hu_i" + __hu_s="${__hu_s//"$__hu_c"/\\u$__hu_hex}" + done + fi + printf -v "$1" '%s' "$__hu_s" +} + +hook::json_escape_jq() { + local __hu_e + hook::json_escape_jq_to __hu_e "$1" + printf '%s' "$__hu_e" +} + # Parse file_path from PostToolUse JSON on stdin; validate existence and (when # CLAUDE_PROJECT_DIR is set) project membership. Both sides of the membership # comparison are canonicalized (symlinks resolved) first, so neither an # escaping symlink nor a project root reached via a symlinked path (e.g. # macOS /tmp) skews the verdict. Outputs the path on success. Returns 1 to skip. # FILE=$(hook::read_file_path) || exit 0 +# +# jq-free on the common shape. The payload is read into the shell and +# hook::_fast_file_path_to takes `.tool_input.file_path` with builtins when it +# can PROVE the answer jq would give (see that helper for what "prove" means); +# every other payload goes to the unchanged jq filter. The path comparison then +# resolves the file, the project root and the temp roots with one realpath +# process instead of one each (hook::_physical_prime), and remembers the +# directories for the process. Same verdict, same emitted path, fewer +# processes: on Windows Git Bash each spawn costs tens of milliseconds and this +# guard runs on every Write and Edit. hook::read_file_path() { - local file - file=$(jq -r '(.tool_input.file_path // empty) | gsub("\r";"")' 2>/dev/null) + local -a chunks=() + local chunk file="" mode=2 + # Builtin read to NUL or EOF. A NUL splits the payload into several chunks; + # the fast path only takes a single-chunk (NUL-free) payload, and the jq + # fallback is fed the chunks NUL-joined, so it sees the bytes jq used to + # read straight from stdin. + while :; do + chunk="" + if IFS= read -r -d '' chunk; then + chunks+=("$chunk") + continue + fi + chunks+=("$chunk") + break + done + if ((${#chunks[@]} == 1)); then + mode=0 + hook::_fast_file_path_to file "${chunks[0]}" || mode=$? + fi + case "$mode" in + 0) ;; # proven: `file` holds jq's answer + 1) return 1 ;; # proven absent + *) + file=$(hook::_print_nul_joined "${chunks[@]}" | jq -r '(.tool_input.file_path // empty) | gsub("\r";"")' 2>/dev/null) + ;; + esac [[ -n "$file" ]] || return 1 [[ -f "$file" ]] || return 1 if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then - local norm_file norm_project - norm_file=$(hook::normalize_path "$(hook::physical_path "$file")") - norm_project=$(hook::normalize_path "$(hook::physical_path "${CLAUDE_PROJECT_DIR}")") + local norm_file norm_project phys_file phys_project file_resolved=0 project_resolved=0 + hook::_temp_root_candidates + hook::_physical_prime "$file" "${CLAUDE_PROJECT_DIR}" ${_HOOK_TEMP_CANDS[@]+"${_HOOK_TEMP_CANDS[@]}"} + hook::_physical_cached_to phys_file "$file" && file_resolved=1 + hook::_phys_cache_forget "$file" + hook::_physical_cached_to phys_project "${CLAUDE_PROJECT_DIR}" && project_resolved=1 + hook::normalize_path_to norm_file "$phys_file" + hook::normalize_path_to norm_project "$phys_project" norm_project="${norm_project%/}" # Anchor on a path-segment boundary: accept the project root itself or a # child under it, but not a sibling whose name merely shares the prefix @@ -558,19 +1126,28 @@ hook::read_file_path() { # built by `mktemp -d`, which is how this repo's own hook suites run), so # the branch must not fire. Only a temp-tree file reached from a project # root OUTSIDE the temp tree is scratch. - if hook::under_temp_root "$norm_file" && ! hook::under_temp_root "$norm_project"; then - return 1 + local file_in_temp=0 + _HOOK_UTR_TARGET_PHYSICAL=$file_resolved + hook::under_temp_root "$norm_file" && file_in_temp=1 + _HOOK_UTR_TARGET_PHYSICAL=0 + if ((file_in_temp)); then + local project_in_temp=0 + _HOOK_UTR_TARGET_PHYSICAL=$project_resolved + hook::under_temp_root "$norm_project" && project_in_temp=1 + _HOOK_UTR_TARGET_PHYSICAL=0 + ((project_in_temp)) || return 1 fi elif command -v git >/dev/null 2>&1; then # When CLAUDE_PROJECT_DIR is unset, scope to git-working-tree membership so # scratch files outside any repository are not mutated by formatter hooks # (#1091 / #972). - local file_physical - file_physical=$(hook::physical_path "$file") + local file_physical file_dir + hook::physical_path_to file_physical "$file" || : if [[ -L "$file" && "$file_physical" == "$file" ]]; then return 1 fi - if ! hook::in_git_working_tree "$(dirname "$file_physical")"; then + hook::dirname_to file_dir "$file_physical" + if ! hook::in_git_working_tree "$file_dir"; then return 1 fi else @@ -1228,7 +1805,19 @@ hook::telemetry_enabled() { # Fire-and-forget: sink is dispatched in the background; the hook never waits # on it and its failure never affects the hook's own exit code or stdout. # Opt-in guard: HOOK_TELEMETRY_SINK unset or empty → return 0 immediately. -# Fail-open: jq absent → return 0 immediately. +# +# The envelope is assembled with shell builtins (hook::json_escape_jq_to for +# the string fields, hook::json_compact_to for the caller's data object) as +# ONE compact line, `jq -c` style: the same document, field order and +# escapes the jq filter below produces, with the data object compacted +# exactly as jq compacts its own output. That path needs no jq, no temp file +# and no subprocess. When the data object cannot be proven jq-idempotent (see +# hook::json_compact_to), the jq path runs, now with -c so both paths write +# the same bytes, and there jq's absence is still fail-open: return 0, no +# envelope. Before this the envelope was jq's default pretty-printed form +# (several lines, and CRLF line endings with a Windows jq); the sink contract +# was always "one JSON document on stdin", and a compact line is also valid +# JSONL for a sink that appends raw envelopes. # # Usage: # hook::emit_telemetry [repo_root] @@ -1251,7 +1840,6 @@ hook::telemetry_enabled() { # NEVER writes to fd1 (the hook's stdout / additionalContext channel). hook::emit_telemetry() { [[ -n "${HOOK_TELEMETRY_SINK:-}" ]] || return 0 - command -v jq >/dev/null 2>&1 || return 0 local hook_id="$1" local hook_event="$2" @@ -1272,35 +1860,59 @@ hook::emit_telemetry() { local e_s="${now%[.,]*}" e_f="${now#*[.,]}" local duration_ms=$(((e_s * 1000000 + 10#$e_f - s_s * 1000000 - 10#$s_f) / 1000)) - # True UTC timestamp (TZ= prefix overrides LC_ALL / local TZ; the Z is not a lie). - local timestamp - timestamp=$(TZ=UTC printf '%(%Y-%m-%dT%H:%M:%SZ)T' -1) - - # Build the envelope. The `data` object is written to a temp file, not passed - # as --argjson, so payloads larger than the Windows 32767-character command-line - # cap are not dropped before jq runs (#1595). /dev/stdin is not used: jq's - # --slurpfile there is not portable on Windows. Redirect jq stderr to - # /dev/null; output goes to a local variable — never to fd1. - local envelope data_file - data_file=$(mktemp) || return 0 - printf '%s' "$data_json" >"$data_file" || { - rm -f "$data_file" - return 0 - } - envelope=$(jq -n \ - --arg schema_version "1.0" \ - --arg timestamp "$timestamp" \ - --arg hook "$hook_id" \ - --arg hook_event "$hook_event" \ - --arg status "$status" \ - --argjson duration_ms "$duration_ms" \ - --slurpfile data "$data_file" \ - '{schema_version:$schema_version,timestamp:$timestamp,hook:$hook,hook_event:$hook_event,status:$status,duration_ms:$duration_ms,data:($data[0] // {})}' \ - 2>/dev/null) || { + # True UTC timestamp (TZ= prefix overrides LC_ALL / local TZ; the Z is not a + # lie). The %()T printf format is Bash 4.2+, and printf -v keeps it in the + # shell (a `$(...)` capture is a fork); an older bash, or a printf that + # binds nothing, falls back to date -u. + local timestamp="" + TZ=UTC printf -v timestamp '%(%Y-%m-%dT%H:%M:%SZ)T' -1 2>/dev/null || timestamp="" + [[ -n "$timestamp" ]] || timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null) || timestamp="" + + # Trailing whitespace after the object (a newline a caller left on the + # value) does not change what jq parses, so it does not block the builtin + # path either. + local envelope="" data="" data_trim="$data_json" + while [[ "$data_trim" == *[$' \t\r\n'] ]]; do + data_trim="${data_trim%?}" + done + if [[ "$data_trim" == \{*\} ]] && hook::json_compact_to data "$data_trim"; then + # Builtin envelope: the same document the jq filter below produces, field + # for field, with jq's string escaping and jq's compact data. + local e_timestamp e_hook e_event e_status + hook::json_escape_jq_to e_timestamp "$timestamp" + hook::json_escape_jq_to e_hook "$hook_id" + hook::json_escape_jq_to e_event "$hook_event" + hook::json_escape_jq_to e_status "$status" + envelope='{"schema_version":"1.0","timestamp":"'"$e_timestamp"'","hook":"'"$e_hook"'","hook_event":"'"$e_event"'","status":"'"$e_status"'","duration_ms":'"$duration_ms"',"data":'"$data"'}' + else + # jq path, for a data object the builtin compactor cannot prove. Fail-open: + # jq absent → return 0. The `data` object is written to a temp file, not + # passed as --argjson, so payloads larger than the Windows 32767-character + # command-line cap are not dropped before jq runs (#1595). /dev/stdin is + # not used: jq's --slurpfile there is not portable on Windows. Redirect jq + # stderr to /dev/null; output goes to a local variable — never to fd1. + command -v jq >/dev/null 2>&1 || return 0 + local data_file + data_file=$(mktemp) || return 0 + printf '%s' "$data_json" >"$data_file" || { + rm -f "$data_file" + return 0 + } + envelope=$(jq -nc \ + --arg schema_version "1.0" \ + --arg timestamp "$timestamp" \ + --arg hook "$hook_id" \ + --arg hook_event "$hook_event" \ + --arg status "$status" \ + --argjson duration_ms "$duration_ms" \ + --slurpfile data "$data_file" \ + '{schema_version:$schema_version,timestamp:$timestamp,hook:$hook,hook_event:$hook_event,status:$status,duration_ms:$duration_ms,data:($data[0] // {})}' \ + 2>/dev/null) || { + rm -f "$data_file" + return 0 + } rm -f "$data_file" - return 0 - } - rm -f "$data_file" + fi # Resolve the sink path. A relative HOOK_TELEMETRY_SINK is joined onto the # consuming repo root (portable, tracked wiring); absolute is used as-is. A diff --git a/plugins/source-control/.claude-plugin/plugin.json b/plugins/source-control/.claude-plugin/plugin.json index 4f59e7ddf4..ff8667e048 100644 --- a/plugins/source-control/.claude-plugin/plugin.json +++ b/plugins/source-control/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "source-control", - "version": "0.55.41", + "version": "0.55.42", "description": "Git and GitHub delivery workflow: /commit (Conventional Commits + Co-authored-by trailer via safe heredoc mechanics), /pull-request (prep, create, CI monitoring, review-comment triage, merge, CI-log fetch), /babysit-prs (self-pacing fleet loop \u2014 safe by default; opt-in worker/autopilot tiers add gate-checked merge and thread resolution behind a deterministic Python engine), /babysit-loop (the loop-lane merge lane: a standing or drain loop that invokes babysit-prs per cycle, configured through repo-scoped babysit_loop_* keys on the layered source-control.md seam, with merge authority human-only until the target repo's tracked config adopts the lane, a gate-proven C2-mechanical baseline once adopted, and standing merge-rung raises binding from the team-tracked layer only \u2014 with one named exception, where an invocation line explicitly typing both the autopilot tier keyword and the dedicated raise argument --merge c3-this-run widens that single invocation's merge authority up to C3 behind a fresh independent frontier-tier resolver, while C4-structural and C5-untrusted-provenance stay unconditionally human-merge), /worktree (create, status, cleanup, audit for parallel-session isolation), /setup (check the effective commit-subject / PR-title convention merged across its config layers and the babysit-prs config, or apply \u2014 interview the repo and write the convention config to a chosen layer), and /resolve-conflicts (intent-first merge/rebase conflict resolution with a semantic-conflict sweep \u2014 never --abort). The commit-subject / PR-title convention is configurable via a source-control.md config written by a re-runnable setup skill, layered across a ~/.claude user-global file, the tracked team file, and a gitignored .claude/source-control.local.md personal overlay merged per key; Conventional Commits is the default when no convention is declared.", "author": { "name": "Melodic Software", diff --git a/plugins/source-control/CHANGELOG.md b/plugins/source-control/CHANGELOG.md index 88e48b07cf..d2149b99d8 100644 --- a/plugins/source-control/CHANGELOG.md +++ b/plugins/source-control/CHANGELOG.md @@ -3,6 +3,22 @@ All notable changes to the `source-control` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.55.42] + +### Changed + +- **Vendored `hook-utils.sh` builds the telemetry envelope and reads `file_path` + with shell builtins.** `hook::emit_telemetry` no longer spawns two jq + processes, a mktemp and an rm per run: the envelope is assembled in the shell + as one compact line (the same document jq produced, now `jq -c` shaped), with + jq kept only as the fallback for a data object the builtin compactor cannot + prove. `hook::read_file_path` takes `.tool_input.file_path` without jq on the + well-formed payload shape and resolves the file, project root and temp roots + with one batched `realpath` instead of one process each. Same verdicts, same + emitted path, same sink record; phase 4b of the hook-performance program + (#3623). The copy is bumped because `scripts/sync-hook-utils.sh` keeps every + carrying plugin byte-identical. + ## [0.55.41] ### Changed diff --git a/plugins/source-control/hooks/hook-utils.sh b/plugins/source-control/hooks/hook-utils.sh index 9818a6a73d..18e4dd5745 100644 --- a/plugins/source-control/hooks/hook-utils.sh +++ b/plugins/source-control/hooks/hook-utils.sh @@ -390,19 +390,34 @@ hook::require_jq_blocking() { # collapse with `/c/repo` and the membership guard would admit a sibling # outside CLAUDE_PROJECT_DIR. The result is used ONLY for comparison; the # emitted path is always the caller's original. -hook::normalize_path() { - local p="${1//\\//}" +# +# Two spellings of every path helper below: `hook::` prints the answer +# (the public, subshell-friendly form every caller already uses) and +# `hook::_to ` stores it in the caller's variable instead. +# The `_to` form is what the hot path uses: a `$(...)` capture is a fork, and +# on Windows Git Bash a fork costs milliseconds, so the file_path guard that +# ran a dozen of them per hook now runs none it does not need. The `_to` +# helpers keep their locals under a `__hu_` prefix so the caller's variable +# name cannot collide with them. +hook::normalize_path_to() { + local __hu_p="${2//\\//}" case "${OSTYPE:-}" in msys* | cygwin* | win32) - if [[ "$p" =~ ^/([a-zA-Z])/ || "$p" =~ ^([a-zA-Z]):/ ]]; then - local rest="${p:2}" - printf '%s' "${BASH_REMATCH[1]^}:${rest,,}" - return + if [[ "$__hu_p" =~ ^/([a-zA-Z])/ || "$__hu_p" =~ ^([a-zA-Z]):/ ]]; then + local __hu_rest="${__hu_p:2}" + printf -v "$1" '%s' "${BASH_REMATCH[1]^}:${__hu_rest,,}" + return 0 fi ;; *) ;; # POSIX hosts: case-sensitive FS, no drive fold — pass through below esac - printf '%s' "$p" + printf -v "$1" '%s' "$__hu_p" +} + +hook::normalize_path() { + local __hu_n + hook::normalize_path_to __hu_n "$1" + printf '%s' "$__hu_n" } # Expand Windows 8.3 short-name components (KYLESE~1 → KyleSexton) on @@ -424,25 +439,31 @@ hook::normalize_path() { # class: cygpath ships with Git Bash (the documented Windows bash), so its # absence or failure keeps the resolver's answer unchanged — degrading to the # pre-expansion comparison, same doctrine as the resolver fallback below. -hook::expand_8dot3() { - local p="$1" +hook::expand_8dot3_to() { + local __hu_p="$2" case "${OSTYPE:-}" in msys* | cygwin* | win32) ;; *) - printf '%s' "$p" - return + printf -v "$1" '%s' "$__hu_p" + return 0 ;; esac - if [[ "$p" == *~* ]] && command -v cygpath >/dev/null 2>&1; then - local plain long - if plain=$(cygpath -m -- "$p" 2>/dev/null) && - long=$(cygpath -l -m -- "$p" 2>/dev/null) && - [[ -n "$long" && "$long" != "$plain" ]]; then - printf '%s' "$long" - return + if [[ "$__hu_p" == *~* ]] && command -v cygpath >/dev/null 2>&1; then + local __hu_plain __hu_long + if __hu_plain=$(cygpath -m -- "$__hu_p" 2>/dev/null) && + __hu_long=$(cygpath -l -m -- "$__hu_p" 2>/dev/null) && + [[ -n "$__hu_long" && "$__hu_long" != "$__hu_plain" ]]; then + printf -v "$1" '%s' "$__hu_long" + return 0 fi fi - printf '%s' "$p" + printf -v "$1" '%s' "$__hu_p" +} + +hook::expand_8dot3() { + local __hu_e + hook::expand_8dot3_to __hu_e "$1" + printf '%s' "$__hu_e" } # Canonicalize to a physical path — symlinks resolved, Windows 8.3 short names @@ -462,20 +483,148 @@ hook::expand_8dot3() { # expansion applies only on the resolver's success path: consumers that fail # closed on an unresolved signature must not see a form-converted path instead. # shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED -hook::physical_path() { - local resolved +hook::physical_path_to() { + local __hu_r HOOK_PHYSICAL_PATH_UNRESOLVED=0 - if resolved=$(realpath -- "$1" 2>/dev/null) || resolved=$(readlink -f -- "$1" 2>/dev/null); then - if [[ -n "$resolved" ]]; then - hook::expand_8dot3 "$resolved" + if __hu_r=$(realpath -- "$2" 2>/dev/null) || __hu_r=$(readlink -f -- "$2" 2>/dev/null); then + if [[ -n "$__hu_r" ]]; then + hook::expand_8dot3_to "$1" "$__hu_r" return 0 fi fi HOOK_PHYSICAL_PATH_UNRESOLVED=1 - printf '%s' "$1" + printf -v "$1" '%s' "$2" + return 1 +} + +# shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED +hook::physical_path() { + local __hu_v __hu_rc=0 + hook::physical_path_to __hu_v "$1" || __hu_rc=$? + printf '%s' "$__hu_v" + return "$__hu_rc" +} + +# --- Per-process physical-path cache -------------------------------------- +# The membership guard resolves the same few directories on every call: the +# project root and this host's temp roots. Their physical form does not change +# within one hook process, so each spelling is resolved once and remembered in +# three parallel indexed arrays (keys, values, resolver status). Plain arrays, +# not an associative array, so the cache runs on Bash 3.2. Only directories are +# meant to live here for a process lifetime; hook::read_file_path forgets the +# edited file's entry as soon as it has read it, so a later call in the same +# process sees the file as it is then. +_HOOK_PHYS_KEYS=() +_HOOK_PHYS_VALS=() +_HOOK_PHYS_RCS=() +_HOOK_PHYS_I=-1 + +# hook::_phys_cache_index : sets _HOOK_PHYS_I to the slot holding , +# returns 1 when it is not cached. A forgotten slot has an empty key, which no +# real path can match. +hook::_phys_cache_index() { + local __hu_i + [[ -n "$1" ]] || return 1 + for ((__hu_i = 0; __hu_i < ${#_HOOK_PHYS_KEYS[@]}; __hu_i++)); do + if [[ "${_HOOK_PHYS_KEYS[__hu_i]}" == "$1" ]]; then + _HOOK_PHYS_I=$__hu_i + return 0 + fi + done return 1 } +hook::_phys_cache_forget() { + hook::_phys_cache_index "$1" && _HOOK_PHYS_KEYS[_HOOK_PHYS_I]="" + return 0 +} + +# hook::_physical_cached_to : hook::physical_path_to through the +# cache. Same value, same return status, same HOOK_PHYSICAL_PATH_UNRESOLVED as +# the uncached call; a miss resolves the one path and stores it. +# shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED +hook::_physical_cached_to() { + if hook::_phys_cache_index "$2"; then + printf -v "$1" '%s' "${_HOOK_PHYS_VALS[_HOOK_PHYS_I]}" + HOOK_PHYSICAL_PATH_UNRESOLVED=${_HOOK_PHYS_RCS[_HOOK_PHYS_I]} + return "${_HOOK_PHYS_RCS[_HOOK_PHYS_I]}" + fi + local __hu_v __hu_rc=0 + hook::physical_path_to __hu_v "$2" || __hu_rc=$? + if [[ -n "$2" ]]; then + _HOOK_PHYS_KEYS+=("$2") + _HOOK_PHYS_VALS+=("$__hu_v") + _HOOK_PHYS_RCS+=("$__hu_rc") + fi + printf -v "$1" '%s' "$__hu_v" + return "$__hu_rc" +} + +# hook::_physical_prime ...: resolve every uncached path with ONE +# realpath process and store the answers. realpath resolves each argument on +# its own and prints one line per argument in argument order, so the batch +# answer for a path is the same string the per-path call returns; the 8.3 +# expansion is applied per answer exactly as hook::physical_path_to does. Any +# doubt (a path carrying a newline, a non-zero exit, a line count that does not +# match) stores nothing and leaves the per-path resolver to answer lazily, so +# the batch can only ever save work, never change an answer. readlink -f hosts +# (no realpath) skip the batch for the same reason. +hook::_physical_prime() { + local -a __hu_todo=() + local __hu_p __hu_out __hu_i __hu_seen="" __hu_v + for __hu_p in "$@"; do + [[ -n "$__hu_p" ]] || continue + [[ "$__hu_p" == *$'\n'* ]] && return 0 + case "$__hu_seen" in + *"|$__hu_p|"*) continue ;; + *) ;; # first sighting + esac + __hu_seen="$__hu_seen|$__hu_p|" + hook::_phys_cache_index "$__hu_p" && continue + __hu_todo+=("$__hu_p") + done + ((${#__hu_todo[@]} > 1)) || return 0 + command -v realpath >/dev/null 2>&1 || return 0 + # portability-ok: realpath with several operands is GNU and BSD alike; a host whose realpath rejects it fails the exit-status check and falls back per path + __hu_out=$(realpath -- "${__hu_todo[@]}" 2>/dev/null) || return 0 + local -a __hu_lines=() + local __hu_glob=0 + [[ $- == *f* ]] || __hu_glob=1 + set -f + local IFS=$'\n' + # shellcheck disable=SC2206 # splitting realpath's one-line-per-operand output is the intent + __hu_lines=($__hu_out) + ((__hu_glob)) && set +f + ((${#__hu_lines[@]} == ${#__hu_todo[@]})) || return 0 + for ((__hu_i = 0; __hu_i < ${#__hu_todo[@]}; __hu_i++)); do + [[ -n "${__hu_lines[__hu_i]}" ]] || return 0 + hook::expand_8dot3_to __hu_v "${__hu_lines[__hu_i]}" + _HOOK_PHYS_KEYS+=("${__hu_todo[__hu_i]}") + _HOOK_PHYS_VALS+=("$__hu_v") + _HOOK_PHYS_RCS+=(0) + done + return 0 +} + +# The temp-root candidates hook::under_temp_root compares against: the +# environment's own answer (TMPDIR/TMP/TEMP) plus the POSIX defaults, never a +# hardcoded platform assumption. Existing directories only, each spelling +# once, in _HOOK_TEMP_CANDS. +_HOOK_TEMP_CANDS=() +hook::_temp_root_candidates() { + local __hu_cand __hu_seen="" + _HOOK_TEMP_CANDS=() + for __hu_cand in "${TMPDIR:-}" "${TMP:-}" "${TEMP:-}" /tmp /var/tmp; do + [[ -n "$__hu_cand" && -d "$__hu_cand" ]] || continue + case "$__hu_seen" in + *"|$__hu_cand|"*) continue ;; + *) ;; # first sighting of this candidate + esac + __hu_seen="$__hu_seen|$__hu_cand|" + _HOOK_TEMP_CANDS+=("$__hu_cand") + done +} + # True when sits inside one of this host's temp trees. # Both arguments and candidates go through the same canonicalize+normalize # pipeline as the membership comparison, because the same directory has several @@ -483,21 +632,40 @@ hook::physical_path() { # of the identical directory, and `realpath` resolves the Windows form to a # drive path while leaving `/tmp` as `/tmp`. Neither form alone matches a # `file_path` that could arrive in either, so every candidate is compared and a -# match on any one is a match. Duplicates are resolved once. +# match on any one is a match. Duplicates are resolved once, and each +# candidate's physical form is remembered for the process (see the cache +# above), so a second call costs no resolver process. # -# Candidates are the environment's own answer (TMPDIR/TMP/TEMP) plus the POSIX -# defaults, never a hardcoded platform assumption. +# Raw-spelling shortcut: when the caller vouches that is a +# PHYSICAL path (_HOOK_UTR_TARGET_PHYSICAL=1, set only by hook::read_file_path +# after the resolver succeeded), a candidate whose raw normalized spelling is +# already a prefix of the target is a match without resolving it. A physical +# path contains no symlink component, so a candidate that spells one of its +# prefixes names a chain of real directories whose physical form is that same +# prefix; the resolver would answer the same. A candidate that does not match +# raw is still resolved, so a symlinked temp root (macOS /tmp) is found the +# way it always was. Without the vouch the shortcut is off and every candidate +# is resolved, exactly as before. Note that hook::read_file_path primes every +# candidate into the cache with one batched realpath before calling here, so +# on that path the shortcut skips a lookup, not a process; the batch is what +# saves the processes. A direct caller without the batch saves the resolver. # hook::under_temp_root "$norm_path" && ... +_HOOK_UTR_TARGET_PHYSICAL=0 hook::under_temp_root() { - local target="$1" cand norm seen="" - for cand in "${TMPDIR:-}" "${TMP:-}" "${TEMP:-}" /tmp /var/tmp; do - [[ -n "$cand" && -d "$cand" ]] || continue - case "$seen" in - *"|$cand|"*) continue ;; - *) ;; # first sighting of this candidate — resolve it below - esac - seen="$seen|$cand|" - norm=$(hook::normalize_path "$(hook::physical_path "$cand")") + local target="$1" cand norm phys + hook::_temp_root_candidates + for cand in ${_HOOK_TEMP_CANDS[@]+"${_HOOK_TEMP_CANDS[@]}"}; do + if ((_HOOK_UTR_TARGET_PHYSICAL)); then + hook::normalize_path_to norm "$cand" + if [[ "$norm" != / ]]; then + norm="${norm%/}" + if [[ -n "$norm" && ("$target" == "$norm" || "$target" == "$norm"/*) ]]; then + return 0 + fi + fi + fi + hook::_physical_cached_to phys "$cand" || : + hook::normalize_path_to norm "$phys" # The filesystem root as a temp candidate contains every absolute path; # trimming its only slash would empty the candidate and discard it. [[ "$norm" == / ]] && return 0 @@ -523,21 +691,421 @@ hook::in_git_working_tree() { ) >/dev/null 2>&1 } +# --- Builtin JSON helpers ---------------------------------------------------- +# hook::read_file_path and hook::emit_telemetry used to spend a jq process each +# on every hook run. On Windows Git Bash one spawn costs tens of milliseconds, +# so the helpers here handle the well-formed shapes those two functions see in +# practice with shell builtins and hand anything they cannot PROVE to the +# unchanged jq path. The contract is "the bytes jq would produce, or fall +# back", never "close enough". +# +# What the builtin paths rely on: the text is valid JSON. Every hook payload +# reaching hook::read_file_path has passed hook::buffer_stdin's jq validation, +# and every telemetry data object is built with jq. The helpers still verify +# the structure they walk (terminated strings, well-formed tokens, properly +# nested brackets, one root, no raw control bytes inside strings), but they +# are not a full JSON parser and do not claim to reject every malformed text. +# +# Why no scanning: on this repo's Windows hosts bash regex matching and the +# `%%`/`##` pattern operators cost about a microsecond per character, so a +# 60 KB Write payload would cost more than the jq process they replace. The +# only string operations used on the whole payload are literal-substring +# replacement, `[[ == *x* ]]` containment, IFS word splitting and offset +# slicing, all of which run at C speed. + +# hook::_json_split +# Split at every unescaped double quote into the global array +# _HOOK_JSON_PARTS: even indices hold the structural text between strings, odd +# indices hold string bodies. Before splitting, every `\\` and `\"` pair is +# replaced by `@@` (same length), so a quote that survives is a real string +# delimiter and a body's ORIGINAL bytes are ${text:offset:length}. Returns 1 +# when the text is too large, its quotes do not pair up, or it holds more +# strings than the callers' loops are meant to walk. +_HOOK_JSON_PARTS=() +hook::_json_split() { + local __hu_s="$1" __hu_t __hu_q __hu_n + ((${#__hu_s} <= 65536)) || return 1 + __hu_t=${__hu_s//"\\\\"/@@} + __hu_t=${__hu_t//"\\\""/@@} + __hu_q=${__hu_t//\"/} + __hu_n=$((${#__hu_t} - ${#__hu_q})) + ((__hu_n % 2 == 0)) || return 1 + ((__hu_n <= 800)) || return 1 + local __hu_glob=0 + [[ $- == *f* ]] || __hu_glob=1 + set -f + local IFS='"' + # shellcheck disable=SC2206 # splitting on IFS='"' is the whole point + _HOOK_JSON_PARTS=($__hu_t) + ((__hu_glob)) && set +f + ((${#_HOOK_JSON_PARTS[@]} % 2 == 1)) || return 1 + return 0 +} + +# hook::_json_skeleton +# Split (hook::_json_split), record each string body's byte offset in +# _HOOK_JSON_OFF, and build _HOOK_JSON_SK: the structural text with every +# string replaced by a numbered placeholder `"#"` and all JSON +# whitespace removed. The skeleton is small (no string bodies), so regex and +# per-token work on it is cheap. Returns 1 unless the text is exactly one JSON +# value by the JSON grammar (well-formed tokens, whitespace only between them, +# objects as key:value lists, arrays as value lists, brackets nested and +# closed once) whose strings carry no raw control byte, which jq rejects. +_HOOK_JSON_SK="" +_HOOK_JSON_OFF=() +hook::_json_skeleton() { + local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top + hook::_json_split "$__hu_s" || return 1 + __hu_n=${#_HOOK_JSON_PARTS[@]} + _HOOK_JSON_OFF=() + for ((__hu_i = 0; __hu_i < __hu_n; __hu_i++)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + if ((__hu_i % 2 == 0)); then + __hu_sk+=$__hu_part + else + _HOOK_JSON_OFF[__hu_i]=$__hu_off + [[ "$__hu_part" == *[[:cntrl:]]* ]] && return 1 + __hu_sk+="\"#$__hu_i\"" + fi + __hu_off=$((__hu_off + ${#__hu_part} + 1)) + done + # Tokenize the skeleton and run it through the JSON grammar. Each token is + # consumed by one anchored regex on the (small) remainder; whitespace is + # allowed only between tokens, so `tr ue` is two bad tokens, not `true`. + local __hu_re=$'^[ \t\n\r]*(\\{|\\}|\\[|\\]|,|:|"#[0-9]+"|-?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][-+]?[0-9]+)?|true|false|null)' + local __hu_ws=$'^[ \t\n\r]*$' + __hu_rest=$__hu_sk + while [[ -n "$__hu_rest" ]] && ! [[ "$__hu_rest" =~ $__hu_ws ]]; do + [[ "$__hu_rest" =~ $__hu_re ]] || return 1 + __hu_tok=${BASH_REMATCH[1]} + __hu_rest=${__hu_rest:${#BASH_REMATCH[0]}} + __hu_top=${__hu_stack:${#__hu_stack}-1:1} + case "$__hu_expect" in + value | value_or_end) + case "$__hu_tok" in + '{') + __hu_stack+='{' + __hu_expect=key_or_end + continue + ;; + '[') + __hu_stack+='[' + __hu_expect=value_or_end + continue + ;; + ']') + [[ "$__hu_expect" == value_or_end ]] || return 1 + ;; + '}' | ',' | ':') return 1 ;; + *) + # A scalar. Inside a container the next token is , or the close; + # at the top level nothing may follow. + if [[ -n "$__hu_stack" ]]; then __hu_expect=comma_or_end; else __hu_expect=end; fi + continue + ;; + esac + ;; + key_or_end | key) + case "$__hu_tok" in + \"#*) + __hu_expect='colon' + continue + ;; + '}') [[ "$__hu_expect" == key_or_end ]] || return 1 ;; + *) return 1 ;; + esac + ;; + colon) + [[ "$__hu_tok" == ':' ]] || return 1 + __hu_expect=value + continue + ;; + comma_or_end) + case "$__hu_tok" in + ',') + if [[ "$__hu_top" == '{' ]]; then __hu_expect=key; else __hu_expect=value; fi + continue + ;; + '}') [[ "$__hu_top" == '{' ]] || return 1 ;; + ']') [[ "$__hu_top" == '[' ]] || return 1 ;; + *) return 1 ;; + esac + ;; + *) return 1 ;; # `end`: a token after the root value closed + esac + # Reaching here means a container just closed. + [[ -n "$__hu_stack" ]] || return 1 + __hu_stack=${__hu_stack:0:${#__hu_stack}-1} + if [[ -n "$__hu_stack" ]]; then __hu_expect=comma_or_end; else __hu_expect=end; fi + done + [[ "$__hu_expect" == end ]] || return 1 + _HOOK_JSON_SK=${__hu_sk//[$' \t\n\r']/} + return 0 +} + +# hook::json_unescape_to +# Decode a JSON string body (the bytes between the quotes) into . Handles +# \" \\ \/ \b \f \n \r \t and \uXXXX for U+0001 to U+007F. Returns 1 on any +# other escape (a \u outside that range, U+0000 which bash cannot hold, or an +# invalid escape), leaving the caller to fall back to jq. +hook::json_unescape_to() { + local __hu_raw="$2" __hu_out="" __hu_pre __hu_c __hu_hex __hu_oct __hu_ch + while [[ "$__hu_raw" == *\\* ]]; do + __hu_pre=${__hu_raw%%\\*} + __hu_out+=$__hu_pre + __hu_raw=${__hu_raw:${#__hu_pre}+1} + __hu_c=${__hu_raw:0:1} + __hu_raw=${__hu_raw:1} + case "$__hu_c" in + '"') __hu_out+='"' ;; + "\\") __hu_out+="\\" ;; + '/') __hu_out+='/' ;; + b) __hu_out+=$'\b' ;; # portability-ok: bash ANSI-C backspace byte, not a GNU grep word boundary + f) __hu_out+=$'\f' ;; + n) __hu_out+=$'\n' ;; + r) __hu_out+=$'\r' ;; + t) __hu_out+=$'\t' ;; + u) + __hu_hex=${__hu_raw:0:4} + [[ "$__hu_hex" =~ ^00[0-7][0-9a-fA-F]$ ]] || return 1 + [[ "$__hu_hex" == 0000 ]] && return 1 + __hu_raw=${__hu_raw:4} + printf -v __hu_oct '%03o' "$((16#$__hu_hex))" + printf -v __hu_ch '%b' "\\0$__hu_oct" + __hu_out+=$__hu_ch + ;; + *) return 1 ;; + esac + done + __hu_out+=$__hu_raw + printf -v "$1" '%s' "$__hu_out" +} + +# hook::_fast_file_path_to +# The builtin answer to `jq -r '(.tool_input.file_path // empty) | +# gsub("\r";"")'` followed by the `$(...)` capture. Returns +# 0 proven: holds exactly what jq would have printed +# 1 proven absent: jq would have printed nothing (the guard skips) +# 2 not proven: run jq +# Proof, on top of hook::_json_skeleton's structural checks: the root is an +# object; exactly one string in the whole payload decodes to `tool_input` and +# exactly one to `file_path` (so neither a duplicate key nor a same-named key +# in another object can change jq's answer); `tool_input` is a key of the root +# (depth 1, key position) whose value is a flat object (no nested object or +# array, else jq); and inside it `file_path` is a key with a plain string +# value. A payload where `file_path` names a value or sits in another object +# has no `.tool_input.file_path`, which jq reports as nothing. Key strings are +# compared AFTER decoding their escapes, so a key spelled with \u escapes is +# still recognized; a body longer than any escaped spelling of either name is +# skipped without decoding. +hook::_fast_file_path_to() { + local __hu_s="$2" __hu_i __hu_n __hu_part __hu_body __hu_ti=-1 __hu_fp=-1 __hu_m __hu_raw __hu_pre __hu_re __hu_file + hook::_json_skeleton "$__hu_s" || return 2 + [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 + __hu_n=${#_HOOK_JSON_PARTS[@]} + for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + ((${#__hu_part} <= 60)) || continue + __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} + if [[ "$__hu_body" == *\\* ]]; then + hook::json_unescape_to __hu_body "$__hu_body" || continue + fi + case "$__hu_body" in + tool_input) + ((__hu_ti < 0)) || return 2 + __hu_ti=$__hu_i + ;; + file_path) + ((__hu_fp < 0)) || return 2 + __hu_fp=$__hu_i + ;; + *) ;; + esac + done + ((__hu_fp >= 0)) || return 1 + ((__hu_ti >= 0)) || return 1 + __hu_re="(^|[{,])\"#$__hu_ti\":\\{([^][{}]*)\\}" + if [[ "$_HOOK_JSON_SK" =~ $__hu_re ]]; then + __hu_body=${BASH_REMATCH[2]} + __hu_pre=${_HOOK_JSON_SK%%\"#"$__hu_ti"\"*} + # Depth of the key: opening brackets minus closing ones before it must be + # exactly one, i.e. a direct member of the root object. + local __hu_o1=${__hu_pre//\{/} __hu_o2=${__hu_pre//\[/} __hu_c1=${__hu_pre//\}/} __hu_c2=${__hu_pre//\]/} + local __hu_depth=$(((${#__hu_pre} - ${#__hu_o1}) + (${#__hu_pre} - ${#__hu_o2}) - (${#__hu_pre} - ${#__hu_c1}) - (${#__hu_pre} - ${#__hu_c2}))) + ((__hu_depth == 1)) || return 2 + else + return 2 + fi + __hu_re="(^|,)\"#$__hu_fp\":\"#([0-9]+)\"(,|$)" + if [[ "$__hu_body" =~ $__hu_re ]]; then + __hu_m=${BASH_REMATCH[2]} + elif [[ "$__hu_body" == *\"#$__hu_fp\"* ]]; then + return 2 + else + return 1 + fi + __hu_raw=${__hu_s:${_HOOK_JSON_OFF[__hu_m]}:${#_HOOK_JSON_PARTS[__hu_m]}} + if [[ "$__hu_raw" == *\\* ]]; then + hook::json_unescape_to __hu_file "$__hu_raw" || return 2 + else + __hu_file=$__hu_raw + fi + __hu_file=${__hu_file//$'\r'/} + while [[ "$__hu_file" == *$'\n' ]]; do + __hu_file=${__hu_file%$'\n'} + done + printf -v "$1" '%s' "$__hu_file" + return 0 +} + +# hook::dirname_to : dirname with builtins for the resolver's +# answer. Strips the last segment; a bare name lives in `.`, a root-level file +# in `/`. The path comes from realpath, so the trailing-slash and doubled-slash +# spellings dirname also collapses never occur here. +hook::dirname_to() { + local __hu_d="${2%/*}" + [[ "$__hu_d" == "$2" ]] && __hu_d=. + [[ -n "$__hu_d" ]] || __hu_d=/ + printf -v "$1" '%s' "$__hu_d" +} + +# Print the arguments joined by NUL bytes, no trailing NUL: the byte stream +# hook::read_file_path read from stdin, rebuilt for the jq fallback. +hook::_print_nul_joined() { + local __hu_first=1 __hu_c + for __hu_c in "$@"; do + ((__hu_first)) || printf '\0' + __hu_first=0 + printf '%s' "$__hu_c" + done +} + +# hook::json_compact_to +# The bytes `jq -c .` prints for , when that can be proven with +# builtins: JSON whitespace between tokens removed, everything else verbatim. +# jq's own output, compact or pretty, satisfies the proof, which is what every +# telemetry data builder in this marketplace produces (jq -c, jq -n, or a +# compact literal). Returns 1 when the text is not a single object, or a +# string carries something jq would re-encode (a \u or \/ escape, an escape +# outside the JSON set, a raw control byte), or the structure fails +# hook::_json_skeleton; the caller then runs jq. +hook::json_compact_to() { + local __hu_s="$2" __hu_out="" __hu_i __hu_part __hu_n + hook::_json_skeleton "$__hu_s" || return 1 + [[ "$_HOOK_JSON_SK" == \{*\} ]] || return 1 + # Numbers: only short integer literals are passed through. A fraction, an + # exponent or an integer beyond 15 digits is rendered differently by + # different jq releases (1.6 canonicalizes, 1.7+ preserves the literal), so + # the bytes cannot be proven; jq decides. In the skeleton every string is a + # `"#N"` placeholder and letters occur only in true/false/null, so a digit + # followed by `.`, `e` or `E` can only be part of a number. + local __hu_num='[0-9][.eE]|[0-9]{16}' + [[ "$_HOOK_JSON_SK" =~ $__hu_num ]] && return 1 + __hu_n=${#_HOOK_JSON_PARTS[@]} + for ((__hu_i = 0; __hu_i < __hu_n; __hu_i++)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + if ((__hu_i % 2 == 0)); then + __hu_out+=${__hu_part//[$' \t\n\r']/} + else + if [[ "$__hu_part" == *\\* ]]; then + [[ "$__hu_part" == *'\u'* || "$__hu_part" == *'\/'* ]] && return 1 + [[ "$__hu_part" == *\\[!bfnrt]* ]] && return 1 + fi + __hu_out+="\"${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}}\"" + fi + done + printf -v "$1" '%s' "$__hu_out" +} + +# hook::json_escape_jq_to / hook::json_escape_jq +# Escape exactly as jq serializes a string value (without the +# quotes): backslash and double quote, the five short forms \b \f \n \r \t, +# every other byte from 0x01 to 0x1f and 0x7f as lowercase \u00xx, and +# everything else, including non-ASCII, verbatim. This is the escaper the +# telemetry envelope needs for byte parity with the jq envelope it replaced; +# hook::json_escape above is a different tool (it DROPS the residual control +# bytes, which is right for a notice and wrong for parity) and is unchanged. +hook::json_escape_jq_to() { + local __hu_s="$2" __hu_i __hu_c __hu_oct __hu_hex + __hu_s="${__hu_s//\\/\\\\}" + __hu_s="${__hu_s//\"/\\\"}" + if [[ "$__hu_s" == *[[:cntrl:]]* ]]; then + __hu_s="${__hu_s//$'\n'/\\n}" + __hu_s="${__hu_s//$'\r'/\\r}" + __hu_s="${__hu_s//$'\t'/\\t}" + __hu_s="${__hu_s//$'\b'/\\b}" # portability-ok: bash ANSI-C backspace byte and the JSON \b escape, not a GNU grep word boundary + __hu_s="${__hu_s//$'\f'/\\f}" + for __hu_i in 1 2 3 4 5 6 7 11 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 127; do + printf -v __hu_oct '%03o' "$__hu_i" + printf -v __hu_c '%b' "\\0$__hu_oct" + [[ "$__hu_s" == *"$__hu_c"* ]] || continue + printf -v __hu_hex '%04x' "$__hu_i" + __hu_s="${__hu_s//"$__hu_c"/\\u$__hu_hex}" + done + fi + printf -v "$1" '%s' "$__hu_s" +} + +hook::json_escape_jq() { + local __hu_e + hook::json_escape_jq_to __hu_e "$1" + printf '%s' "$__hu_e" +} + # Parse file_path from PostToolUse JSON on stdin; validate existence and (when # CLAUDE_PROJECT_DIR is set) project membership. Both sides of the membership # comparison are canonicalized (symlinks resolved) first, so neither an # escaping symlink nor a project root reached via a symlinked path (e.g. # macOS /tmp) skews the verdict. Outputs the path on success. Returns 1 to skip. # FILE=$(hook::read_file_path) || exit 0 +# +# jq-free on the common shape. The payload is read into the shell and +# hook::_fast_file_path_to takes `.tool_input.file_path` with builtins when it +# can PROVE the answer jq would give (see that helper for what "prove" means); +# every other payload goes to the unchanged jq filter. The path comparison then +# resolves the file, the project root and the temp roots with one realpath +# process instead of one each (hook::_physical_prime), and remembers the +# directories for the process. Same verdict, same emitted path, fewer +# processes: on Windows Git Bash each spawn costs tens of milliseconds and this +# guard runs on every Write and Edit. hook::read_file_path() { - local file - file=$(jq -r '(.tool_input.file_path // empty) | gsub("\r";"")' 2>/dev/null) + local -a chunks=() + local chunk file="" mode=2 + # Builtin read to NUL or EOF. A NUL splits the payload into several chunks; + # the fast path only takes a single-chunk (NUL-free) payload, and the jq + # fallback is fed the chunks NUL-joined, so it sees the bytes jq used to + # read straight from stdin. + while :; do + chunk="" + if IFS= read -r -d '' chunk; then + chunks+=("$chunk") + continue + fi + chunks+=("$chunk") + break + done + if ((${#chunks[@]} == 1)); then + mode=0 + hook::_fast_file_path_to file "${chunks[0]}" || mode=$? + fi + case "$mode" in + 0) ;; # proven: `file` holds jq's answer + 1) return 1 ;; # proven absent + *) + file=$(hook::_print_nul_joined "${chunks[@]}" | jq -r '(.tool_input.file_path // empty) | gsub("\r";"")' 2>/dev/null) + ;; + esac [[ -n "$file" ]] || return 1 [[ -f "$file" ]] || return 1 if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then - local norm_file norm_project - norm_file=$(hook::normalize_path "$(hook::physical_path "$file")") - norm_project=$(hook::normalize_path "$(hook::physical_path "${CLAUDE_PROJECT_DIR}")") + local norm_file norm_project phys_file phys_project file_resolved=0 project_resolved=0 + hook::_temp_root_candidates + hook::_physical_prime "$file" "${CLAUDE_PROJECT_DIR}" ${_HOOK_TEMP_CANDS[@]+"${_HOOK_TEMP_CANDS[@]}"} + hook::_physical_cached_to phys_file "$file" && file_resolved=1 + hook::_phys_cache_forget "$file" + hook::_physical_cached_to phys_project "${CLAUDE_PROJECT_DIR}" && project_resolved=1 + hook::normalize_path_to norm_file "$phys_file" + hook::normalize_path_to norm_project "$phys_project" norm_project="${norm_project%/}" # Anchor on a path-segment boundary: accept the project root itself or a # child under it, but not a sibling whose name merely shares the prefix @@ -558,19 +1126,28 @@ hook::read_file_path() { # built by `mktemp -d`, which is how this repo's own hook suites run), so # the branch must not fire. Only a temp-tree file reached from a project # root OUTSIDE the temp tree is scratch. - if hook::under_temp_root "$norm_file" && ! hook::under_temp_root "$norm_project"; then - return 1 + local file_in_temp=0 + _HOOK_UTR_TARGET_PHYSICAL=$file_resolved + hook::under_temp_root "$norm_file" && file_in_temp=1 + _HOOK_UTR_TARGET_PHYSICAL=0 + if ((file_in_temp)); then + local project_in_temp=0 + _HOOK_UTR_TARGET_PHYSICAL=$project_resolved + hook::under_temp_root "$norm_project" && project_in_temp=1 + _HOOK_UTR_TARGET_PHYSICAL=0 + ((project_in_temp)) || return 1 fi elif command -v git >/dev/null 2>&1; then # When CLAUDE_PROJECT_DIR is unset, scope to git-working-tree membership so # scratch files outside any repository are not mutated by formatter hooks # (#1091 / #972). - local file_physical - file_physical=$(hook::physical_path "$file") + local file_physical file_dir + hook::physical_path_to file_physical "$file" || : if [[ -L "$file" && "$file_physical" == "$file" ]]; then return 1 fi - if ! hook::in_git_working_tree "$(dirname "$file_physical")"; then + hook::dirname_to file_dir "$file_physical" + if ! hook::in_git_working_tree "$file_dir"; then return 1 fi else @@ -1228,7 +1805,19 @@ hook::telemetry_enabled() { # Fire-and-forget: sink is dispatched in the background; the hook never waits # on it and its failure never affects the hook's own exit code or stdout. # Opt-in guard: HOOK_TELEMETRY_SINK unset or empty → return 0 immediately. -# Fail-open: jq absent → return 0 immediately. +# +# The envelope is assembled with shell builtins (hook::json_escape_jq_to for +# the string fields, hook::json_compact_to for the caller's data object) as +# ONE compact line, `jq -c` style: the same document, field order and +# escapes the jq filter below produces, with the data object compacted +# exactly as jq compacts its own output. That path needs no jq, no temp file +# and no subprocess. When the data object cannot be proven jq-idempotent (see +# hook::json_compact_to), the jq path runs, now with -c so both paths write +# the same bytes, and there jq's absence is still fail-open: return 0, no +# envelope. Before this the envelope was jq's default pretty-printed form +# (several lines, and CRLF line endings with a Windows jq); the sink contract +# was always "one JSON document on stdin", and a compact line is also valid +# JSONL for a sink that appends raw envelopes. # # Usage: # hook::emit_telemetry [repo_root] @@ -1251,7 +1840,6 @@ hook::telemetry_enabled() { # NEVER writes to fd1 (the hook's stdout / additionalContext channel). hook::emit_telemetry() { [[ -n "${HOOK_TELEMETRY_SINK:-}" ]] || return 0 - command -v jq >/dev/null 2>&1 || return 0 local hook_id="$1" local hook_event="$2" @@ -1272,35 +1860,59 @@ hook::emit_telemetry() { local e_s="${now%[.,]*}" e_f="${now#*[.,]}" local duration_ms=$(((e_s * 1000000 + 10#$e_f - s_s * 1000000 - 10#$s_f) / 1000)) - # True UTC timestamp (TZ= prefix overrides LC_ALL / local TZ; the Z is not a lie). - local timestamp - timestamp=$(TZ=UTC printf '%(%Y-%m-%dT%H:%M:%SZ)T' -1) - - # Build the envelope. The `data` object is written to a temp file, not passed - # as --argjson, so payloads larger than the Windows 32767-character command-line - # cap are not dropped before jq runs (#1595). /dev/stdin is not used: jq's - # --slurpfile there is not portable on Windows. Redirect jq stderr to - # /dev/null; output goes to a local variable — never to fd1. - local envelope data_file - data_file=$(mktemp) || return 0 - printf '%s' "$data_json" >"$data_file" || { - rm -f "$data_file" - return 0 - } - envelope=$(jq -n \ - --arg schema_version "1.0" \ - --arg timestamp "$timestamp" \ - --arg hook "$hook_id" \ - --arg hook_event "$hook_event" \ - --arg status "$status" \ - --argjson duration_ms "$duration_ms" \ - --slurpfile data "$data_file" \ - '{schema_version:$schema_version,timestamp:$timestamp,hook:$hook,hook_event:$hook_event,status:$status,duration_ms:$duration_ms,data:($data[0] // {})}' \ - 2>/dev/null) || { + # True UTC timestamp (TZ= prefix overrides LC_ALL / local TZ; the Z is not a + # lie). The %()T printf format is Bash 4.2+, and printf -v keeps it in the + # shell (a `$(...)` capture is a fork); an older bash, or a printf that + # binds nothing, falls back to date -u. + local timestamp="" + TZ=UTC printf -v timestamp '%(%Y-%m-%dT%H:%M:%SZ)T' -1 2>/dev/null || timestamp="" + [[ -n "$timestamp" ]] || timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null) || timestamp="" + + # Trailing whitespace after the object (a newline a caller left on the + # value) does not change what jq parses, so it does not block the builtin + # path either. + local envelope="" data="" data_trim="$data_json" + while [[ "$data_trim" == *[$' \t\r\n'] ]]; do + data_trim="${data_trim%?}" + done + if [[ "$data_trim" == \{*\} ]] && hook::json_compact_to data "$data_trim"; then + # Builtin envelope: the same document the jq filter below produces, field + # for field, with jq's string escaping and jq's compact data. + local e_timestamp e_hook e_event e_status + hook::json_escape_jq_to e_timestamp "$timestamp" + hook::json_escape_jq_to e_hook "$hook_id" + hook::json_escape_jq_to e_event "$hook_event" + hook::json_escape_jq_to e_status "$status" + envelope='{"schema_version":"1.0","timestamp":"'"$e_timestamp"'","hook":"'"$e_hook"'","hook_event":"'"$e_event"'","status":"'"$e_status"'","duration_ms":'"$duration_ms"',"data":'"$data"'}' + else + # jq path, for a data object the builtin compactor cannot prove. Fail-open: + # jq absent → return 0. The `data` object is written to a temp file, not + # passed as --argjson, so payloads larger than the Windows 32767-character + # command-line cap are not dropped before jq runs (#1595). /dev/stdin is + # not used: jq's --slurpfile there is not portable on Windows. Redirect jq + # stderr to /dev/null; output goes to a local variable — never to fd1. + command -v jq >/dev/null 2>&1 || return 0 + local data_file + data_file=$(mktemp) || return 0 + printf '%s' "$data_json" >"$data_file" || { + rm -f "$data_file" + return 0 + } + envelope=$(jq -nc \ + --arg schema_version "1.0" \ + --arg timestamp "$timestamp" \ + --arg hook "$hook_id" \ + --arg hook_event "$hook_event" \ + --arg status "$status" \ + --argjson duration_ms "$duration_ms" \ + --slurpfile data "$data_file" \ + '{schema_version:$schema_version,timestamp:$timestamp,hook:$hook,hook_event:$hook_event,status:$status,duration_ms:$duration_ms,data:($data[0] // {})}' \ + 2>/dev/null) || { + rm -f "$data_file" + return 0 + } rm -f "$data_file" - return 0 - } - rm -f "$data_file" + fi # Resolve the sink path. A relative HOOK_TELEMETRY_SINK is joined onto the # consuming repo root (portable, tracked wiring); absolute is used as-is. A diff --git a/plugins/typos-format/.claude-plugin/plugin.json b/plugins/typos-format/.claude-plugin/plugin.json index 463393ee97..6687182969 100644 --- a/plugins/typos-format/.claude-plugin/plugin.json +++ b/plugins/typos-format/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "typos-format", - "version": "0.6.35", + "version": "0.6.36", "description": "Spell-check on edit via typos-cli, unconditionally \u2014 report-only by default, honoring the consuming repo's own typos configuration when one is present.", "author": { "name": "Melodic Software", diff --git a/plugins/typos-format/CHANGELOG.md b/plugins/typos-format/CHANGELOG.md index 1388fbba3e..3661bbe5ea 100644 --- a/plugins/typos-format/CHANGELOG.md +++ b/plugins/typos-format/CHANGELOG.md @@ -3,6 +3,22 @@ All notable changes to the `typos-format` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.6.36] + +### Changed + +- **Vendored `hook-utils.sh` builds the telemetry envelope and reads `file_path` + with shell builtins.** `hook::emit_telemetry` no longer spawns two jq + processes, a mktemp and an rm per run: the envelope is assembled in the shell + as one compact line (the same document jq produced, now `jq -c` shaped), with + jq kept only as the fallback for a data object the builtin compactor cannot + prove. `hook::read_file_path` takes `.tool_input.file_path` without jq on the + well-formed payload shape and resolves the file, project root and temp roots + with one batched `realpath` instead of one process each. Same verdicts, same + emitted path, same sink record; phase 4b of the hook-performance program + (#3623). The copy is bumped because `scripts/sync-hook-utils.sh` keeps every + carrying plugin byte-identical. + ## [0.6.35] ### Changed diff --git a/plugins/typos-format/hooks/hook-utils.sh b/plugins/typos-format/hooks/hook-utils.sh index 9818a6a73d..18e4dd5745 100644 --- a/plugins/typos-format/hooks/hook-utils.sh +++ b/plugins/typos-format/hooks/hook-utils.sh @@ -390,19 +390,34 @@ hook::require_jq_blocking() { # collapse with `/c/repo` and the membership guard would admit a sibling # outside CLAUDE_PROJECT_DIR. The result is used ONLY for comparison; the # emitted path is always the caller's original. -hook::normalize_path() { - local p="${1//\\//}" +# +# Two spellings of every path helper below: `hook::` prints the answer +# (the public, subshell-friendly form every caller already uses) and +# `hook::_to ` stores it in the caller's variable instead. +# The `_to` form is what the hot path uses: a `$(...)` capture is a fork, and +# on Windows Git Bash a fork costs milliseconds, so the file_path guard that +# ran a dozen of them per hook now runs none it does not need. The `_to` +# helpers keep their locals under a `__hu_` prefix so the caller's variable +# name cannot collide with them. +hook::normalize_path_to() { + local __hu_p="${2//\\//}" case "${OSTYPE:-}" in msys* | cygwin* | win32) - if [[ "$p" =~ ^/([a-zA-Z])/ || "$p" =~ ^([a-zA-Z]):/ ]]; then - local rest="${p:2}" - printf '%s' "${BASH_REMATCH[1]^}:${rest,,}" - return + if [[ "$__hu_p" =~ ^/([a-zA-Z])/ || "$__hu_p" =~ ^([a-zA-Z]):/ ]]; then + local __hu_rest="${__hu_p:2}" + printf -v "$1" '%s' "${BASH_REMATCH[1]^}:${__hu_rest,,}" + return 0 fi ;; *) ;; # POSIX hosts: case-sensitive FS, no drive fold — pass through below esac - printf '%s' "$p" + printf -v "$1" '%s' "$__hu_p" +} + +hook::normalize_path() { + local __hu_n + hook::normalize_path_to __hu_n "$1" + printf '%s' "$__hu_n" } # Expand Windows 8.3 short-name components (KYLESE~1 → KyleSexton) on @@ -424,25 +439,31 @@ hook::normalize_path() { # class: cygpath ships with Git Bash (the documented Windows bash), so its # absence or failure keeps the resolver's answer unchanged — degrading to the # pre-expansion comparison, same doctrine as the resolver fallback below. -hook::expand_8dot3() { - local p="$1" +hook::expand_8dot3_to() { + local __hu_p="$2" case "${OSTYPE:-}" in msys* | cygwin* | win32) ;; *) - printf '%s' "$p" - return + printf -v "$1" '%s' "$__hu_p" + return 0 ;; esac - if [[ "$p" == *~* ]] && command -v cygpath >/dev/null 2>&1; then - local plain long - if plain=$(cygpath -m -- "$p" 2>/dev/null) && - long=$(cygpath -l -m -- "$p" 2>/dev/null) && - [[ -n "$long" && "$long" != "$plain" ]]; then - printf '%s' "$long" - return + if [[ "$__hu_p" == *~* ]] && command -v cygpath >/dev/null 2>&1; then + local __hu_plain __hu_long + if __hu_plain=$(cygpath -m -- "$__hu_p" 2>/dev/null) && + __hu_long=$(cygpath -l -m -- "$__hu_p" 2>/dev/null) && + [[ -n "$__hu_long" && "$__hu_long" != "$__hu_plain" ]]; then + printf -v "$1" '%s' "$__hu_long" + return 0 fi fi - printf '%s' "$p" + printf -v "$1" '%s' "$__hu_p" +} + +hook::expand_8dot3() { + local __hu_e + hook::expand_8dot3_to __hu_e "$1" + printf '%s' "$__hu_e" } # Canonicalize to a physical path — symlinks resolved, Windows 8.3 short names @@ -462,20 +483,148 @@ hook::expand_8dot3() { # expansion applies only on the resolver's success path: consumers that fail # closed on an unresolved signature must not see a form-converted path instead. # shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED -hook::physical_path() { - local resolved +hook::physical_path_to() { + local __hu_r HOOK_PHYSICAL_PATH_UNRESOLVED=0 - if resolved=$(realpath -- "$1" 2>/dev/null) || resolved=$(readlink -f -- "$1" 2>/dev/null); then - if [[ -n "$resolved" ]]; then - hook::expand_8dot3 "$resolved" + if __hu_r=$(realpath -- "$2" 2>/dev/null) || __hu_r=$(readlink -f -- "$2" 2>/dev/null); then + if [[ -n "$__hu_r" ]]; then + hook::expand_8dot3_to "$1" "$__hu_r" return 0 fi fi HOOK_PHYSICAL_PATH_UNRESOLVED=1 - printf '%s' "$1" + printf -v "$1" '%s' "$2" + return 1 +} + +# shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED +hook::physical_path() { + local __hu_v __hu_rc=0 + hook::physical_path_to __hu_v "$1" || __hu_rc=$? + printf '%s' "$__hu_v" + return "$__hu_rc" +} + +# --- Per-process physical-path cache -------------------------------------- +# The membership guard resolves the same few directories on every call: the +# project root and this host's temp roots. Their physical form does not change +# within one hook process, so each spelling is resolved once and remembered in +# three parallel indexed arrays (keys, values, resolver status). Plain arrays, +# not an associative array, so the cache runs on Bash 3.2. Only directories are +# meant to live here for a process lifetime; hook::read_file_path forgets the +# edited file's entry as soon as it has read it, so a later call in the same +# process sees the file as it is then. +_HOOK_PHYS_KEYS=() +_HOOK_PHYS_VALS=() +_HOOK_PHYS_RCS=() +_HOOK_PHYS_I=-1 + +# hook::_phys_cache_index : sets _HOOK_PHYS_I to the slot holding , +# returns 1 when it is not cached. A forgotten slot has an empty key, which no +# real path can match. +hook::_phys_cache_index() { + local __hu_i + [[ -n "$1" ]] || return 1 + for ((__hu_i = 0; __hu_i < ${#_HOOK_PHYS_KEYS[@]}; __hu_i++)); do + if [[ "${_HOOK_PHYS_KEYS[__hu_i]}" == "$1" ]]; then + _HOOK_PHYS_I=$__hu_i + return 0 + fi + done return 1 } +hook::_phys_cache_forget() { + hook::_phys_cache_index "$1" && _HOOK_PHYS_KEYS[_HOOK_PHYS_I]="" + return 0 +} + +# hook::_physical_cached_to : hook::physical_path_to through the +# cache. Same value, same return status, same HOOK_PHYSICAL_PATH_UNRESOLVED as +# the uncached call; a miss resolves the one path and stores it. +# shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED +hook::_physical_cached_to() { + if hook::_phys_cache_index "$2"; then + printf -v "$1" '%s' "${_HOOK_PHYS_VALS[_HOOK_PHYS_I]}" + HOOK_PHYSICAL_PATH_UNRESOLVED=${_HOOK_PHYS_RCS[_HOOK_PHYS_I]} + return "${_HOOK_PHYS_RCS[_HOOK_PHYS_I]}" + fi + local __hu_v __hu_rc=0 + hook::physical_path_to __hu_v "$2" || __hu_rc=$? + if [[ -n "$2" ]]; then + _HOOK_PHYS_KEYS+=("$2") + _HOOK_PHYS_VALS+=("$__hu_v") + _HOOK_PHYS_RCS+=("$__hu_rc") + fi + printf -v "$1" '%s' "$__hu_v" + return "$__hu_rc" +} + +# hook::_physical_prime ...: resolve every uncached path with ONE +# realpath process and store the answers. realpath resolves each argument on +# its own and prints one line per argument in argument order, so the batch +# answer for a path is the same string the per-path call returns; the 8.3 +# expansion is applied per answer exactly as hook::physical_path_to does. Any +# doubt (a path carrying a newline, a non-zero exit, a line count that does not +# match) stores nothing and leaves the per-path resolver to answer lazily, so +# the batch can only ever save work, never change an answer. readlink -f hosts +# (no realpath) skip the batch for the same reason. +hook::_physical_prime() { + local -a __hu_todo=() + local __hu_p __hu_out __hu_i __hu_seen="" __hu_v + for __hu_p in "$@"; do + [[ -n "$__hu_p" ]] || continue + [[ "$__hu_p" == *$'\n'* ]] && return 0 + case "$__hu_seen" in + *"|$__hu_p|"*) continue ;; + *) ;; # first sighting + esac + __hu_seen="$__hu_seen|$__hu_p|" + hook::_phys_cache_index "$__hu_p" && continue + __hu_todo+=("$__hu_p") + done + ((${#__hu_todo[@]} > 1)) || return 0 + command -v realpath >/dev/null 2>&1 || return 0 + # portability-ok: realpath with several operands is GNU and BSD alike; a host whose realpath rejects it fails the exit-status check and falls back per path + __hu_out=$(realpath -- "${__hu_todo[@]}" 2>/dev/null) || return 0 + local -a __hu_lines=() + local __hu_glob=0 + [[ $- == *f* ]] || __hu_glob=1 + set -f + local IFS=$'\n' + # shellcheck disable=SC2206 # splitting realpath's one-line-per-operand output is the intent + __hu_lines=($__hu_out) + ((__hu_glob)) && set +f + ((${#__hu_lines[@]} == ${#__hu_todo[@]})) || return 0 + for ((__hu_i = 0; __hu_i < ${#__hu_todo[@]}; __hu_i++)); do + [[ -n "${__hu_lines[__hu_i]}" ]] || return 0 + hook::expand_8dot3_to __hu_v "${__hu_lines[__hu_i]}" + _HOOK_PHYS_KEYS+=("${__hu_todo[__hu_i]}") + _HOOK_PHYS_VALS+=("$__hu_v") + _HOOK_PHYS_RCS+=(0) + done + return 0 +} + +# The temp-root candidates hook::under_temp_root compares against: the +# environment's own answer (TMPDIR/TMP/TEMP) plus the POSIX defaults, never a +# hardcoded platform assumption. Existing directories only, each spelling +# once, in _HOOK_TEMP_CANDS. +_HOOK_TEMP_CANDS=() +hook::_temp_root_candidates() { + local __hu_cand __hu_seen="" + _HOOK_TEMP_CANDS=() + for __hu_cand in "${TMPDIR:-}" "${TMP:-}" "${TEMP:-}" /tmp /var/tmp; do + [[ -n "$__hu_cand" && -d "$__hu_cand" ]] || continue + case "$__hu_seen" in + *"|$__hu_cand|"*) continue ;; + *) ;; # first sighting of this candidate + esac + __hu_seen="$__hu_seen|$__hu_cand|" + _HOOK_TEMP_CANDS+=("$__hu_cand") + done +} + # True when sits inside one of this host's temp trees. # Both arguments and candidates go through the same canonicalize+normalize # pipeline as the membership comparison, because the same directory has several @@ -483,21 +632,40 @@ hook::physical_path() { # of the identical directory, and `realpath` resolves the Windows form to a # drive path while leaving `/tmp` as `/tmp`. Neither form alone matches a # `file_path` that could arrive in either, so every candidate is compared and a -# match on any one is a match. Duplicates are resolved once. +# match on any one is a match. Duplicates are resolved once, and each +# candidate's physical form is remembered for the process (see the cache +# above), so a second call costs no resolver process. # -# Candidates are the environment's own answer (TMPDIR/TMP/TEMP) plus the POSIX -# defaults, never a hardcoded platform assumption. +# Raw-spelling shortcut: when the caller vouches that is a +# PHYSICAL path (_HOOK_UTR_TARGET_PHYSICAL=1, set only by hook::read_file_path +# after the resolver succeeded), a candidate whose raw normalized spelling is +# already a prefix of the target is a match without resolving it. A physical +# path contains no symlink component, so a candidate that spells one of its +# prefixes names a chain of real directories whose physical form is that same +# prefix; the resolver would answer the same. A candidate that does not match +# raw is still resolved, so a symlinked temp root (macOS /tmp) is found the +# way it always was. Without the vouch the shortcut is off and every candidate +# is resolved, exactly as before. Note that hook::read_file_path primes every +# candidate into the cache with one batched realpath before calling here, so +# on that path the shortcut skips a lookup, not a process; the batch is what +# saves the processes. A direct caller without the batch saves the resolver. # hook::under_temp_root "$norm_path" && ... +_HOOK_UTR_TARGET_PHYSICAL=0 hook::under_temp_root() { - local target="$1" cand norm seen="" - for cand in "${TMPDIR:-}" "${TMP:-}" "${TEMP:-}" /tmp /var/tmp; do - [[ -n "$cand" && -d "$cand" ]] || continue - case "$seen" in - *"|$cand|"*) continue ;; - *) ;; # first sighting of this candidate — resolve it below - esac - seen="$seen|$cand|" - norm=$(hook::normalize_path "$(hook::physical_path "$cand")") + local target="$1" cand norm phys + hook::_temp_root_candidates + for cand in ${_HOOK_TEMP_CANDS[@]+"${_HOOK_TEMP_CANDS[@]}"}; do + if ((_HOOK_UTR_TARGET_PHYSICAL)); then + hook::normalize_path_to norm "$cand" + if [[ "$norm" != / ]]; then + norm="${norm%/}" + if [[ -n "$norm" && ("$target" == "$norm" || "$target" == "$norm"/*) ]]; then + return 0 + fi + fi + fi + hook::_physical_cached_to phys "$cand" || : + hook::normalize_path_to norm "$phys" # The filesystem root as a temp candidate contains every absolute path; # trimming its only slash would empty the candidate and discard it. [[ "$norm" == / ]] && return 0 @@ -523,21 +691,421 @@ hook::in_git_working_tree() { ) >/dev/null 2>&1 } +# --- Builtin JSON helpers ---------------------------------------------------- +# hook::read_file_path and hook::emit_telemetry used to spend a jq process each +# on every hook run. On Windows Git Bash one spawn costs tens of milliseconds, +# so the helpers here handle the well-formed shapes those two functions see in +# practice with shell builtins and hand anything they cannot PROVE to the +# unchanged jq path. The contract is "the bytes jq would produce, or fall +# back", never "close enough". +# +# What the builtin paths rely on: the text is valid JSON. Every hook payload +# reaching hook::read_file_path has passed hook::buffer_stdin's jq validation, +# and every telemetry data object is built with jq. The helpers still verify +# the structure they walk (terminated strings, well-formed tokens, properly +# nested brackets, one root, no raw control bytes inside strings), but they +# are not a full JSON parser and do not claim to reject every malformed text. +# +# Why no scanning: on this repo's Windows hosts bash regex matching and the +# `%%`/`##` pattern operators cost about a microsecond per character, so a +# 60 KB Write payload would cost more than the jq process they replace. The +# only string operations used on the whole payload are literal-substring +# replacement, `[[ == *x* ]]` containment, IFS word splitting and offset +# slicing, all of which run at C speed. + +# hook::_json_split +# Split at every unescaped double quote into the global array +# _HOOK_JSON_PARTS: even indices hold the structural text between strings, odd +# indices hold string bodies. Before splitting, every `\\` and `\"` pair is +# replaced by `@@` (same length), so a quote that survives is a real string +# delimiter and a body's ORIGINAL bytes are ${text:offset:length}. Returns 1 +# when the text is too large, its quotes do not pair up, or it holds more +# strings than the callers' loops are meant to walk. +_HOOK_JSON_PARTS=() +hook::_json_split() { + local __hu_s="$1" __hu_t __hu_q __hu_n + ((${#__hu_s} <= 65536)) || return 1 + __hu_t=${__hu_s//"\\\\"/@@} + __hu_t=${__hu_t//"\\\""/@@} + __hu_q=${__hu_t//\"/} + __hu_n=$((${#__hu_t} - ${#__hu_q})) + ((__hu_n % 2 == 0)) || return 1 + ((__hu_n <= 800)) || return 1 + local __hu_glob=0 + [[ $- == *f* ]] || __hu_glob=1 + set -f + local IFS='"' + # shellcheck disable=SC2206 # splitting on IFS='"' is the whole point + _HOOK_JSON_PARTS=($__hu_t) + ((__hu_glob)) && set +f + ((${#_HOOK_JSON_PARTS[@]} % 2 == 1)) || return 1 + return 0 +} + +# hook::_json_skeleton +# Split (hook::_json_split), record each string body's byte offset in +# _HOOK_JSON_OFF, and build _HOOK_JSON_SK: the structural text with every +# string replaced by a numbered placeholder `"#"` and all JSON +# whitespace removed. The skeleton is small (no string bodies), so regex and +# per-token work on it is cheap. Returns 1 unless the text is exactly one JSON +# value by the JSON grammar (well-formed tokens, whitespace only between them, +# objects as key:value lists, arrays as value lists, brackets nested and +# closed once) whose strings carry no raw control byte, which jq rejects. +_HOOK_JSON_SK="" +_HOOK_JSON_OFF=() +hook::_json_skeleton() { + local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top + hook::_json_split "$__hu_s" || return 1 + __hu_n=${#_HOOK_JSON_PARTS[@]} + _HOOK_JSON_OFF=() + for ((__hu_i = 0; __hu_i < __hu_n; __hu_i++)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + if ((__hu_i % 2 == 0)); then + __hu_sk+=$__hu_part + else + _HOOK_JSON_OFF[__hu_i]=$__hu_off + [[ "$__hu_part" == *[[:cntrl:]]* ]] && return 1 + __hu_sk+="\"#$__hu_i\"" + fi + __hu_off=$((__hu_off + ${#__hu_part} + 1)) + done + # Tokenize the skeleton and run it through the JSON grammar. Each token is + # consumed by one anchored regex on the (small) remainder; whitespace is + # allowed only between tokens, so `tr ue` is two bad tokens, not `true`. + local __hu_re=$'^[ \t\n\r]*(\\{|\\}|\\[|\\]|,|:|"#[0-9]+"|-?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][-+]?[0-9]+)?|true|false|null)' + local __hu_ws=$'^[ \t\n\r]*$' + __hu_rest=$__hu_sk + while [[ -n "$__hu_rest" ]] && ! [[ "$__hu_rest" =~ $__hu_ws ]]; do + [[ "$__hu_rest" =~ $__hu_re ]] || return 1 + __hu_tok=${BASH_REMATCH[1]} + __hu_rest=${__hu_rest:${#BASH_REMATCH[0]}} + __hu_top=${__hu_stack:${#__hu_stack}-1:1} + case "$__hu_expect" in + value | value_or_end) + case "$__hu_tok" in + '{') + __hu_stack+='{' + __hu_expect=key_or_end + continue + ;; + '[') + __hu_stack+='[' + __hu_expect=value_or_end + continue + ;; + ']') + [[ "$__hu_expect" == value_or_end ]] || return 1 + ;; + '}' | ',' | ':') return 1 ;; + *) + # A scalar. Inside a container the next token is , or the close; + # at the top level nothing may follow. + if [[ -n "$__hu_stack" ]]; then __hu_expect=comma_or_end; else __hu_expect=end; fi + continue + ;; + esac + ;; + key_or_end | key) + case "$__hu_tok" in + \"#*) + __hu_expect='colon' + continue + ;; + '}') [[ "$__hu_expect" == key_or_end ]] || return 1 ;; + *) return 1 ;; + esac + ;; + colon) + [[ "$__hu_tok" == ':' ]] || return 1 + __hu_expect=value + continue + ;; + comma_or_end) + case "$__hu_tok" in + ',') + if [[ "$__hu_top" == '{' ]]; then __hu_expect=key; else __hu_expect=value; fi + continue + ;; + '}') [[ "$__hu_top" == '{' ]] || return 1 ;; + ']') [[ "$__hu_top" == '[' ]] || return 1 ;; + *) return 1 ;; + esac + ;; + *) return 1 ;; # `end`: a token after the root value closed + esac + # Reaching here means a container just closed. + [[ -n "$__hu_stack" ]] || return 1 + __hu_stack=${__hu_stack:0:${#__hu_stack}-1} + if [[ -n "$__hu_stack" ]]; then __hu_expect=comma_or_end; else __hu_expect=end; fi + done + [[ "$__hu_expect" == end ]] || return 1 + _HOOK_JSON_SK=${__hu_sk//[$' \t\n\r']/} + return 0 +} + +# hook::json_unescape_to +# Decode a JSON string body (the bytes between the quotes) into . Handles +# \" \\ \/ \b \f \n \r \t and \uXXXX for U+0001 to U+007F. Returns 1 on any +# other escape (a \u outside that range, U+0000 which bash cannot hold, or an +# invalid escape), leaving the caller to fall back to jq. +hook::json_unescape_to() { + local __hu_raw="$2" __hu_out="" __hu_pre __hu_c __hu_hex __hu_oct __hu_ch + while [[ "$__hu_raw" == *\\* ]]; do + __hu_pre=${__hu_raw%%\\*} + __hu_out+=$__hu_pre + __hu_raw=${__hu_raw:${#__hu_pre}+1} + __hu_c=${__hu_raw:0:1} + __hu_raw=${__hu_raw:1} + case "$__hu_c" in + '"') __hu_out+='"' ;; + "\\") __hu_out+="\\" ;; + '/') __hu_out+='/' ;; + b) __hu_out+=$'\b' ;; # portability-ok: bash ANSI-C backspace byte, not a GNU grep word boundary + f) __hu_out+=$'\f' ;; + n) __hu_out+=$'\n' ;; + r) __hu_out+=$'\r' ;; + t) __hu_out+=$'\t' ;; + u) + __hu_hex=${__hu_raw:0:4} + [[ "$__hu_hex" =~ ^00[0-7][0-9a-fA-F]$ ]] || return 1 + [[ "$__hu_hex" == 0000 ]] && return 1 + __hu_raw=${__hu_raw:4} + printf -v __hu_oct '%03o' "$((16#$__hu_hex))" + printf -v __hu_ch '%b' "\\0$__hu_oct" + __hu_out+=$__hu_ch + ;; + *) return 1 ;; + esac + done + __hu_out+=$__hu_raw + printf -v "$1" '%s' "$__hu_out" +} + +# hook::_fast_file_path_to +# The builtin answer to `jq -r '(.tool_input.file_path // empty) | +# gsub("\r";"")'` followed by the `$(...)` capture. Returns +# 0 proven: holds exactly what jq would have printed +# 1 proven absent: jq would have printed nothing (the guard skips) +# 2 not proven: run jq +# Proof, on top of hook::_json_skeleton's structural checks: the root is an +# object; exactly one string in the whole payload decodes to `tool_input` and +# exactly one to `file_path` (so neither a duplicate key nor a same-named key +# in another object can change jq's answer); `tool_input` is a key of the root +# (depth 1, key position) whose value is a flat object (no nested object or +# array, else jq); and inside it `file_path` is a key with a plain string +# value. A payload where `file_path` names a value or sits in another object +# has no `.tool_input.file_path`, which jq reports as nothing. Key strings are +# compared AFTER decoding their escapes, so a key spelled with \u escapes is +# still recognized; a body longer than any escaped spelling of either name is +# skipped without decoding. +hook::_fast_file_path_to() { + local __hu_s="$2" __hu_i __hu_n __hu_part __hu_body __hu_ti=-1 __hu_fp=-1 __hu_m __hu_raw __hu_pre __hu_re __hu_file + hook::_json_skeleton "$__hu_s" || return 2 + [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 + __hu_n=${#_HOOK_JSON_PARTS[@]} + for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + ((${#__hu_part} <= 60)) || continue + __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} + if [[ "$__hu_body" == *\\* ]]; then + hook::json_unescape_to __hu_body "$__hu_body" || continue + fi + case "$__hu_body" in + tool_input) + ((__hu_ti < 0)) || return 2 + __hu_ti=$__hu_i + ;; + file_path) + ((__hu_fp < 0)) || return 2 + __hu_fp=$__hu_i + ;; + *) ;; + esac + done + ((__hu_fp >= 0)) || return 1 + ((__hu_ti >= 0)) || return 1 + __hu_re="(^|[{,])\"#$__hu_ti\":\\{([^][{}]*)\\}" + if [[ "$_HOOK_JSON_SK" =~ $__hu_re ]]; then + __hu_body=${BASH_REMATCH[2]} + __hu_pre=${_HOOK_JSON_SK%%\"#"$__hu_ti"\"*} + # Depth of the key: opening brackets minus closing ones before it must be + # exactly one, i.e. a direct member of the root object. + local __hu_o1=${__hu_pre//\{/} __hu_o2=${__hu_pre//\[/} __hu_c1=${__hu_pre//\}/} __hu_c2=${__hu_pre//\]/} + local __hu_depth=$(((${#__hu_pre} - ${#__hu_o1}) + (${#__hu_pre} - ${#__hu_o2}) - (${#__hu_pre} - ${#__hu_c1}) - (${#__hu_pre} - ${#__hu_c2}))) + ((__hu_depth == 1)) || return 2 + else + return 2 + fi + __hu_re="(^|,)\"#$__hu_fp\":\"#([0-9]+)\"(,|$)" + if [[ "$__hu_body" =~ $__hu_re ]]; then + __hu_m=${BASH_REMATCH[2]} + elif [[ "$__hu_body" == *\"#$__hu_fp\"* ]]; then + return 2 + else + return 1 + fi + __hu_raw=${__hu_s:${_HOOK_JSON_OFF[__hu_m]}:${#_HOOK_JSON_PARTS[__hu_m]}} + if [[ "$__hu_raw" == *\\* ]]; then + hook::json_unescape_to __hu_file "$__hu_raw" || return 2 + else + __hu_file=$__hu_raw + fi + __hu_file=${__hu_file//$'\r'/} + while [[ "$__hu_file" == *$'\n' ]]; do + __hu_file=${__hu_file%$'\n'} + done + printf -v "$1" '%s' "$__hu_file" + return 0 +} + +# hook::dirname_to : dirname with builtins for the resolver's +# answer. Strips the last segment; a bare name lives in `.`, a root-level file +# in `/`. The path comes from realpath, so the trailing-slash and doubled-slash +# spellings dirname also collapses never occur here. +hook::dirname_to() { + local __hu_d="${2%/*}" + [[ "$__hu_d" == "$2" ]] && __hu_d=. + [[ -n "$__hu_d" ]] || __hu_d=/ + printf -v "$1" '%s' "$__hu_d" +} + +# Print the arguments joined by NUL bytes, no trailing NUL: the byte stream +# hook::read_file_path read from stdin, rebuilt for the jq fallback. +hook::_print_nul_joined() { + local __hu_first=1 __hu_c + for __hu_c in "$@"; do + ((__hu_first)) || printf '\0' + __hu_first=0 + printf '%s' "$__hu_c" + done +} + +# hook::json_compact_to +# The bytes `jq -c .` prints for , when that can be proven with +# builtins: JSON whitespace between tokens removed, everything else verbatim. +# jq's own output, compact or pretty, satisfies the proof, which is what every +# telemetry data builder in this marketplace produces (jq -c, jq -n, or a +# compact literal). Returns 1 when the text is not a single object, or a +# string carries something jq would re-encode (a \u or \/ escape, an escape +# outside the JSON set, a raw control byte), or the structure fails +# hook::_json_skeleton; the caller then runs jq. +hook::json_compact_to() { + local __hu_s="$2" __hu_out="" __hu_i __hu_part __hu_n + hook::_json_skeleton "$__hu_s" || return 1 + [[ "$_HOOK_JSON_SK" == \{*\} ]] || return 1 + # Numbers: only short integer literals are passed through. A fraction, an + # exponent or an integer beyond 15 digits is rendered differently by + # different jq releases (1.6 canonicalizes, 1.7+ preserves the literal), so + # the bytes cannot be proven; jq decides. In the skeleton every string is a + # `"#N"` placeholder and letters occur only in true/false/null, so a digit + # followed by `.`, `e` or `E` can only be part of a number. + local __hu_num='[0-9][.eE]|[0-9]{16}' + [[ "$_HOOK_JSON_SK" =~ $__hu_num ]] && return 1 + __hu_n=${#_HOOK_JSON_PARTS[@]} + for ((__hu_i = 0; __hu_i < __hu_n; __hu_i++)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + if ((__hu_i % 2 == 0)); then + __hu_out+=${__hu_part//[$' \t\n\r']/} + else + if [[ "$__hu_part" == *\\* ]]; then + [[ "$__hu_part" == *'\u'* || "$__hu_part" == *'\/'* ]] && return 1 + [[ "$__hu_part" == *\\[!bfnrt]* ]] && return 1 + fi + __hu_out+="\"${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}}\"" + fi + done + printf -v "$1" '%s' "$__hu_out" +} + +# hook::json_escape_jq_to / hook::json_escape_jq +# Escape exactly as jq serializes a string value (without the +# quotes): backslash and double quote, the five short forms \b \f \n \r \t, +# every other byte from 0x01 to 0x1f and 0x7f as lowercase \u00xx, and +# everything else, including non-ASCII, verbatim. This is the escaper the +# telemetry envelope needs for byte parity with the jq envelope it replaced; +# hook::json_escape above is a different tool (it DROPS the residual control +# bytes, which is right for a notice and wrong for parity) and is unchanged. +hook::json_escape_jq_to() { + local __hu_s="$2" __hu_i __hu_c __hu_oct __hu_hex + __hu_s="${__hu_s//\\/\\\\}" + __hu_s="${__hu_s//\"/\\\"}" + if [[ "$__hu_s" == *[[:cntrl:]]* ]]; then + __hu_s="${__hu_s//$'\n'/\\n}" + __hu_s="${__hu_s//$'\r'/\\r}" + __hu_s="${__hu_s//$'\t'/\\t}" + __hu_s="${__hu_s//$'\b'/\\b}" # portability-ok: bash ANSI-C backspace byte and the JSON \b escape, not a GNU grep word boundary + __hu_s="${__hu_s//$'\f'/\\f}" + for __hu_i in 1 2 3 4 5 6 7 11 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 127; do + printf -v __hu_oct '%03o' "$__hu_i" + printf -v __hu_c '%b' "\\0$__hu_oct" + [[ "$__hu_s" == *"$__hu_c"* ]] || continue + printf -v __hu_hex '%04x' "$__hu_i" + __hu_s="${__hu_s//"$__hu_c"/\\u$__hu_hex}" + done + fi + printf -v "$1" '%s' "$__hu_s" +} + +hook::json_escape_jq() { + local __hu_e + hook::json_escape_jq_to __hu_e "$1" + printf '%s' "$__hu_e" +} + # Parse file_path from PostToolUse JSON on stdin; validate existence and (when # CLAUDE_PROJECT_DIR is set) project membership. Both sides of the membership # comparison are canonicalized (symlinks resolved) first, so neither an # escaping symlink nor a project root reached via a symlinked path (e.g. # macOS /tmp) skews the verdict. Outputs the path on success. Returns 1 to skip. # FILE=$(hook::read_file_path) || exit 0 +# +# jq-free on the common shape. The payload is read into the shell and +# hook::_fast_file_path_to takes `.tool_input.file_path` with builtins when it +# can PROVE the answer jq would give (see that helper for what "prove" means); +# every other payload goes to the unchanged jq filter. The path comparison then +# resolves the file, the project root and the temp roots with one realpath +# process instead of one each (hook::_physical_prime), and remembers the +# directories for the process. Same verdict, same emitted path, fewer +# processes: on Windows Git Bash each spawn costs tens of milliseconds and this +# guard runs on every Write and Edit. hook::read_file_path() { - local file - file=$(jq -r '(.tool_input.file_path // empty) | gsub("\r";"")' 2>/dev/null) + local -a chunks=() + local chunk file="" mode=2 + # Builtin read to NUL or EOF. A NUL splits the payload into several chunks; + # the fast path only takes a single-chunk (NUL-free) payload, and the jq + # fallback is fed the chunks NUL-joined, so it sees the bytes jq used to + # read straight from stdin. + while :; do + chunk="" + if IFS= read -r -d '' chunk; then + chunks+=("$chunk") + continue + fi + chunks+=("$chunk") + break + done + if ((${#chunks[@]} == 1)); then + mode=0 + hook::_fast_file_path_to file "${chunks[0]}" || mode=$? + fi + case "$mode" in + 0) ;; # proven: `file` holds jq's answer + 1) return 1 ;; # proven absent + *) + file=$(hook::_print_nul_joined "${chunks[@]}" | jq -r '(.tool_input.file_path // empty) | gsub("\r";"")' 2>/dev/null) + ;; + esac [[ -n "$file" ]] || return 1 [[ -f "$file" ]] || return 1 if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then - local norm_file norm_project - norm_file=$(hook::normalize_path "$(hook::physical_path "$file")") - norm_project=$(hook::normalize_path "$(hook::physical_path "${CLAUDE_PROJECT_DIR}")") + local norm_file norm_project phys_file phys_project file_resolved=0 project_resolved=0 + hook::_temp_root_candidates + hook::_physical_prime "$file" "${CLAUDE_PROJECT_DIR}" ${_HOOK_TEMP_CANDS[@]+"${_HOOK_TEMP_CANDS[@]}"} + hook::_physical_cached_to phys_file "$file" && file_resolved=1 + hook::_phys_cache_forget "$file" + hook::_physical_cached_to phys_project "${CLAUDE_PROJECT_DIR}" && project_resolved=1 + hook::normalize_path_to norm_file "$phys_file" + hook::normalize_path_to norm_project "$phys_project" norm_project="${norm_project%/}" # Anchor on a path-segment boundary: accept the project root itself or a # child under it, but not a sibling whose name merely shares the prefix @@ -558,19 +1126,28 @@ hook::read_file_path() { # built by `mktemp -d`, which is how this repo's own hook suites run), so # the branch must not fire. Only a temp-tree file reached from a project # root OUTSIDE the temp tree is scratch. - if hook::under_temp_root "$norm_file" && ! hook::under_temp_root "$norm_project"; then - return 1 + local file_in_temp=0 + _HOOK_UTR_TARGET_PHYSICAL=$file_resolved + hook::under_temp_root "$norm_file" && file_in_temp=1 + _HOOK_UTR_TARGET_PHYSICAL=0 + if ((file_in_temp)); then + local project_in_temp=0 + _HOOK_UTR_TARGET_PHYSICAL=$project_resolved + hook::under_temp_root "$norm_project" && project_in_temp=1 + _HOOK_UTR_TARGET_PHYSICAL=0 + ((project_in_temp)) || return 1 fi elif command -v git >/dev/null 2>&1; then # When CLAUDE_PROJECT_DIR is unset, scope to git-working-tree membership so # scratch files outside any repository are not mutated by formatter hooks # (#1091 / #972). - local file_physical - file_physical=$(hook::physical_path "$file") + local file_physical file_dir + hook::physical_path_to file_physical "$file" || : if [[ -L "$file" && "$file_physical" == "$file" ]]; then return 1 fi - if ! hook::in_git_working_tree "$(dirname "$file_physical")"; then + hook::dirname_to file_dir "$file_physical" + if ! hook::in_git_working_tree "$file_dir"; then return 1 fi else @@ -1228,7 +1805,19 @@ hook::telemetry_enabled() { # Fire-and-forget: sink is dispatched in the background; the hook never waits # on it and its failure never affects the hook's own exit code or stdout. # Opt-in guard: HOOK_TELEMETRY_SINK unset or empty → return 0 immediately. -# Fail-open: jq absent → return 0 immediately. +# +# The envelope is assembled with shell builtins (hook::json_escape_jq_to for +# the string fields, hook::json_compact_to for the caller's data object) as +# ONE compact line, `jq -c` style: the same document, field order and +# escapes the jq filter below produces, with the data object compacted +# exactly as jq compacts its own output. That path needs no jq, no temp file +# and no subprocess. When the data object cannot be proven jq-idempotent (see +# hook::json_compact_to), the jq path runs, now with -c so both paths write +# the same bytes, and there jq's absence is still fail-open: return 0, no +# envelope. Before this the envelope was jq's default pretty-printed form +# (several lines, and CRLF line endings with a Windows jq); the sink contract +# was always "one JSON document on stdin", and a compact line is also valid +# JSONL for a sink that appends raw envelopes. # # Usage: # hook::emit_telemetry [repo_root] @@ -1251,7 +1840,6 @@ hook::telemetry_enabled() { # NEVER writes to fd1 (the hook's stdout / additionalContext channel). hook::emit_telemetry() { [[ -n "${HOOK_TELEMETRY_SINK:-}" ]] || return 0 - command -v jq >/dev/null 2>&1 || return 0 local hook_id="$1" local hook_event="$2" @@ -1272,35 +1860,59 @@ hook::emit_telemetry() { local e_s="${now%[.,]*}" e_f="${now#*[.,]}" local duration_ms=$(((e_s * 1000000 + 10#$e_f - s_s * 1000000 - 10#$s_f) / 1000)) - # True UTC timestamp (TZ= prefix overrides LC_ALL / local TZ; the Z is not a lie). - local timestamp - timestamp=$(TZ=UTC printf '%(%Y-%m-%dT%H:%M:%SZ)T' -1) - - # Build the envelope. The `data` object is written to a temp file, not passed - # as --argjson, so payloads larger than the Windows 32767-character command-line - # cap are not dropped before jq runs (#1595). /dev/stdin is not used: jq's - # --slurpfile there is not portable on Windows. Redirect jq stderr to - # /dev/null; output goes to a local variable — never to fd1. - local envelope data_file - data_file=$(mktemp) || return 0 - printf '%s' "$data_json" >"$data_file" || { - rm -f "$data_file" - return 0 - } - envelope=$(jq -n \ - --arg schema_version "1.0" \ - --arg timestamp "$timestamp" \ - --arg hook "$hook_id" \ - --arg hook_event "$hook_event" \ - --arg status "$status" \ - --argjson duration_ms "$duration_ms" \ - --slurpfile data "$data_file" \ - '{schema_version:$schema_version,timestamp:$timestamp,hook:$hook,hook_event:$hook_event,status:$status,duration_ms:$duration_ms,data:($data[0] // {})}' \ - 2>/dev/null) || { + # True UTC timestamp (TZ= prefix overrides LC_ALL / local TZ; the Z is not a + # lie). The %()T printf format is Bash 4.2+, and printf -v keeps it in the + # shell (a `$(...)` capture is a fork); an older bash, or a printf that + # binds nothing, falls back to date -u. + local timestamp="" + TZ=UTC printf -v timestamp '%(%Y-%m-%dT%H:%M:%SZ)T' -1 2>/dev/null || timestamp="" + [[ -n "$timestamp" ]] || timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null) || timestamp="" + + # Trailing whitespace after the object (a newline a caller left on the + # value) does not change what jq parses, so it does not block the builtin + # path either. + local envelope="" data="" data_trim="$data_json" + while [[ "$data_trim" == *[$' \t\r\n'] ]]; do + data_trim="${data_trim%?}" + done + if [[ "$data_trim" == \{*\} ]] && hook::json_compact_to data "$data_trim"; then + # Builtin envelope: the same document the jq filter below produces, field + # for field, with jq's string escaping and jq's compact data. + local e_timestamp e_hook e_event e_status + hook::json_escape_jq_to e_timestamp "$timestamp" + hook::json_escape_jq_to e_hook "$hook_id" + hook::json_escape_jq_to e_event "$hook_event" + hook::json_escape_jq_to e_status "$status" + envelope='{"schema_version":"1.0","timestamp":"'"$e_timestamp"'","hook":"'"$e_hook"'","hook_event":"'"$e_event"'","status":"'"$e_status"'","duration_ms":'"$duration_ms"',"data":'"$data"'}' + else + # jq path, for a data object the builtin compactor cannot prove. Fail-open: + # jq absent → return 0. The `data` object is written to a temp file, not + # passed as --argjson, so payloads larger than the Windows 32767-character + # command-line cap are not dropped before jq runs (#1595). /dev/stdin is + # not used: jq's --slurpfile there is not portable on Windows. Redirect jq + # stderr to /dev/null; output goes to a local variable — never to fd1. + command -v jq >/dev/null 2>&1 || return 0 + local data_file + data_file=$(mktemp) || return 0 + printf '%s' "$data_json" >"$data_file" || { + rm -f "$data_file" + return 0 + } + envelope=$(jq -nc \ + --arg schema_version "1.0" \ + --arg timestamp "$timestamp" \ + --arg hook "$hook_id" \ + --arg hook_event "$hook_event" \ + --arg status "$status" \ + --argjson duration_ms "$duration_ms" \ + --slurpfile data "$data_file" \ + '{schema_version:$schema_version,timestamp:$timestamp,hook:$hook,hook_event:$hook_event,status:$status,duration_ms:$duration_ms,data:($data[0] // {})}' \ + 2>/dev/null) || { + rm -f "$data_file" + return 0 + } rm -f "$data_file" - return 0 - } - rm -f "$data_file" + fi # Resolve the sink path. A relative HOOK_TELEMETRY_SINK is joined onto the # consuming repo root (portable, tracked wiring); absolute is used as-is. A From 843eb10198fa724d72a22332b9f720cbe34b2f11 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:48:59 -0400 Subject: [PATCH 3/5] docs(hook-telemetry): state that the envelope is one compact JSON line and sinks parse JSON Co-Authored-By: Claude Fable 5.1 --- docs/conventions/hook-telemetry/README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/conventions/hook-telemetry/README.md b/docs/conventions/hook-telemetry/README.md index c0c2f9b7ff..ada85f64ce 100644 --- a/docs/conventions/hook-telemetry/README.md +++ b/docs/conventions/hook-telemetry/README.md @@ -139,7 +139,10 @@ unwatched. This mirrors the standards-repo "adopt by copy" seam. ## Consuming (sink side) A repo **subscribes** by setting `HOOK_TELEMETRY_SINK` (relative, committed in `settings.json`) to an -executable that reads one envelope on stdin and maps the common fields into its own store. The sink: +executable that reads one envelope on stdin and maps the common fields into its own store. The +envelope arrives as one JSON document; since the shared library's 2026-09-02 builtin emitter it is a +single compact line (`jq -c` shape, no trailing CR), where earlier producers wrote jq's +pretty-printed form. A sink must parse the document as JSON, never by line or byte layout. The sink: - consumes only the common envelope unless it specifically handles a given `hook`'s `data`; - ignores unknown keys and treats an unrecognized `status` as a catch-all (see Forward compatibility); From a832a4b4a54fae1d11ac644bc87d19437ed5ba7c Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:04:47 -0400 Subject: [PATCH 4/5] fix(hook-utils): reject invalid string escapes before the builtin JSON paths prove anything jq rejects a text with an invalid escape (\q, \uZZZZ, a trailing backslash) anywhere in it, so the fast file_path reader must not prove a value from such a text and the compactor must not splice it. The skeleton pass now deletes every well-formed escape from each string body with literal glob substitution and falls back to jq if a backslash survives. The string cap per payload rises from 800 to 2000 so a large Edit payload with many quoted strings still qualifies. Copies re-synced. Co-Authored-By: Claude Fable 5.1 --- lib/hook-utils.sh | 21 ++++++++++++++++--- lib/hook-utils.test.sh | 13 ++++++++++++ plugins/actionlint/hooks/hook-utils.sh | 21 ++++++++++++++++--- plugins/autonomy/hooks/hook-utils.sh | 21 ++++++++++++++++--- plugins/bash-format/hooks/hook-utils.sh | 21 ++++++++++++++++--- plugins/biome-format/hooks/hook-utils.sh | 21 ++++++++++++++++--- plugins/claude-ops/hooks/hook-utils.sh | 21 ++++++++++++++++--- plugins/context-guard/hooks/hook-utils.sh | 21 ++++++++++++++++--- .../desktop-notification/hooks/hook-utils.sh | 21 ++++++++++++++++--- plugins/eol-normalizer/hooks/hook-utils.sh | 21 ++++++++++++++++--- plugins/go-format/hooks/hook-utils.sh | 21 ++++++++++++++++--- plugins/guardrails/hooks/hook-utils.sh | 21 ++++++++++++++++--- .../instruction-placement/hooks/hook-utils.sh | 21 ++++++++++++++++--- plugins/markdown-format/hooks/hook-utils.sh | 21 ++++++++++++++++--- plugins/powershell-format/hooks/hook-utils.sh | 21 ++++++++++++++++--- plugins/rate-limit-guard/hooks/hook-utils.sh | 21 ++++++++++++++++--- plugins/ruff-format/hooks/hook-utils.sh | 21 ++++++++++++++++--- plugins/source-control/hooks/hook-utils.sh | 21 ++++++++++++++++--- plugins/typos-format/hooks/hook-utils.sh | 21 ++++++++++++++++--- 19 files changed, 337 insertions(+), 54 deletions(-) diff --git a/lib/hook-utils.sh b/lib/hook-utils.sh index 18e4dd5745..f8e2a6c9f2 100644 --- a/lib/hook-utils.sh +++ b/lib/hook-utils.sh @@ -730,7 +730,7 @@ hook::_json_split() { __hu_q=${__hu_t//\"/} __hu_n=$((${#__hu_t} - ${#__hu_q})) ((__hu_n % 2 == 0)) || return 1 - ((__hu_n <= 800)) || return 1 + ((__hu_n <= 2000)) || return 1 local __hu_glob=0 [[ $- == *f* ]] || __hu_glob=1 set -f @@ -750,11 +750,12 @@ hook::_json_split() { # per-token work on it is cheap. Returns 1 unless the text is exactly one JSON # value by the JSON grammar (well-formed tokens, whitespace only between them, # objects as key:value lists, arrays as value lists, brackets nested and -# closed once) whose strings carry no raw control byte, which jq rejects. +# closed once) whose strings carry only escapes jq accepts and no raw control +# byte, which jq rejects. _HOOK_JSON_SK="" _HOOK_JSON_OFF=() hook::_json_skeleton() { - local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top + local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top __hu_esc __hu_c hook::_json_split "$__hu_s" || return 1 __hu_n=${#_HOOK_JSON_PARTS[@]} _HOOK_JSON_OFF=() @@ -765,6 +766,20 @@ hook::_json_skeleton() { else _HOOK_JSON_OFF[__hu_i]=$__hu_off [[ "$__hu_part" == *[[:cntrl:]]* ]] && return 1 + # Every escape must be one jq accepts, or jq rejects the whole text. In + # the neutralized part `\\` and `\"` are already `@@`, so a surviving + # backslash starts one of the other escapes: `\/ \b \f \n \r \t` or + # `\uXXXX`. Delete every well-formed one (literal glob substitution, C + # speed); a backslash that survives is an invalid escape. + if [[ "$__hu_part" == *\\* ]]; then + __hu_esc=${__hu_part//'\u'[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]/} + # Six literal two-byte replacements: a quoted backslash before a + # bracket expression does not survive bash's pattern quoting. + for __hu_c in / b f n r t; do + __hu_esc=${__hu_esc//"\\$__hu_c"/} + done + [[ "$__hu_esc" == *\\* ]] && return 1 + fi __hu_sk+="\"#$__hu_i\"" fi __hu_off=$((__hu_off + ${#__hu_part} + 1)) diff --git a/lib/hook-utils.test.sh b/lib/hook-utils.test.sh index cad7259c13..d844f56672 100755 --- a/lib/hook-utils.test.sh +++ b/lib/hook-utils.test.sh @@ -810,6 +810,9 @@ compact_refuses "trailing comma" '{"a":1,}' compact_refuses "bad literal" '{"a":tru}' compact_refuses "split literal" '{"a":tr ue}' compact_refuses "unterminated string" '{"a":"x}' +compact_refuses "invalid escape" '{"a":"x\qy"}' +compact_refuses "trailing backslash escape" '{"a":"x\\\"}' +compact_refuses "bad \\u hex digits" '{"a":"\uZZZZ"}' # --- Test 3b: builtin envelope is jq's compact rendering, byte for byte ------- # The sink receives exactly one line, and re-rendering it with jq -c yields the @@ -911,6 +914,16 @@ fast_is_jq "non-string value" '{"tool_input":{"file_path":123}}' fast_is_jq "non-ASCII unicode escape" '{"tool_input":{"file_path":"\u00e9.md"}}' fast_is_jq "truncated payload" '{"tool_input":{"file_path":"in.md"},"tool_response":{"filePath' fast_is_jq "array root" '[{"tool_input":{"file_path":"arr.md"}}]' +fast_is_jq "invalid escape in an unrelated string" '{"tool_input":{"file_path":"in.md"},"z":"a\qb"}' +fast_is_jq "bad \\u hex in an unrelated string" '{"tool_input":{"file_path":"in.md"},"z":"\uZZZZ"}' +# The two cases above must not be PROVEN present: jq rejects the whole text. +rc12g=0 +hook::_fast_file_path_to got12g '{"tool_input":{"file_path":"in.md"},"z":"a\qb"}' || rc12g=$? +if [[ "$rc12g" -eq 2 ]]; then + ok "fast file_path: an invalid escape anywhere in the payload falls back to jq" +else + fail "fast file_path: invalid escape elsewhere rc=$rc12g got=[${got12g:-}] (want 2)" +fi # The top-level key must never be taken: the case above proves parity with jq # (absent); this pins the verdict itself. rc12g=0 diff --git a/plugins/actionlint/hooks/hook-utils.sh b/plugins/actionlint/hooks/hook-utils.sh index 18e4dd5745..f8e2a6c9f2 100644 --- a/plugins/actionlint/hooks/hook-utils.sh +++ b/plugins/actionlint/hooks/hook-utils.sh @@ -730,7 +730,7 @@ hook::_json_split() { __hu_q=${__hu_t//\"/} __hu_n=$((${#__hu_t} - ${#__hu_q})) ((__hu_n % 2 == 0)) || return 1 - ((__hu_n <= 800)) || return 1 + ((__hu_n <= 2000)) || return 1 local __hu_glob=0 [[ $- == *f* ]] || __hu_glob=1 set -f @@ -750,11 +750,12 @@ hook::_json_split() { # per-token work on it is cheap. Returns 1 unless the text is exactly one JSON # value by the JSON grammar (well-formed tokens, whitespace only between them, # objects as key:value lists, arrays as value lists, brackets nested and -# closed once) whose strings carry no raw control byte, which jq rejects. +# closed once) whose strings carry only escapes jq accepts and no raw control +# byte, which jq rejects. _HOOK_JSON_SK="" _HOOK_JSON_OFF=() hook::_json_skeleton() { - local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top + local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top __hu_esc __hu_c hook::_json_split "$__hu_s" || return 1 __hu_n=${#_HOOK_JSON_PARTS[@]} _HOOK_JSON_OFF=() @@ -765,6 +766,20 @@ hook::_json_skeleton() { else _HOOK_JSON_OFF[__hu_i]=$__hu_off [[ "$__hu_part" == *[[:cntrl:]]* ]] && return 1 + # Every escape must be one jq accepts, or jq rejects the whole text. In + # the neutralized part `\\` and `\"` are already `@@`, so a surviving + # backslash starts one of the other escapes: `\/ \b \f \n \r \t` or + # `\uXXXX`. Delete every well-formed one (literal glob substitution, C + # speed); a backslash that survives is an invalid escape. + if [[ "$__hu_part" == *\\* ]]; then + __hu_esc=${__hu_part//'\u'[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]/} + # Six literal two-byte replacements: a quoted backslash before a + # bracket expression does not survive bash's pattern quoting. + for __hu_c in / b f n r t; do + __hu_esc=${__hu_esc//"\\$__hu_c"/} + done + [[ "$__hu_esc" == *\\* ]] && return 1 + fi __hu_sk+="\"#$__hu_i\"" fi __hu_off=$((__hu_off + ${#__hu_part} + 1)) diff --git a/plugins/autonomy/hooks/hook-utils.sh b/plugins/autonomy/hooks/hook-utils.sh index 18e4dd5745..f8e2a6c9f2 100644 --- a/plugins/autonomy/hooks/hook-utils.sh +++ b/plugins/autonomy/hooks/hook-utils.sh @@ -730,7 +730,7 @@ hook::_json_split() { __hu_q=${__hu_t//\"/} __hu_n=$((${#__hu_t} - ${#__hu_q})) ((__hu_n % 2 == 0)) || return 1 - ((__hu_n <= 800)) || return 1 + ((__hu_n <= 2000)) || return 1 local __hu_glob=0 [[ $- == *f* ]] || __hu_glob=1 set -f @@ -750,11 +750,12 @@ hook::_json_split() { # per-token work on it is cheap. Returns 1 unless the text is exactly one JSON # value by the JSON grammar (well-formed tokens, whitespace only between them, # objects as key:value lists, arrays as value lists, brackets nested and -# closed once) whose strings carry no raw control byte, which jq rejects. +# closed once) whose strings carry only escapes jq accepts and no raw control +# byte, which jq rejects. _HOOK_JSON_SK="" _HOOK_JSON_OFF=() hook::_json_skeleton() { - local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top + local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top __hu_esc __hu_c hook::_json_split "$__hu_s" || return 1 __hu_n=${#_HOOK_JSON_PARTS[@]} _HOOK_JSON_OFF=() @@ -765,6 +766,20 @@ hook::_json_skeleton() { else _HOOK_JSON_OFF[__hu_i]=$__hu_off [[ "$__hu_part" == *[[:cntrl:]]* ]] && return 1 + # Every escape must be one jq accepts, or jq rejects the whole text. In + # the neutralized part `\\` and `\"` are already `@@`, so a surviving + # backslash starts one of the other escapes: `\/ \b \f \n \r \t` or + # `\uXXXX`. Delete every well-formed one (literal glob substitution, C + # speed); a backslash that survives is an invalid escape. + if [[ "$__hu_part" == *\\* ]]; then + __hu_esc=${__hu_part//'\u'[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]/} + # Six literal two-byte replacements: a quoted backslash before a + # bracket expression does not survive bash's pattern quoting. + for __hu_c in / b f n r t; do + __hu_esc=${__hu_esc//"\\$__hu_c"/} + done + [[ "$__hu_esc" == *\\* ]] && return 1 + fi __hu_sk+="\"#$__hu_i\"" fi __hu_off=$((__hu_off + ${#__hu_part} + 1)) diff --git a/plugins/bash-format/hooks/hook-utils.sh b/plugins/bash-format/hooks/hook-utils.sh index 18e4dd5745..f8e2a6c9f2 100644 --- a/plugins/bash-format/hooks/hook-utils.sh +++ b/plugins/bash-format/hooks/hook-utils.sh @@ -730,7 +730,7 @@ hook::_json_split() { __hu_q=${__hu_t//\"/} __hu_n=$((${#__hu_t} - ${#__hu_q})) ((__hu_n % 2 == 0)) || return 1 - ((__hu_n <= 800)) || return 1 + ((__hu_n <= 2000)) || return 1 local __hu_glob=0 [[ $- == *f* ]] || __hu_glob=1 set -f @@ -750,11 +750,12 @@ hook::_json_split() { # per-token work on it is cheap. Returns 1 unless the text is exactly one JSON # value by the JSON grammar (well-formed tokens, whitespace only between them, # objects as key:value lists, arrays as value lists, brackets nested and -# closed once) whose strings carry no raw control byte, which jq rejects. +# closed once) whose strings carry only escapes jq accepts and no raw control +# byte, which jq rejects. _HOOK_JSON_SK="" _HOOK_JSON_OFF=() hook::_json_skeleton() { - local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top + local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top __hu_esc __hu_c hook::_json_split "$__hu_s" || return 1 __hu_n=${#_HOOK_JSON_PARTS[@]} _HOOK_JSON_OFF=() @@ -765,6 +766,20 @@ hook::_json_skeleton() { else _HOOK_JSON_OFF[__hu_i]=$__hu_off [[ "$__hu_part" == *[[:cntrl:]]* ]] && return 1 + # Every escape must be one jq accepts, or jq rejects the whole text. In + # the neutralized part `\\` and `\"` are already `@@`, so a surviving + # backslash starts one of the other escapes: `\/ \b \f \n \r \t` or + # `\uXXXX`. Delete every well-formed one (literal glob substitution, C + # speed); a backslash that survives is an invalid escape. + if [[ "$__hu_part" == *\\* ]]; then + __hu_esc=${__hu_part//'\u'[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]/} + # Six literal two-byte replacements: a quoted backslash before a + # bracket expression does not survive bash's pattern quoting. + for __hu_c in / b f n r t; do + __hu_esc=${__hu_esc//"\\$__hu_c"/} + done + [[ "$__hu_esc" == *\\* ]] && return 1 + fi __hu_sk+="\"#$__hu_i\"" fi __hu_off=$((__hu_off + ${#__hu_part} + 1)) diff --git a/plugins/biome-format/hooks/hook-utils.sh b/plugins/biome-format/hooks/hook-utils.sh index 18e4dd5745..f8e2a6c9f2 100644 --- a/plugins/biome-format/hooks/hook-utils.sh +++ b/plugins/biome-format/hooks/hook-utils.sh @@ -730,7 +730,7 @@ hook::_json_split() { __hu_q=${__hu_t//\"/} __hu_n=$((${#__hu_t} - ${#__hu_q})) ((__hu_n % 2 == 0)) || return 1 - ((__hu_n <= 800)) || return 1 + ((__hu_n <= 2000)) || return 1 local __hu_glob=0 [[ $- == *f* ]] || __hu_glob=1 set -f @@ -750,11 +750,12 @@ hook::_json_split() { # per-token work on it is cheap. Returns 1 unless the text is exactly one JSON # value by the JSON grammar (well-formed tokens, whitespace only between them, # objects as key:value lists, arrays as value lists, brackets nested and -# closed once) whose strings carry no raw control byte, which jq rejects. +# closed once) whose strings carry only escapes jq accepts and no raw control +# byte, which jq rejects. _HOOK_JSON_SK="" _HOOK_JSON_OFF=() hook::_json_skeleton() { - local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top + local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top __hu_esc __hu_c hook::_json_split "$__hu_s" || return 1 __hu_n=${#_HOOK_JSON_PARTS[@]} _HOOK_JSON_OFF=() @@ -765,6 +766,20 @@ hook::_json_skeleton() { else _HOOK_JSON_OFF[__hu_i]=$__hu_off [[ "$__hu_part" == *[[:cntrl:]]* ]] && return 1 + # Every escape must be one jq accepts, or jq rejects the whole text. In + # the neutralized part `\\` and `\"` are already `@@`, so a surviving + # backslash starts one of the other escapes: `\/ \b \f \n \r \t` or + # `\uXXXX`. Delete every well-formed one (literal glob substitution, C + # speed); a backslash that survives is an invalid escape. + if [[ "$__hu_part" == *\\* ]]; then + __hu_esc=${__hu_part//'\u'[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]/} + # Six literal two-byte replacements: a quoted backslash before a + # bracket expression does not survive bash's pattern quoting. + for __hu_c in / b f n r t; do + __hu_esc=${__hu_esc//"\\$__hu_c"/} + done + [[ "$__hu_esc" == *\\* ]] && return 1 + fi __hu_sk+="\"#$__hu_i\"" fi __hu_off=$((__hu_off + ${#__hu_part} + 1)) diff --git a/plugins/claude-ops/hooks/hook-utils.sh b/plugins/claude-ops/hooks/hook-utils.sh index 18e4dd5745..f8e2a6c9f2 100644 --- a/plugins/claude-ops/hooks/hook-utils.sh +++ b/plugins/claude-ops/hooks/hook-utils.sh @@ -730,7 +730,7 @@ hook::_json_split() { __hu_q=${__hu_t//\"/} __hu_n=$((${#__hu_t} - ${#__hu_q})) ((__hu_n % 2 == 0)) || return 1 - ((__hu_n <= 800)) || return 1 + ((__hu_n <= 2000)) || return 1 local __hu_glob=0 [[ $- == *f* ]] || __hu_glob=1 set -f @@ -750,11 +750,12 @@ hook::_json_split() { # per-token work on it is cheap. Returns 1 unless the text is exactly one JSON # value by the JSON grammar (well-formed tokens, whitespace only between them, # objects as key:value lists, arrays as value lists, brackets nested and -# closed once) whose strings carry no raw control byte, which jq rejects. +# closed once) whose strings carry only escapes jq accepts and no raw control +# byte, which jq rejects. _HOOK_JSON_SK="" _HOOK_JSON_OFF=() hook::_json_skeleton() { - local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top + local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top __hu_esc __hu_c hook::_json_split "$__hu_s" || return 1 __hu_n=${#_HOOK_JSON_PARTS[@]} _HOOK_JSON_OFF=() @@ -765,6 +766,20 @@ hook::_json_skeleton() { else _HOOK_JSON_OFF[__hu_i]=$__hu_off [[ "$__hu_part" == *[[:cntrl:]]* ]] && return 1 + # Every escape must be one jq accepts, or jq rejects the whole text. In + # the neutralized part `\\` and `\"` are already `@@`, so a surviving + # backslash starts one of the other escapes: `\/ \b \f \n \r \t` or + # `\uXXXX`. Delete every well-formed one (literal glob substitution, C + # speed); a backslash that survives is an invalid escape. + if [[ "$__hu_part" == *\\* ]]; then + __hu_esc=${__hu_part//'\u'[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]/} + # Six literal two-byte replacements: a quoted backslash before a + # bracket expression does not survive bash's pattern quoting. + for __hu_c in / b f n r t; do + __hu_esc=${__hu_esc//"\\$__hu_c"/} + done + [[ "$__hu_esc" == *\\* ]] && return 1 + fi __hu_sk+="\"#$__hu_i\"" fi __hu_off=$((__hu_off + ${#__hu_part} + 1)) diff --git a/plugins/context-guard/hooks/hook-utils.sh b/plugins/context-guard/hooks/hook-utils.sh index 18e4dd5745..f8e2a6c9f2 100755 --- a/plugins/context-guard/hooks/hook-utils.sh +++ b/plugins/context-guard/hooks/hook-utils.sh @@ -730,7 +730,7 @@ hook::_json_split() { __hu_q=${__hu_t//\"/} __hu_n=$((${#__hu_t} - ${#__hu_q})) ((__hu_n % 2 == 0)) || return 1 - ((__hu_n <= 800)) || return 1 + ((__hu_n <= 2000)) || return 1 local __hu_glob=0 [[ $- == *f* ]] || __hu_glob=1 set -f @@ -750,11 +750,12 @@ hook::_json_split() { # per-token work on it is cheap. Returns 1 unless the text is exactly one JSON # value by the JSON grammar (well-formed tokens, whitespace only between them, # objects as key:value lists, arrays as value lists, brackets nested and -# closed once) whose strings carry no raw control byte, which jq rejects. +# closed once) whose strings carry only escapes jq accepts and no raw control +# byte, which jq rejects. _HOOK_JSON_SK="" _HOOK_JSON_OFF=() hook::_json_skeleton() { - local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top + local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top __hu_esc __hu_c hook::_json_split "$__hu_s" || return 1 __hu_n=${#_HOOK_JSON_PARTS[@]} _HOOK_JSON_OFF=() @@ -765,6 +766,20 @@ hook::_json_skeleton() { else _HOOK_JSON_OFF[__hu_i]=$__hu_off [[ "$__hu_part" == *[[:cntrl:]]* ]] && return 1 + # Every escape must be one jq accepts, or jq rejects the whole text. In + # the neutralized part `\\` and `\"` are already `@@`, so a surviving + # backslash starts one of the other escapes: `\/ \b \f \n \r \t` or + # `\uXXXX`. Delete every well-formed one (literal glob substitution, C + # speed); a backslash that survives is an invalid escape. + if [[ "$__hu_part" == *\\* ]]; then + __hu_esc=${__hu_part//'\u'[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]/} + # Six literal two-byte replacements: a quoted backslash before a + # bracket expression does not survive bash's pattern quoting. + for __hu_c in / b f n r t; do + __hu_esc=${__hu_esc//"\\$__hu_c"/} + done + [[ "$__hu_esc" == *\\* ]] && return 1 + fi __hu_sk+="\"#$__hu_i\"" fi __hu_off=$((__hu_off + ${#__hu_part} + 1)) diff --git a/plugins/desktop-notification/hooks/hook-utils.sh b/plugins/desktop-notification/hooks/hook-utils.sh index 18e4dd5745..f8e2a6c9f2 100644 --- a/plugins/desktop-notification/hooks/hook-utils.sh +++ b/plugins/desktop-notification/hooks/hook-utils.sh @@ -730,7 +730,7 @@ hook::_json_split() { __hu_q=${__hu_t//\"/} __hu_n=$((${#__hu_t} - ${#__hu_q})) ((__hu_n % 2 == 0)) || return 1 - ((__hu_n <= 800)) || return 1 + ((__hu_n <= 2000)) || return 1 local __hu_glob=0 [[ $- == *f* ]] || __hu_glob=1 set -f @@ -750,11 +750,12 @@ hook::_json_split() { # per-token work on it is cheap. Returns 1 unless the text is exactly one JSON # value by the JSON grammar (well-formed tokens, whitespace only between them, # objects as key:value lists, arrays as value lists, brackets nested and -# closed once) whose strings carry no raw control byte, which jq rejects. +# closed once) whose strings carry only escapes jq accepts and no raw control +# byte, which jq rejects. _HOOK_JSON_SK="" _HOOK_JSON_OFF=() hook::_json_skeleton() { - local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top + local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top __hu_esc __hu_c hook::_json_split "$__hu_s" || return 1 __hu_n=${#_HOOK_JSON_PARTS[@]} _HOOK_JSON_OFF=() @@ -765,6 +766,20 @@ hook::_json_skeleton() { else _HOOK_JSON_OFF[__hu_i]=$__hu_off [[ "$__hu_part" == *[[:cntrl:]]* ]] && return 1 + # Every escape must be one jq accepts, or jq rejects the whole text. In + # the neutralized part `\\` and `\"` are already `@@`, so a surviving + # backslash starts one of the other escapes: `\/ \b \f \n \r \t` or + # `\uXXXX`. Delete every well-formed one (literal glob substitution, C + # speed); a backslash that survives is an invalid escape. + if [[ "$__hu_part" == *\\* ]]; then + __hu_esc=${__hu_part//'\u'[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]/} + # Six literal two-byte replacements: a quoted backslash before a + # bracket expression does not survive bash's pattern quoting. + for __hu_c in / b f n r t; do + __hu_esc=${__hu_esc//"\\$__hu_c"/} + done + [[ "$__hu_esc" == *\\* ]] && return 1 + fi __hu_sk+="\"#$__hu_i\"" fi __hu_off=$((__hu_off + ${#__hu_part} + 1)) diff --git a/plugins/eol-normalizer/hooks/hook-utils.sh b/plugins/eol-normalizer/hooks/hook-utils.sh index 18e4dd5745..f8e2a6c9f2 100644 --- a/plugins/eol-normalizer/hooks/hook-utils.sh +++ b/plugins/eol-normalizer/hooks/hook-utils.sh @@ -730,7 +730,7 @@ hook::_json_split() { __hu_q=${__hu_t//\"/} __hu_n=$((${#__hu_t} - ${#__hu_q})) ((__hu_n % 2 == 0)) || return 1 - ((__hu_n <= 800)) || return 1 + ((__hu_n <= 2000)) || return 1 local __hu_glob=0 [[ $- == *f* ]] || __hu_glob=1 set -f @@ -750,11 +750,12 @@ hook::_json_split() { # per-token work on it is cheap. Returns 1 unless the text is exactly one JSON # value by the JSON grammar (well-formed tokens, whitespace only between them, # objects as key:value lists, arrays as value lists, brackets nested and -# closed once) whose strings carry no raw control byte, which jq rejects. +# closed once) whose strings carry only escapes jq accepts and no raw control +# byte, which jq rejects. _HOOK_JSON_SK="" _HOOK_JSON_OFF=() hook::_json_skeleton() { - local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top + local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top __hu_esc __hu_c hook::_json_split "$__hu_s" || return 1 __hu_n=${#_HOOK_JSON_PARTS[@]} _HOOK_JSON_OFF=() @@ -765,6 +766,20 @@ hook::_json_skeleton() { else _HOOK_JSON_OFF[__hu_i]=$__hu_off [[ "$__hu_part" == *[[:cntrl:]]* ]] && return 1 + # Every escape must be one jq accepts, or jq rejects the whole text. In + # the neutralized part `\\` and `\"` are already `@@`, so a surviving + # backslash starts one of the other escapes: `\/ \b \f \n \r \t` or + # `\uXXXX`. Delete every well-formed one (literal glob substitution, C + # speed); a backslash that survives is an invalid escape. + if [[ "$__hu_part" == *\\* ]]; then + __hu_esc=${__hu_part//'\u'[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]/} + # Six literal two-byte replacements: a quoted backslash before a + # bracket expression does not survive bash's pattern quoting. + for __hu_c in / b f n r t; do + __hu_esc=${__hu_esc//"\\$__hu_c"/} + done + [[ "$__hu_esc" == *\\* ]] && return 1 + fi __hu_sk+="\"#$__hu_i\"" fi __hu_off=$((__hu_off + ${#__hu_part} + 1)) diff --git a/plugins/go-format/hooks/hook-utils.sh b/plugins/go-format/hooks/hook-utils.sh index 18e4dd5745..f8e2a6c9f2 100644 --- a/plugins/go-format/hooks/hook-utils.sh +++ b/plugins/go-format/hooks/hook-utils.sh @@ -730,7 +730,7 @@ hook::_json_split() { __hu_q=${__hu_t//\"/} __hu_n=$((${#__hu_t} - ${#__hu_q})) ((__hu_n % 2 == 0)) || return 1 - ((__hu_n <= 800)) || return 1 + ((__hu_n <= 2000)) || return 1 local __hu_glob=0 [[ $- == *f* ]] || __hu_glob=1 set -f @@ -750,11 +750,12 @@ hook::_json_split() { # per-token work on it is cheap. Returns 1 unless the text is exactly one JSON # value by the JSON grammar (well-formed tokens, whitespace only between them, # objects as key:value lists, arrays as value lists, brackets nested and -# closed once) whose strings carry no raw control byte, which jq rejects. +# closed once) whose strings carry only escapes jq accepts and no raw control +# byte, which jq rejects. _HOOK_JSON_SK="" _HOOK_JSON_OFF=() hook::_json_skeleton() { - local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top + local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top __hu_esc __hu_c hook::_json_split "$__hu_s" || return 1 __hu_n=${#_HOOK_JSON_PARTS[@]} _HOOK_JSON_OFF=() @@ -765,6 +766,20 @@ hook::_json_skeleton() { else _HOOK_JSON_OFF[__hu_i]=$__hu_off [[ "$__hu_part" == *[[:cntrl:]]* ]] && return 1 + # Every escape must be one jq accepts, or jq rejects the whole text. In + # the neutralized part `\\` and `\"` are already `@@`, so a surviving + # backslash starts one of the other escapes: `\/ \b \f \n \r \t` or + # `\uXXXX`. Delete every well-formed one (literal glob substitution, C + # speed); a backslash that survives is an invalid escape. + if [[ "$__hu_part" == *\\* ]]; then + __hu_esc=${__hu_part//'\u'[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]/} + # Six literal two-byte replacements: a quoted backslash before a + # bracket expression does not survive bash's pattern quoting. + for __hu_c in / b f n r t; do + __hu_esc=${__hu_esc//"\\$__hu_c"/} + done + [[ "$__hu_esc" == *\\* ]] && return 1 + fi __hu_sk+="\"#$__hu_i\"" fi __hu_off=$((__hu_off + ${#__hu_part} + 1)) diff --git a/plugins/guardrails/hooks/hook-utils.sh b/plugins/guardrails/hooks/hook-utils.sh index 18e4dd5745..f8e2a6c9f2 100644 --- a/plugins/guardrails/hooks/hook-utils.sh +++ b/plugins/guardrails/hooks/hook-utils.sh @@ -730,7 +730,7 @@ hook::_json_split() { __hu_q=${__hu_t//\"/} __hu_n=$((${#__hu_t} - ${#__hu_q})) ((__hu_n % 2 == 0)) || return 1 - ((__hu_n <= 800)) || return 1 + ((__hu_n <= 2000)) || return 1 local __hu_glob=0 [[ $- == *f* ]] || __hu_glob=1 set -f @@ -750,11 +750,12 @@ hook::_json_split() { # per-token work on it is cheap. Returns 1 unless the text is exactly one JSON # value by the JSON grammar (well-formed tokens, whitespace only between them, # objects as key:value lists, arrays as value lists, brackets nested and -# closed once) whose strings carry no raw control byte, which jq rejects. +# closed once) whose strings carry only escapes jq accepts and no raw control +# byte, which jq rejects. _HOOK_JSON_SK="" _HOOK_JSON_OFF=() hook::_json_skeleton() { - local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top + local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top __hu_esc __hu_c hook::_json_split "$__hu_s" || return 1 __hu_n=${#_HOOK_JSON_PARTS[@]} _HOOK_JSON_OFF=() @@ -765,6 +766,20 @@ hook::_json_skeleton() { else _HOOK_JSON_OFF[__hu_i]=$__hu_off [[ "$__hu_part" == *[[:cntrl:]]* ]] && return 1 + # Every escape must be one jq accepts, or jq rejects the whole text. In + # the neutralized part `\\` and `\"` are already `@@`, so a surviving + # backslash starts one of the other escapes: `\/ \b \f \n \r \t` or + # `\uXXXX`. Delete every well-formed one (literal glob substitution, C + # speed); a backslash that survives is an invalid escape. + if [[ "$__hu_part" == *\\* ]]; then + __hu_esc=${__hu_part//'\u'[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]/} + # Six literal two-byte replacements: a quoted backslash before a + # bracket expression does not survive bash's pattern quoting. + for __hu_c in / b f n r t; do + __hu_esc=${__hu_esc//"\\$__hu_c"/} + done + [[ "$__hu_esc" == *\\* ]] && return 1 + fi __hu_sk+="\"#$__hu_i\"" fi __hu_off=$((__hu_off + ${#__hu_part} + 1)) diff --git a/plugins/instruction-placement/hooks/hook-utils.sh b/plugins/instruction-placement/hooks/hook-utils.sh index 18e4dd5745..f8e2a6c9f2 100644 --- a/plugins/instruction-placement/hooks/hook-utils.sh +++ b/plugins/instruction-placement/hooks/hook-utils.sh @@ -730,7 +730,7 @@ hook::_json_split() { __hu_q=${__hu_t//\"/} __hu_n=$((${#__hu_t} - ${#__hu_q})) ((__hu_n % 2 == 0)) || return 1 - ((__hu_n <= 800)) || return 1 + ((__hu_n <= 2000)) || return 1 local __hu_glob=0 [[ $- == *f* ]] || __hu_glob=1 set -f @@ -750,11 +750,12 @@ hook::_json_split() { # per-token work on it is cheap. Returns 1 unless the text is exactly one JSON # value by the JSON grammar (well-formed tokens, whitespace only between them, # objects as key:value lists, arrays as value lists, brackets nested and -# closed once) whose strings carry no raw control byte, which jq rejects. +# closed once) whose strings carry only escapes jq accepts and no raw control +# byte, which jq rejects. _HOOK_JSON_SK="" _HOOK_JSON_OFF=() hook::_json_skeleton() { - local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top + local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top __hu_esc __hu_c hook::_json_split "$__hu_s" || return 1 __hu_n=${#_HOOK_JSON_PARTS[@]} _HOOK_JSON_OFF=() @@ -765,6 +766,20 @@ hook::_json_skeleton() { else _HOOK_JSON_OFF[__hu_i]=$__hu_off [[ "$__hu_part" == *[[:cntrl:]]* ]] && return 1 + # Every escape must be one jq accepts, or jq rejects the whole text. In + # the neutralized part `\\` and `\"` are already `@@`, so a surviving + # backslash starts one of the other escapes: `\/ \b \f \n \r \t` or + # `\uXXXX`. Delete every well-formed one (literal glob substitution, C + # speed); a backslash that survives is an invalid escape. + if [[ "$__hu_part" == *\\* ]]; then + __hu_esc=${__hu_part//'\u'[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]/} + # Six literal two-byte replacements: a quoted backslash before a + # bracket expression does not survive bash's pattern quoting. + for __hu_c in / b f n r t; do + __hu_esc=${__hu_esc//"\\$__hu_c"/} + done + [[ "$__hu_esc" == *\\* ]] && return 1 + fi __hu_sk+="\"#$__hu_i\"" fi __hu_off=$((__hu_off + ${#__hu_part} + 1)) diff --git a/plugins/markdown-format/hooks/hook-utils.sh b/plugins/markdown-format/hooks/hook-utils.sh index 18e4dd5745..f8e2a6c9f2 100644 --- a/plugins/markdown-format/hooks/hook-utils.sh +++ b/plugins/markdown-format/hooks/hook-utils.sh @@ -730,7 +730,7 @@ hook::_json_split() { __hu_q=${__hu_t//\"/} __hu_n=$((${#__hu_t} - ${#__hu_q})) ((__hu_n % 2 == 0)) || return 1 - ((__hu_n <= 800)) || return 1 + ((__hu_n <= 2000)) || return 1 local __hu_glob=0 [[ $- == *f* ]] || __hu_glob=1 set -f @@ -750,11 +750,12 @@ hook::_json_split() { # per-token work on it is cheap. Returns 1 unless the text is exactly one JSON # value by the JSON grammar (well-formed tokens, whitespace only between them, # objects as key:value lists, arrays as value lists, brackets nested and -# closed once) whose strings carry no raw control byte, which jq rejects. +# closed once) whose strings carry only escapes jq accepts and no raw control +# byte, which jq rejects. _HOOK_JSON_SK="" _HOOK_JSON_OFF=() hook::_json_skeleton() { - local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top + local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top __hu_esc __hu_c hook::_json_split "$__hu_s" || return 1 __hu_n=${#_HOOK_JSON_PARTS[@]} _HOOK_JSON_OFF=() @@ -765,6 +766,20 @@ hook::_json_skeleton() { else _HOOK_JSON_OFF[__hu_i]=$__hu_off [[ "$__hu_part" == *[[:cntrl:]]* ]] && return 1 + # Every escape must be one jq accepts, or jq rejects the whole text. In + # the neutralized part `\\` and `\"` are already `@@`, so a surviving + # backslash starts one of the other escapes: `\/ \b \f \n \r \t` or + # `\uXXXX`. Delete every well-formed one (literal glob substitution, C + # speed); a backslash that survives is an invalid escape. + if [[ "$__hu_part" == *\\* ]]; then + __hu_esc=${__hu_part//'\u'[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]/} + # Six literal two-byte replacements: a quoted backslash before a + # bracket expression does not survive bash's pattern quoting. + for __hu_c in / b f n r t; do + __hu_esc=${__hu_esc//"\\$__hu_c"/} + done + [[ "$__hu_esc" == *\\* ]] && return 1 + fi __hu_sk+="\"#$__hu_i\"" fi __hu_off=$((__hu_off + ${#__hu_part} + 1)) diff --git a/plugins/powershell-format/hooks/hook-utils.sh b/plugins/powershell-format/hooks/hook-utils.sh index 18e4dd5745..f8e2a6c9f2 100644 --- a/plugins/powershell-format/hooks/hook-utils.sh +++ b/plugins/powershell-format/hooks/hook-utils.sh @@ -730,7 +730,7 @@ hook::_json_split() { __hu_q=${__hu_t//\"/} __hu_n=$((${#__hu_t} - ${#__hu_q})) ((__hu_n % 2 == 0)) || return 1 - ((__hu_n <= 800)) || return 1 + ((__hu_n <= 2000)) || return 1 local __hu_glob=0 [[ $- == *f* ]] || __hu_glob=1 set -f @@ -750,11 +750,12 @@ hook::_json_split() { # per-token work on it is cheap. Returns 1 unless the text is exactly one JSON # value by the JSON grammar (well-formed tokens, whitespace only between them, # objects as key:value lists, arrays as value lists, brackets nested and -# closed once) whose strings carry no raw control byte, which jq rejects. +# closed once) whose strings carry only escapes jq accepts and no raw control +# byte, which jq rejects. _HOOK_JSON_SK="" _HOOK_JSON_OFF=() hook::_json_skeleton() { - local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top + local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top __hu_esc __hu_c hook::_json_split "$__hu_s" || return 1 __hu_n=${#_HOOK_JSON_PARTS[@]} _HOOK_JSON_OFF=() @@ -765,6 +766,20 @@ hook::_json_skeleton() { else _HOOK_JSON_OFF[__hu_i]=$__hu_off [[ "$__hu_part" == *[[:cntrl:]]* ]] && return 1 + # Every escape must be one jq accepts, or jq rejects the whole text. In + # the neutralized part `\\` and `\"` are already `@@`, so a surviving + # backslash starts one of the other escapes: `\/ \b \f \n \r \t` or + # `\uXXXX`. Delete every well-formed one (literal glob substitution, C + # speed); a backslash that survives is an invalid escape. + if [[ "$__hu_part" == *\\* ]]; then + __hu_esc=${__hu_part//'\u'[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]/} + # Six literal two-byte replacements: a quoted backslash before a + # bracket expression does not survive bash's pattern quoting. + for __hu_c in / b f n r t; do + __hu_esc=${__hu_esc//"\\$__hu_c"/} + done + [[ "$__hu_esc" == *\\* ]] && return 1 + fi __hu_sk+="\"#$__hu_i\"" fi __hu_off=$((__hu_off + ${#__hu_part} + 1)) diff --git a/plugins/rate-limit-guard/hooks/hook-utils.sh b/plugins/rate-limit-guard/hooks/hook-utils.sh index 18e4dd5745..f8e2a6c9f2 100755 --- a/plugins/rate-limit-guard/hooks/hook-utils.sh +++ b/plugins/rate-limit-guard/hooks/hook-utils.sh @@ -730,7 +730,7 @@ hook::_json_split() { __hu_q=${__hu_t//\"/} __hu_n=$((${#__hu_t} - ${#__hu_q})) ((__hu_n % 2 == 0)) || return 1 - ((__hu_n <= 800)) || return 1 + ((__hu_n <= 2000)) || return 1 local __hu_glob=0 [[ $- == *f* ]] || __hu_glob=1 set -f @@ -750,11 +750,12 @@ hook::_json_split() { # per-token work on it is cheap. Returns 1 unless the text is exactly one JSON # value by the JSON grammar (well-formed tokens, whitespace only between them, # objects as key:value lists, arrays as value lists, brackets nested and -# closed once) whose strings carry no raw control byte, which jq rejects. +# closed once) whose strings carry only escapes jq accepts and no raw control +# byte, which jq rejects. _HOOK_JSON_SK="" _HOOK_JSON_OFF=() hook::_json_skeleton() { - local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top + local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top __hu_esc __hu_c hook::_json_split "$__hu_s" || return 1 __hu_n=${#_HOOK_JSON_PARTS[@]} _HOOK_JSON_OFF=() @@ -765,6 +766,20 @@ hook::_json_skeleton() { else _HOOK_JSON_OFF[__hu_i]=$__hu_off [[ "$__hu_part" == *[[:cntrl:]]* ]] && return 1 + # Every escape must be one jq accepts, or jq rejects the whole text. In + # the neutralized part `\\` and `\"` are already `@@`, so a surviving + # backslash starts one of the other escapes: `\/ \b \f \n \r \t` or + # `\uXXXX`. Delete every well-formed one (literal glob substitution, C + # speed); a backslash that survives is an invalid escape. + if [[ "$__hu_part" == *\\* ]]; then + __hu_esc=${__hu_part//'\u'[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]/} + # Six literal two-byte replacements: a quoted backslash before a + # bracket expression does not survive bash's pattern quoting. + for __hu_c in / b f n r t; do + __hu_esc=${__hu_esc//"\\$__hu_c"/} + done + [[ "$__hu_esc" == *\\* ]] && return 1 + fi __hu_sk+="\"#$__hu_i\"" fi __hu_off=$((__hu_off + ${#__hu_part} + 1)) diff --git a/plugins/ruff-format/hooks/hook-utils.sh b/plugins/ruff-format/hooks/hook-utils.sh index 18e4dd5745..f8e2a6c9f2 100644 --- a/plugins/ruff-format/hooks/hook-utils.sh +++ b/plugins/ruff-format/hooks/hook-utils.sh @@ -730,7 +730,7 @@ hook::_json_split() { __hu_q=${__hu_t//\"/} __hu_n=$((${#__hu_t} - ${#__hu_q})) ((__hu_n % 2 == 0)) || return 1 - ((__hu_n <= 800)) || return 1 + ((__hu_n <= 2000)) || return 1 local __hu_glob=0 [[ $- == *f* ]] || __hu_glob=1 set -f @@ -750,11 +750,12 @@ hook::_json_split() { # per-token work on it is cheap. Returns 1 unless the text is exactly one JSON # value by the JSON grammar (well-formed tokens, whitespace only between them, # objects as key:value lists, arrays as value lists, brackets nested and -# closed once) whose strings carry no raw control byte, which jq rejects. +# closed once) whose strings carry only escapes jq accepts and no raw control +# byte, which jq rejects. _HOOK_JSON_SK="" _HOOK_JSON_OFF=() hook::_json_skeleton() { - local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top + local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top __hu_esc __hu_c hook::_json_split "$__hu_s" || return 1 __hu_n=${#_HOOK_JSON_PARTS[@]} _HOOK_JSON_OFF=() @@ -765,6 +766,20 @@ hook::_json_skeleton() { else _HOOK_JSON_OFF[__hu_i]=$__hu_off [[ "$__hu_part" == *[[:cntrl:]]* ]] && return 1 + # Every escape must be one jq accepts, or jq rejects the whole text. In + # the neutralized part `\\` and `\"` are already `@@`, so a surviving + # backslash starts one of the other escapes: `\/ \b \f \n \r \t` or + # `\uXXXX`. Delete every well-formed one (literal glob substitution, C + # speed); a backslash that survives is an invalid escape. + if [[ "$__hu_part" == *\\* ]]; then + __hu_esc=${__hu_part//'\u'[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]/} + # Six literal two-byte replacements: a quoted backslash before a + # bracket expression does not survive bash's pattern quoting. + for __hu_c in / b f n r t; do + __hu_esc=${__hu_esc//"\\$__hu_c"/} + done + [[ "$__hu_esc" == *\\* ]] && return 1 + fi __hu_sk+="\"#$__hu_i\"" fi __hu_off=$((__hu_off + ${#__hu_part} + 1)) diff --git a/plugins/source-control/hooks/hook-utils.sh b/plugins/source-control/hooks/hook-utils.sh index 18e4dd5745..f8e2a6c9f2 100644 --- a/plugins/source-control/hooks/hook-utils.sh +++ b/plugins/source-control/hooks/hook-utils.sh @@ -730,7 +730,7 @@ hook::_json_split() { __hu_q=${__hu_t//\"/} __hu_n=$((${#__hu_t} - ${#__hu_q})) ((__hu_n % 2 == 0)) || return 1 - ((__hu_n <= 800)) || return 1 + ((__hu_n <= 2000)) || return 1 local __hu_glob=0 [[ $- == *f* ]] || __hu_glob=1 set -f @@ -750,11 +750,12 @@ hook::_json_split() { # per-token work on it is cheap. Returns 1 unless the text is exactly one JSON # value by the JSON grammar (well-formed tokens, whitespace only between them, # objects as key:value lists, arrays as value lists, brackets nested and -# closed once) whose strings carry no raw control byte, which jq rejects. +# closed once) whose strings carry only escapes jq accepts and no raw control +# byte, which jq rejects. _HOOK_JSON_SK="" _HOOK_JSON_OFF=() hook::_json_skeleton() { - local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top + local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top __hu_esc __hu_c hook::_json_split "$__hu_s" || return 1 __hu_n=${#_HOOK_JSON_PARTS[@]} _HOOK_JSON_OFF=() @@ -765,6 +766,20 @@ hook::_json_skeleton() { else _HOOK_JSON_OFF[__hu_i]=$__hu_off [[ "$__hu_part" == *[[:cntrl:]]* ]] && return 1 + # Every escape must be one jq accepts, or jq rejects the whole text. In + # the neutralized part `\\` and `\"` are already `@@`, so a surviving + # backslash starts one of the other escapes: `\/ \b \f \n \r \t` or + # `\uXXXX`. Delete every well-formed one (literal glob substitution, C + # speed); a backslash that survives is an invalid escape. + if [[ "$__hu_part" == *\\* ]]; then + __hu_esc=${__hu_part//'\u'[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]/} + # Six literal two-byte replacements: a quoted backslash before a + # bracket expression does not survive bash's pattern quoting. + for __hu_c in / b f n r t; do + __hu_esc=${__hu_esc//"\\$__hu_c"/} + done + [[ "$__hu_esc" == *\\* ]] && return 1 + fi __hu_sk+="\"#$__hu_i\"" fi __hu_off=$((__hu_off + ${#__hu_part} + 1)) diff --git a/plugins/typos-format/hooks/hook-utils.sh b/plugins/typos-format/hooks/hook-utils.sh index 18e4dd5745..f8e2a6c9f2 100644 --- a/plugins/typos-format/hooks/hook-utils.sh +++ b/plugins/typos-format/hooks/hook-utils.sh @@ -730,7 +730,7 @@ hook::_json_split() { __hu_q=${__hu_t//\"/} __hu_n=$((${#__hu_t} - ${#__hu_q})) ((__hu_n % 2 == 0)) || return 1 - ((__hu_n <= 800)) || return 1 + ((__hu_n <= 2000)) || return 1 local __hu_glob=0 [[ $- == *f* ]] || __hu_glob=1 set -f @@ -750,11 +750,12 @@ hook::_json_split() { # per-token work on it is cheap. Returns 1 unless the text is exactly one JSON # value by the JSON grammar (well-formed tokens, whitespace only between them, # objects as key:value lists, arrays as value lists, brackets nested and -# closed once) whose strings carry no raw control byte, which jq rejects. +# closed once) whose strings carry only escapes jq accepts and no raw control +# byte, which jq rejects. _HOOK_JSON_SK="" _HOOK_JSON_OFF=() hook::_json_skeleton() { - local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top + local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top __hu_esc __hu_c hook::_json_split "$__hu_s" || return 1 __hu_n=${#_HOOK_JSON_PARTS[@]} _HOOK_JSON_OFF=() @@ -765,6 +766,20 @@ hook::_json_skeleton() { else _HOOK_JSON_OFF[__hu_i]=$__hu_off [[ "$__hu_part" == *[[:cntrl:]]* ]] && return 1 + # Every escape must be one jq accepts, or jq rejects the whole text. In + # the neutralized part `\\` and `\"` are already `@@`, so a surviving + # backslash starts one of the other escapes: `\/ \b \f \n \r \t` or + # `\uXXXX`. Delete every well-formed one (literal glob substitution, C + # speed); a backslash that survives is an invalid escape. + if [[ "$__hu_part" == *\\* ]]; then + __hu_esc=${__hu_part//'\u'[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]/} + # Six literal two-byte replacements: a quoted backslash before a + # bracket expression does not survive bash's pattern quoting. + for __hu_c in / b f n r t; do + __hu_esc=${__hu_esc//"\\$__hu_c"/} + done + [[ "$__hu_esc" == *\\* ]] && return 1 + fi __hu_sk+="\"#$__hu_i\"" fi __hu_off=$((__hu_off + ${#__hu_part} + 1)) From 67693922790b61b7abe509e551b8508578d0768e Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:20:02 -0400 Subject: [PATCH 5/5] test(hooks): read the telemetry envelope through jq and count one jq spawn on the unwired path The envelope is one compact line now, so block-windows-drive-tmp's suite parses it instead of matching pretty-printed spacing, markdown-format's unwired case expects the single stdin-probe jq (file_path is read with builtins), and the compactor's truncated-literal fixtures carry the spellchecker directive. Co-Authored-By: Claude Fable 5.1 --- lib/hook-utils.sh | 2 +- lib/hook-utils.test.sh | 4 ++-- plugins/actionlint/hooks/hook-utils.sh | 2 +- plugins/autonomy/hooks/hook-utils.sh | 2 +- plugins/bash-format/hooks/hook-utils.sh | 2 +- plugins/biome-format/hooks/hook-utils.sh | 2 +- plugins/claude-ops/hooks/hook-utils.sh | 2 +- plugins/context-guard/hooks/hook-utils.sh | 2 +- plugins/desktop-notification/hooks/hook-utils.sh | 2 +- plugins/eol-normalizer/hooks/hook-utils.sh | 2 +- plugins/go-format/hooks/hook-utils.sh | 2 +- .../hooks/block-windows-drive-tmp.test.sh | 16 ++++++++-------- plugins/guardrails/hooks/hook-utils.sh | 2 +- .../instruction-placement/hooks/hook-utils.sh | 2 +- plugins/markdown-format/hooks/hook-utils.sh | 2 +- .../hooks/markdown-format.test.sh | 10 +++++----- plugins/powershell-format/hooks/hook-utils.sh | 2 +- plugins/rate-limit-guard/hooks/hook-utils.sh | 2 +- plugins/ruff-format/hooks/hook-utils.sh | 2 +- plugins/source-control/hooks/hook-utils.sh | 2 +- plugins/typos-format/hooks/hook-utils.sh | 2 +- 21 files changed, 33 insertions(+), 33 deletions(-) diff --git a/lib/hook-utils.sh b/lib/hook-utils.sh index f8e2a6c9f2..baeda466cd 100644 --- a/lib/hook-utils.sh +++ b/lib/hook-utils.sh @@ -786,7 +786,7 @@ hook::_json_skeleton() { done # Tokenize the skeleton and run it through the JSON grammar. Each token is # consumed by one anchored regex on the (small) remainder; whitespace is - # allowed only between tokens, so `tr ue` is two bad tokens, not `true`. + # allowed only between tokens, so `tr ue` is two bad tokens, not `true`. # spellchecker:disable-line local __hu_re=$'^[ \t\n\r]*(\\{|\\}|\\[|\\]|,|:|"#[0-9]+"|-?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][-+]?[0-9]+)?|true|false|null)' local __hu_ws=$'^[ \t\n\r]*$' __hu_rest=$__hu_sk diff --git a/lib/hook-utils.test.sh b/lib/hook-utils.test.sh index d844f56672..3c758ea8c1 100755 --- a/lib/hook-utils.test.sh +++ b/lib/hook-utils.test.sh @@ -807,8 +807,8 @@ compact_refuses "escaped slash" '{"a":"x\/y"}' compact_refuses "raw control byte in a string" "$(printf '{"a":"x\001y"}')" compact_refuses "array root" '[1,2]' compact_refuses "trailing comma" '{"a":1,}' -compact_refuses "bad literal" '{"a":tru}' -compact_refuses "split literal" '{"a":tr ue}' +compact_refuses "bad literal" '{"a":tru}' # spellchecker:disable-line +compact_refuses "split literal" '{"a":tr ue}' # spellchecker:disable-line compact_refuses "unterminated string" '{"a":"x}' compact_refuses "invalid escape" '{"a":"x\qy"}' compact_refuses "trailing backslash escape" '{"a":"x\\\"}' diff --git a/plugins/actionlint/hooks/hook-utils.sh b/plugins/actionlint/hooks/hook-utils.sh index f8e2a6c9f2..baeda466cd 100644 --- a/plugins/actionlint/hooks/hook-utils.sh +++ b/plugins/actionlint/hooks/hook-utils.sh @@ -786,7 +786,7 @@ hook::_json_skeleton() { done # Tokenize the skeleton and run it through the JSON grammar. Each token is # consumed by one anchored regex on the (small) remainder; whitespace is - # allowed only between tokens, so `tr ue` is two bad tokens, not `true`. + # allowed only between tokens, so `tr ue` is two bad tokens, not `true`. # spellchecker:disable-line local __hu_re=$'^[ \t\n\r]*(\\{|\\}|\\[|\\]|,|:|"#[0-9]+"|-?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][-+]?[0-9]+)?|true|false|null)' local __hu_ws=$'^[ \t\n\r]*$' __hu_rest=$__hu_sk diff --git a/plugins/autonomy/hooks/hook-utils.sh b/plugins/autonomy/hooks/hook-utils.sh index f8e2a6c9f2..baeda466cd 100644 --- a/plugins/autonomy/hooks/hook-utils.sh +++ b/plugins/autonomy/hooks/hook-utils.sh @@ -786,7 +786,7 @@ hook::_json_skeleton() { done # Tokenize the skeleton and run it through the JSON grammar. Each token is # consumed by one anchored regex on the (small) remainder; whitespace is - # allowed only between tokens, so `tr ue` is two bad tokens, not `true`. + # allowed only between tokens, so `tr ue` is two bad tokens, not `true`. # spellchecker:disable-line local __hu_re=$'^[ \t\n\r]*(\\{|\\}|\\[|\\]|,|:|"#[0-9]+"|-?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][-+]?[0-9]+)?|true|false|null)' local __hu_ws=$'^[ \t\n\r]*$' __hu_rest=$__hu_sk diff --git a/plugins/bash-format/hooks/hook-utils.sh b/plugins/bash-format/hooks/hook-utils.sh index f8e2a6c9f2..baeda466cd 100644 --- a/plugins/bash-format/hooks/hook-utils.sh +++ b/plugins/bash-format/hooks/hook-utils.sh @@ -786,7 +786,7 @@ hook::_json_skeleton() { done # Tokenize the skeleton and run it through the JSON grammar. Each token is # consumed by one anchored regex on the (small) remainder; whitespace is - # allowed only between tokens, so `tr ue` is two bad tokens, not `true`. + # allowed only between tokens, so `tr ue` is two bad tokens, not `true`. # spellchecker:disable-line local __hu_re=$'^[ \t\n\r]*(\\{|\\}|\\[|\\]|,|:|"#[0-9]+"|-?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][-+]?[0-9]+)?|true|false|null)' local __hu_ws=$'^[ \t\n\r]*$' __hu_rest=$__hu_sk diff --git a/plugins/biome-format/hooks/hook-utils.sh b/plugins/biome-format/hooks/hook-utils.sh index f8e2a6c9f2..baeda466cd 100644 --- a/plugins/biome-format/hooks/hook-utils.sh +++ b/plugins/biome-format/hooks/hook-utils.sh @@ -786,7 +786,7 @@ hook::_json_skeleton() { done # Tokenize the skeleton and run it through the JSON grammar. Each token is # consumed by one anchored regex on the (small) remainder; whitespace is - # allowed only between tokens, so `tr ue` is two bad tokens, not `true`. + # allowed only between tokens, so `tr ue` is two bad tokens, not `true`. # spellchecker:disable-line local __hu_re=$'^[ \t\n\r]*(\\{|\\}|\\[|\\]|,|:|"#[0-9]+"|-?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][-+]?[0-9]+)?|true|false|null)' local __hu_ws=$'^[ \t\n\r]*$' __hu_rest=$__hu_sk diff --git a/plugins/claude-ops/hooks/hook-utils.sh b/plugins/claude-ops/hooks/hook-utils.sh index f8e2a6c9f2..baeda466cd 100644 --- a/plugins/claude-ops/hooks/hook-utils.sh +++ b/plugins/claude-ops/hooks/hook-utils.sh @@ -786,7 +786,7 @@ hook::_json_skeleton() { done # Tokenize the skeleton and run it through the JSON grammar. Each token is # consumed by one anchored regex on the (small) remainder; whitespace is - # allowed only between tokens, so `tr ue` is two bad tokens, not `true`. + # allowed only between tokens, so `tr ue` is two bad tokens, not `true`. # spellchecker:disable-line local __hu_re=$'^[ \t\n\r]*(\\{|\\}|\\[|\\]|,|:|"#[0-9]+"|-?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][-+]?[0-9]+)?|true|false|null)' local __hu_ws=$'^[ \t\n\r]*$' __hu_rest=$__hu_sk diff --git a/plugins/context-guard/hooks/hook-utils.sh b/plugins/context-guard/hooks/hook-utils.sh index f8e2a6c9f2..baeda466cd 100755 --- a/plugins/context-guard/hooks/hook-utils.sh +++ b/plugins/context-guard/hooks/hook-utils.sh @@ -786,7 +786,7 @@ hook::_json_skeleton() { done # Tokenize the skeleton and run it through the JSON grammar. Each token is # consumed by one anchored regex on the (small) remainder; whitespace is - # allowed only between tokens, so `tr ue` is two bad tokens, not `true`. + # allowed only between tokens, so `tr ue` is two bad tokens, not `true`. # spellchecker:disable-line local __hu_re=$'^[ \t\n\r]*(\\{|\\}|\\[|\\]|,|:|"#[0-9]+"|-?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][-+]?[0-9]+)?|true|false|null)' local __hu_ws=$'^[ \t\n\r]*$' __hu_rest=$__hu_sk diff --git a/plugins/desktop-notification/hooks/hook-utils.sh b/plugins/desktop-notification/hooks/hook-utils.sh index f8e2a6c9f2..baeda466cd 100644 --- a/plugins/desktop-notification/hooks/hook-utils.sh +++ b/plugins/desktop-notification/hooks/hook-utils.sh @@ -786,7 +786,7 @@ hook::_json_skeleton() { done # Tokenize the skeleton and run it through the JSON grammar. Each token is # consumed by one anchored regex on the (small) remainder; whitespace is - # allowed only between tokens, so `tr ue` is two bad tokens, not `true`. + # allowed only between tokens, so `tr ue` is two bad tokens, not `true`. # spellchecker:disable-line local __hu_re=$'^[ \t\n\r]*(\\{|\\}|\\[|\\]|,|:|"#[0-9]+"|-?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][-+]?[0-9]+)?|true|false|null)' local __hu_ws=$'^[ \t\n\r]*$' __hu_rest=$__hu_sk diff --git a/plugins/eol-normalizer/hooks/hook-utils.sh b/plugins/eol-normalizer/hooks/hook-utils.sh index f8e2a6c9f2..baeda466cd 100644 --- a/plugins/eol-normalizer/hooks/hook-utils.sh +++ b/plugins/eol-normalizer/hooks/hook-utils.sh @@ -786,7 +786,7 @@ hook::_json_skeleton() { done # Tokenize the skeleton and run it through the JSON grammar. Each token is # consumed by one anchored regex on the (small) remainder; whitespace is - # allowed only between tokens, so `tr ue` is two bad tokens, not `true`. + # allowed only between tokens, so `tr ue` is two bad tokens, not `true`. # spellchecker:disable-line local __hu_re=$'^[ \t\n\r]*(\\{|\\}|\\[|\\]|,|:|"#[0-9]+"|-?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][-+]?[0-9]+)?|true|false|null)' local __hu_ws=$'^[ \t\n\r]*$' __hu_rest=$__hu_sk diff --git a/plugins/go-format/hooks/hook-utils.sh b/plugins/go-format/hooks/hook-utils.sh index f8e2a6c9f2..baeda466cd 100644 --- a/plugins/go-format/hooks/hook-utils.sh +++ b/plugins/go-format/hooks/hook-utils.sh @@ -786,7 +786,7 @@ hook::_json_skeleton() { done # Tokenize the skeleton and run it through the JSON grammar. Each token is # consumed by one anchored regex on the (small) remainder; whitespace is - # allowed only between tokens, so `tr ue` is two bad tokens, not `true`. + # allowed only between tokens, so `tr ue` is two bad tokens, not `true`. # spellchecker:disable-line local __hu_re=$'^[ \t\n\r]*(\\{|\\}|\\[|\\]|,|:|"#[0-9]+"|-?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][-+]?[0-9]+)?|true|false|null)' local __hu_ws=$'^[ \t\n\r]*$' __hu_rest=$__hu_sk diff --git a/plugins/guardrails/hooks/block-windows-drive-tmp.test.sh b/plugins/guardrails/hooks/block-windows-drive-tmp.test.sh index d3cc7bca1b..e6f43520b7 100755 --- a/plugins/guardrails/hooks/block-windows-drive-tmp.test.sh +++ b/plugins/guardrails/hooks/block-windows-drive-tmp.test.sh @@ -342,9 +342,9 @@ out=$(env OSTYPE=msys HOOK_TELEMETRY_SINK="$SINK" bash "$HOOK" \ wait_for_sink "$TEL" || true if [[ -s "$TEL" ]]; then tel_body=$(cat "$TEL") - assert_contains "telemetry hook id" "$tel_body" '"hook": "block-windows-drive-tmp"' - assert_contains "telemetry blocked" "$tel_body" '"status": "blocked"' - assert_contains "telemetry form" "$tel_body" '"form": "redirect"' + assert_contains "telemetry hook id" "$(jq -r .hook <<<"$tel_body")" 'block-windows-drive-tmp' + assert_contains "telemetry blocked" "$(jq -r .status <<<"$tel_body")" 'blocked' + assert_contains "telemetry form" "$(jq -r .data.form <<<"$tel_body")" 'redirect' else # Telemetry is best-effort; an empty sink on a slow box is not a contract fail # when the block itself already asserted. Record as an explicit skip-visible. @@ -367,11 +367,11 @@ out=$(env OSTYPE=msys HOOK_TELEMETRY_SINK="$SINK_FP" bash "$HOOK" \ wait_for_sink "$TEL_FP" || true if [[ -s "$TEL_FP" ]]; then tel_fp_body=$(cat "$TEL_FP") - assert_contains "file-path telemetry hook id" "$tel_fp_body" '"hook": "block-windows-drive-tmp"' - assert_contains "file-path telemetry blocked" "$tel_fp_body" '"status": "blocked"' - assert_contains "file-path telemetry form" "$tel_fp_body" '"form": "file-path"' - assert_contains "file-path telemetry tool" "$tel_fp_body" '"tool": "Write"' - assert_contains "file-path telemetry subject" "$tel_fp_body" '"subject": "Write"' + assert_contains "file-path telemetry hook id" "$(jq -r .hook <<<"$tel_fp_body")" 'block-windows-drive-tmp' + assert_contains "file-path telemetry blocked" "$(jq -r .status <<<"$tel_fp_body")" 'blocked' + assert_contains "file-path telemetry form" "$(jq -r .data.form <<<"$tel_fp_body")" 'file-path' + assert_contains "file-path telemetry tool" "$(jq -r .data.tool <<<"$tel_fp_body")" 'Write' + assert_contains "file-path telemetry subject" "$(jq -r .data.subject <<<"$tel_fp_body")" 'Write' assert_absent "file-path telemetry carries no path" "$tel_fp_body" "rSFIkHm5DO" else ok "file-path telemetry sink empty (best-effort; block path already covered)" diff --git a/plugins/guardrails/hooks/hook-utils.sh b/plugins/guardrails/hooks/hook-utils.sh index f8e2a6c9f2..baeda466cd 100644 --- a/plugins/guardrails/hooks/hook-utils.sh +++ b/plugins/guardrails/hooks/hook-utils.sh @@ -786,7 +786,7 @@ hook::_json_skeleton() { done # Tokenize the skeleton and run it through the JSON grammar. Each token is # consumed by one anchored regex on the (small) remainder; whitespace is - # allowed only between tokens, so `tr ue` is two bad tokens, not `true`. + # allowed only between tokens, so `tr ue` is two bad tokens, not `true`. # spellchecker:disable-line local __hu_re=$'^[ \t\n\r]*(\\{|\\}|\\[|\\]|,|:|"#[0-9]+"|-?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][-+]?[0-9]+)?|true|false|null)' local __hu_ws=$'^[ \t\n\r]*$' __hu_rest=$__hu_sk diff --git a/plugins/instruction-placement/hooks/hook-utils.sh b/plugins/instruction-placement/hooks/hook-utils.sh index f8e2a6c9f2..baeda466cd 100644 --- a/plugins/instruction-placement/hooks/hook-utils.sh +++ b/plugins/instruction-placement/hooks/hook-utils.sh @@ -786,7 +786,7 @@ hook::_json_skeleton() { done # Tokenize the skeleton and run it through the JSON grammar. Each token is # consumed by one anchored regex on the (small) remainder; whitespace is - # allowed only between tokens, so `tr ue` is two bad tokens, not `true`. + # allowed only between tokens, so `tr ue` is two bad tokens, not `true`. # spellchecker:disable-line local __hu_re=$'^[ \t\n\r]*(\\{|\\}|\\[|\\]|,|:|"#[0-9]+"|-?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][-+]?[0-9]+)?|true|false|null)' local __hu_ws=$'^[ \t\n\r]*$' __hu_rest=$__hu_sk diff --git a/plugins/markdown-format/hooks/hook-utils.sh b/plugins/markdown-format/hooks/hook-utils.sh index f8e2a6c9f2..baeda466cd 100644 --- a/plugins/markdown-format/hooks/hook-utils.sh +++ b/plugins/markdown-format/hooks/hook-utils.sh @@ -786,7 +786,7 @@ hook::_json_skeleton() { done # Tokenize the skeleton and run it through the JSON grammar. Each token is # consumed by one anchored regex on the (small) remainder; whitespace is - # allowed only between tokens, so `tr ue` is two bad tokens, not `true`. + # allowed only between tokens, so `tr ue` is two bad tokens, not `true`. # spellchecker:disable-line local __hu_re=$'^[ \t\n\r]*(\\{|\\}|\\[|\\]|,|:|"#[0-9]+"|-?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][-+]?[0-9]+)?|true|false|null)' local __hu_ws=$'^[ \t\n\r]*$' __hu_rest=$__hu_sk diff --git a/plugins/markdown-format/hooks/markdown-format.test.sh b/plugins/markdown-format/hooks/markdown-format.test.sh index 07bca9d4d8..9912d52d0e 100755 --- a/plugins/markdown-format/hooks/markdown-format.test.sh +++ b/plugins/markdown-format/hooks/markdown-format.test.sh @@ -2122,10 +2122,10 @@ chmod +x "$SHIM_DIR/cygpath" "$SHIM_DIR/jq" count_lm() { grep -c -- '-lm' "$CYG_LOG" 2>/dev/null || true; } -# Unwired (sink unset), clean fixture: the legitimate jq spawns are +# Unwired (sink unset), clean fixture: the one legitimate jq spawn is # hook::buffer_stdin's payload-completeness probe (`jq -e .` — a piped read # always ends read -d '' with a non-zero status at EOF, so the probe runs on -# every invocation) and hook::read_file_path's file_path parse — TOOL, FILE_REL +# every invocation); hook::read_file_path reads file_path with builtins. TOOL, FILE_REL # and data_json are all telemetry-only and must not be built. : >"$CYG_LOG" : >"$JQ_LOG" @@ -2145,10 +2145,10 @@ else fail "telemetry-gate/unwired: $CYG_LM_UNWIRED cygpath -lm spawns: $(cat "$CYG_LOG")" fi JQ_UNWIRED="$(wc -l <"$JQ_LOG")" -if [[ "$JQ_UNWIRED" -eq 2 ]]; then - ok "telemetry-gate/unwired: exactly 2 jq spawns (stdin probe + file_path parse)" +if [[ "$JQ_UNWIRED" -eq 1 ]]; then + ok "telemetry-gate/unwired: exactly 1 jq spawn (stdin probe; file_path is read with builtins)" else - fail "telemetry-gate/unwired: expected 2 jq spawns, got $JQ_UNWIRED: $(cat "$JQ_LOG")" + fail "telemetry-gate/unwired: expected 1 jq spawn, got $JQ_UNWIRED: $(cat "$JQ_LOG")" fi # Wired (stub sink), same fixture shape: the payload construction must still diff --git a/plugins/powershell-format/hooks/hook-utils.sh b/plugins/powershell-format/hooks/hook-utils.sh index f8e2a6c9f2..baeda466cd 100644 --- a/plugins/powershell-format/hooks/hook-utils.sh +++ b/plugins/powershell-format/hooks/hook-utils.sh @@ -786,7 +786,7 @@ hook::_json_skeleton() { done # Tokenize the skeleton and run it through the JSON grammar. Each token is # consumed by one anchored regex on the (small) remainder; whitespace is - # allowed only between tokens, so `tr ue` is two bad tokens, not `true`. + # allowed only between tokens, so `tr ue` is two bad tokens, not `true`. # spellchecker:disable-line local __hu_re=$'^[ \t\n\r]*(\\{|\\}|\\[|\\]|,|:|"#[0-9]+"|-?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][-+]?[0-9]+)?|true|false|null)' local __hu_ws=$'^[ \t\n\r]*$' __hu_rest=$__hu_sk diff --git a/plugins/rate-limit-guard/hooks/hook-utils.sh b/plugins/rate-limit-guard/hooks/hook-utils.sh index f8e2a6c9f2..baeda466cd 100755 --- a/plugins/rate-limit-guard/hooks/hook-utils.sh +++ b/plugins/rate-limit-guard/hooks/hook-utils.sh @@ -786,7 +786,7 @@ hook::_json_skeleton() { done # Tokenize the skeleton and run it through the JSON grammar. Each token is # consumed by one anchored regex on the (small) remainder; whitespace is - # allowed only between tokens, so `tr ue` is two bad tokens, not `true`. + # allowed only between tokens, so `tr ue` is two bad tokens, not `true`. # spellchecker:disable-line local __hu_re=$'^[ \t\n\r]*(\\{|\\}|\\[|\\]|,|:|"#[0-9]+"|-?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][-+]?[0-9]+)?|true|false|null)' local __hu_ws=$'^[ \t\n\r]*$' __hu_rest=$__hu_sk diff --git a/plugins/ruff-format/hooks/hook-utils.sh b/plugins/ruff-format/hooks/hook-utils.sh index f8e2a6c9f2..baeda466cd 100644 --- a/plugins/ruff-format/hooks/hook-utils.sh +++ b/plugins/ruff-format/hooks/hook-utils.sh @@ -786,7 +786,7 @@ hook::_json_skeleton() { done # Tokenize the skeleton and run it through the JSON grammar. Each token is # consumed by one anchored regex on the (small) remainder; whitespace is - # allowed only between tokens, so `tr ue` is two bad tokens, not `true`. + # allowed only between tokens, so `tr ue` is two bad tokens, not `true`. # spellchecker:disable-line local __hu_re=$'^[ \t\n\r]*(\\{|\\}|\\[|\\]|,|:|"#[0-9]+"|-?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][-+]?[0-9]+)?|true|false|null)' local __hu_ws=$'^[ \t\n\r]*$' __hu_rest=$__hu_sk diff --git a/plugins/source-control/hooks/hook-utils.sh b/plugins/source-control/hooks/hook-utils.sh index f8e2a6c9f2..baeda466cd 100644 --- a/plugins/source-control/hooks/hook-utils.sh +++ b/plugins/source-control/hooks/hook-utils.sh @@ -786,7 +786,7 @@ hook::_json_skeleton() { done # Tokenize the skeleton and run it through the JSON grammar. Each token is # consumed by one anchored regex on the (small) remainder; whitespace is - # allowed only between tokens, so `tr ue` is two bad tokens, not `true`. + # allowed only between tokens, so `tr ue` is two bad tokens, not `true`. # spellchecker:disable-line local __hu_re=$'^[ \t\n\r]*(\\{|\\}|\\[|\\]|,|:|"#[0-9]+"|-?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][-+]?[0-9]+)?|true|false|null)' local __hu_ws=$'^[ \t\n\r]*$' __hu_rest=$__hu_sk diff --git a/plugins/typos-format/hooks/hook-utils.sh b/plugins/typos-format/hooks/hook-utils.sh index f8e2a6c9f2..baeda466cd 100644 --- a/plugins/typos-format/hooks/hook-utils.sh +++ b/plugins/typos-format/hooks/hook-utils.sh @@ -786,7 +786,7 @@ hook::_json_skeleton() { done # Tokenize the skeleton and run it through the JSON grammar. Each token is # consumed by one anchored regex on the (small) remainder; whitespace is - # allowed only between tokens, so `tr ue` is two bad tokens, not `true`. + # allowed only between tokens, so `tr ue` is two bad tokens, not `true`. # spellchecker:disable-line local __hu_re=$'^[ \t\n\r]*(\\{|\\}|\\[|\\]|,|:|"#[0-9]+"|-?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][-+]?[0-9]+)?|true|false|null)' local __hu_ws=$'^[ \t\n\r]*$' __hu_rest=$__hu_sk