diff --git a/lib/hook-utils.sh b/lib/hook-utils.sh index 3f218ce00..1c003435a 100644 --- a/lib/hook-utils.sh +++ b/lib/hook-utils.sh @@ -132,6 +132,16 @@ hook::ctx_flush() { hook::ctx_reset } +# Cheap telemetry opt-in probe — true iff a consumer wired a sink. Producers +# gate telemetry-payload construction on this (repo-relative path +# normalization, data JSON) so the unwired default path spawns zero +# telemetry-only subprocesses. Pure shell test, no subprocess. +# hook::emit_telemetry re-checks the sink itself, so skipping this probe +# costs only wasted payload work, never correctness. +hook::telemetry_enabled() { + [[ -n "${HOOK_TELEMETRY_SINK:-}" ]] +} + # Emit one telemetry envelope per hook run to the consumer-set sink. # 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. diff --git a/lib/hook-utils.test.sh b/lib/hook-utils.test.sh index d9bbe811e..fda1518ee 100755 --- a/lib/hook-utils.test.sh +++ b/lib/hook-utils.test.sh @@ -434,6 +434,32 @@ fi rm -rf "$PROJ12" "$OUTSIDE12" "$SIB12" +# --- Test 13: hook::telemetry_enabled — cheap sink-presence probe ------------- +# Producers gate telemetry-payload construction on this, so its verdict must +# track HOOK_TELEMETRY_SINK exactly: unset and empty are disabled, any +# non-empty value is enabled. +unset HOOK_TELEMETRY_SINK 2>/dev/null || true +if hook::telemetry_enabled; then + fail "telemetry_enabled: sink unset reported enabled" +else + ok "telemetry_enabled: sink unset → disabled" +fi + +HOOK_TELEMETRY_SINK="" +if hook::telemetry_enabled; then + fail "telemetry_enabled: empty sink reported enabled" +else + ok "telemetry_enabled: empty sink → disabled" +fi + +HOOK_TELEMETRY_SINK="/some/sink.sh" +if hook::telemetry_enabled; then + ok "telemetry_enabled: non-empty sink → enabled" +else + fail "telemetry_enabled: non-empty sink reported disabled" +fi +unset HOOK_TELEMETRY_SINK + echo echo "PASS=$PASS FAIL=$FAIL" [[ $FAIL -eq 0 ]] diff --git a/plugins/bash-lint/.claude-plugin/plugin.json b/plugins/bash-lint/.claude-plugin/plugin.json index 8fc6740ea..66cf18fce 100644 --- a/plugins/bash-lint/.claude-plugin/plugin.json +++ b/plugins/bash-lint/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "bash-lint", - "version": "0.1.3", + "version": "0.1.4", "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-lint/hooks/hook-utils.sh b/plugins/bash-lint/hooks/hook-utils.sh index 3f218ce00..1c003435a 100644 --- a/plugins/bash-lint/hooks/hook-utils.sh +++ b/plugins/bash-lint/hooks/hook-utils.sh @@ -132,6 +132,16 @@ hook::ctx_flush() { hook::ctx_reset } +# Cheap telemetry opt-in probe — true iff a consumer wired a sink. Producers +# gate telemetry-payload construction on this (repo-relative path +# normalization, data JSON) so the unwired default path spawns zero +# telemetry-only subprocesses. Pure shell test, no subprocess. +# hook::emit_telemetry re-checks the sink itself, so skipping this probe +# costs only wasted payload work, never correctness. +hook::telemetry_enabled() { + [[ -n "${HOOK_TELEMETRY_SINK:-}" ]] +} + # Emit one telemetry envelope per hook run to the consumer-set sink. # 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. diff --git a/plugins/biome-format/.claude-plugin/plugin.json b/plugins/biome-format/.claude-plugin/plugin.json index cf33e54f5..f0df26423 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.1.2", + "version": "0.1.3", "description": "Auto-format and lint JS/TS/JSX/JSON on edit via Biome, only when a biome.json governs the repo — using the consuming repo's own Biome config.", "author": { "name": "Melodic Software", diff --git a/plugins/biome-format/hooks/hook-utils.sh b/plugins/biome-format/hooks/hook-utils.sh index 3f218ce00..1c003435a 100644 --- a/plugins/biome-format/hooks/hook-utils.sh +++ b/plugins/biome-format/hooks/hook-utils.sh @@ -132,6 +132,16 @@ hook::ctx_flush() { hook::ctx_reset } +# Cheap telemetry opt-in probe — true iff a consumer wired a sink. Producers +# gate telemetry-payload construction on this (repo-relative path +# normalization, data JSON) so the unwired default path spawns zero +# telemetry-only subprocesses. Pure shell test, no subprocess. +# hook::emit_telemetry re-checks the sink itself, so skipping this probe +# costs only wasted payload work, never correctness. +hook::telemetry_enabled() { + [[ -n "${HOOK_TELEMETRY_SINK:-}" ]] +} + # Emit one telemetry envelope per hook run to the consumer-set sink. # 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. diff --git a/plugins/markdown-formatter/.claude-plugin/plugin.json b/plugins/markdown-formatter/.claude-plugin/plugin.json index 39710b639..8af5daf76 100644 --- a/plugins/markdown-formatter/.claude-plugin/plugin.json +++ b/plugins/markdown-formatter/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "markdown-formatter", - "version": "0.1.4", + "version": "0.1.5", "description": "Auto-format and lint Markdown on edit via markdownlint-cli2, using the consuming repo's own markdownlint config.", "author": { "name": "Melodic Software", diff --git a/plugins/markdown-formatter/hooks/hook-utils.sh b/plugins/markdown-formatter/hooks/hook-utils.sh index 3f218ce00..1c003435a 100644 --- a/plugins/markdown-formatter/hooks/hook-utils.sh +++ b/plugins/markdown-formatter/hooks/hook-utils.sh @@ -132,6 +132,16 @@ hook::ctx_flush() { hook::ctx_reset } +# Cheap telemetry opt-in probe — true iff a consumer wired a sink. Producers +# gate telemetry-payload construction on this (repo-relative path +# normalization, data JSON) so the unwired default path spawns zero +# telemetry-only subprocesses. Pure shell test, no subprocess. +# hook::emit_telemetry re-checks the sink itself, so skipping this probe +# costs only wasted payload work, never correctness. +hook::telemetry_enabled() { + [[ -n "${HOOK_TELEMETRY_SINK:-}" ]] +} + # Emit one telemetry envelope per hook run to the consumer-set sink. # 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. diff --git a/plugins/markdown-formatter/hooks/markdown-format.sh b/plugins/markdown-formatter/hooks/markdown-format.sh index 02f366703..ffcc3c35f 100755 --- a/plugins/markdown-formatter/hooks/markdown-format.sh +++ b/plugins/markdown-formatter/hooks/markdown-format.sh @@ -24,12 +24,15 @@ hook::check_enabled "MARKDOWN_FORMAT" # under `set -u` would abort before the advisory exit 0, failing every edit. start=${EPOCHREALTIME:-} -# Telemetry needs the high-res start stamp. When EPOCHREALTIME is unavailable -# (Bash < 5.0) the stamp is empty and telemetry is skipped, so the hook still -# formats on older bash rather than aborting. +# Emit this run's telemetry envelope: $1 status, $2 findings JSON array. +# Two guards: the high-res start stamp (EPOCHREALTIME is Bash 5.0+; on older +# bash it is empty and telemetry is skipped, so the hook still formats rather +# than aborting) and the sink opt-in. The data payload costs a jq subprocess, +# so it is built here after both guards — never on the unwired path. emit_tel() { [[ -n "$start" ]] || return 0 - hook::emit_telemetry "$@" + hook::telemetry_enabled || return 0 + hook::emit_telemetry "markdown-format" "PostToolUse" "$1" "$start" "$(build_data_json "$2")" "$REPO_ROOT" } INPUT=$(cat) @@ -40,26 +43,35 @@ case "$FILE" in *) exit 0 ;; esac -TOOL=$(printf '%s' "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null) - # Resolve repo root early — needed for CWD-anchored config discovery and for # computing the schema-required repo-relative path in data.file. REPO_ROOT="$(hook::repo_root "$(dirname "$FILE")")" -# Repo-relative path: schema requires "relative to the consuming repo root". -# On Windows Git Bash, git rev-parse --show-toplevel returns a drive-letter path -# while FILE may be in POSIX mount form. Normalize both through cygpath -lm -# (long name, forward-slash mixed form) when available so the prefix strip -# compares the same representation. On Linux/macOS, cygpath is absent and both -# paths are already POSIX. Falls back to raw FILE on any normalization error. + +# Telemetry-payload precursors — TOOL and FILE_REL feed only the envelope's +# data object, so both are built only when a sink is wired: the unwired +# default path spawns zero telemetry-only subprocesses (the tool_name jq +# parse, and 2× cygpath on Windows). +# +# FILE_REL is the repo-relative path: schema requires "relative to the +# consuming repo root". On Windows Git Bash, git rev-parse --show-toplevel +# returns a drive-letter path while FILE may be in POSIX mount form. Normalize +# both through cygpath -lm (long name, forward-slash mixed form) when +# available so the prefix strip compares the same representation. On +# Linux/macOS, cygpath is absent and both paths are already POSIX. Falls back +# to raw FILE on any normalization error. +TOOL="" FILE_REL="$FILE" -if command -v cygpath >/dev/null 2>&1; then - _file_lm=$(cygpath -lm "$FILE" 2>/dev/null) - _root_lm=$(cygpath -lm "$REPO_ROOT" 2>/dev/null) - if [[ -n "$_file_lm" && -n "$_root_lm" ]]; then - FILE_REL="${_file_lm#"$_root_lm"/}" +if hook::telemetry_enabled; then + TOOL=$(printf '%s' "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null) + if command -v cygpath >/dev/null 2>&1; then + _file_lm=$(cygpath -lm "$FILE" 2>/dev/null) + _root_lm=$(cygpath -lm "$REPO_ROOT" 2>/dev/null) + if [[ -n "$_file_lm" && -n "$_root_lm" ]]; then + FILE_REL="${_file_lm#"$_root_lm"/}" + fi + else + FILE_REL="${FILE#"$REPO_ROOT"/}" fi -else - FILE_REL="${FILE#"$REPO_ROOT"/}" fi # Build the telemetry data object for the current TOOL/FILE_REL. $1 is the @@ -85,15 +97,13 @@ elif command -v npx >/dev/null 2>&1; then MDLINT=(npx markdownlint-cli2) else # markdownlint unavailable — emit skipped telemetry then exit cleanly. - data_json=$(build_data_json '[]') - emit_tel "markdown-format" "PostToolUse" "skipped" "$start" "$data_json" "$REPO_ROOT" + emit_tel "skipped" '[]' exit 0 fi if FIX_OUTPUT=$(cd "$REPO_ROOT" && "${MDLINT[@]}" --fix "$FILE" 2>&1); then # Clean after fix — emit ok with empty findings. - data_json=$(build_data_json '[]') - emit_tel "markdown-format" "PostToolUse" "ok" "$start" "$data_json" "$REPO_ROOT" + emit_tel "ok" '[]' exit 0 fi @@ -120,6 +130,5 @@ if [[ -n "$findings_raw" ]]; then FINDINGS_JSON=$(printf '%s' "$findings_raw" | jq -R . | jq -s . 2>/dev/null) || FINDINGS_JSON='[]' fi -data_json=$(build_data_json "$FINDINGS_JSON") -emit_tel "markdown-format" "PostToolUse" "ok" "$start" "$data_json" "$REPO_ROOT" +emit_tel "ok" "$FINDINGS_JSON" exit 0 diff --git a/plugins/markdown-formatter/hooks/markdown-format.test.sh b/plugins/markdown-formatter/hooks/markdown-format.test.sh index b4c3fa3fa..592bc8af0 100755 --- a/plugins/markdown-formatter/hooks/markdown-format.test.sh +++ b/plugins/markdown-formatter/hooks/markdown-format.test.sh @@ -379,6 +379,92 @@ else fi rm -f "$TEL_LEAK" +# --- Unwired producer runs zero telemetry-only subprocesses ------------------- +# The telemetry payload (tool_name jq parse, cygpath path normalization, data +# JSON build) must be gated on sink presence. Count the hook's subprocess +# spawns via PATH shims: a cygpath shim that logs and echoes its last argument +# unchanged (a plausible `-lm` result on any host, so the Windows branch is +# exercised even on Linux), and a jq shim that logs then delegates to the real +# jq so hook behavior is unaffected. cygpath assertions filter on the hook's +# `-lm` flag: on Windows, npm/npx launcher shims may call `cygpath -w` on +# their own, which is not the hook's doing. +SHIM_DIR="$WORK/shims" +mkdir -p "$SHIM_DIR" +CYG_LOG="$WORK/cygpath.log" +JQ_LOG="$WORK/jq.log" +REAL_JQ="$(command -v jq)" +cat >"$SHIM_DIR/cygpath" <>"$CYG_LOG" +printf '%s\n' "\${!#}" +EOF +cat >"$SHIM_DIR/jq" <>"$JQ_LOG" +exec "$REAL_JQ" "\$@" +EOF +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 only legitimate jq spawn is +# hook::read_file_path's file_path parse — TOOL, FILE_REL and data_json are +# all telemetry-only and must not be built. +: >"$CYG_LOG" +: >"$JQ_LOG" +printf '# Gate Doc\n\nClean text.\n' >"$REPO/fixtureGate.md" +OUT_GATE="$(cd "$UNRELATED" && printf '{"tool_input":{"file_path":"%s"},"tool_name":"Write"}' "$REPO/fixtureGate.md" \ + | env -u CLAUDE_PROJECT_DIR -u HOOK_TELEMETRY_SINK HOOK_MARKDOWN_FORMAT_ENABLED=true PATH="$SHIM_DIR:$PATH" bash "$HOOK")" +RC_GATE=$? +if [[ $RC_GATE -eq 0 && -z "$OUT_GATE" ]]; then + ok "telemetry-gate/unwired: exit 0, empty stdout" +else + fail "telemetry-gate/unwired: rc=$RC_GATE out=$OUT_GATE" +fi +CYG_LM_UNWIRED="$(count_lm)" +if [[ "$CYG_LM_UNWIRED" -eq 0 ]]; then + ok "telemetry-gate/unwired: zero cygpath -lm spawns" +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 1 ]]; then + ok "telemetry-gate/unwired: exactly 1 jq spawn (file_path parse only)" +else + 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 +# run — positive control proving the shims observe the telemetry spawns (the +# unwired zeroes above would otherwise pass vacuously). +: >"$CYG_LOG" +: >"$JQ_LOG" +TEL_GATE="$(mktemp)" +GATE_SINK="$(make_sink "cat >\"$TEL_GATE\"")" +printf '# Gate Doc Wired\n\nClean text.\n' >"$REPO/fixtureGateWired.md" +# shellcheck disable=SC2034 # stdout captured for timing correctness; content checked via TEL_GATE +_OUT_GW="$(cd "$UNRELATED" && printf '{"tool_input":{"file_path":"%s"},"tool_name":"Write"}' "$REPO/fixtureGateWired.md" \ + | env -u CLAUDE_PROJECT_DIR HOOK_MARKDOWN_FORMAT_ENABLED=true HOOK_TELEMETRY_SINK="$GATE_SINK" PATH="$SHIM_DIR:$PATH" bash "$HOOK")" +wait_for_sink "$TEL_GATE" +CYG_LM_WIRED="$(count_lm)" +if [[ "$CYG_LM_WIRED" -eq 2 ]]; then + ok "telemetry-gate/wired: 2 cygpath -lm spawns (FILE + REPO_ROOT normalization)" +else + fail "telemetry-gate/wired: expected 2 cygpath -lm spawns, got $CYG_LM_WIRED: $(cat "$CYG_LOG")" +fi +JQ_WIRED="$(wc -l <"$JQ_LOG")" +if [[ "$JQ_WIRED" -gt 1 ]]; then + ok "telemetry-gate/wired: payload jq spawns present ($JQ_WIRED total)" +else + fail "telemetry-gate/wired: expected >1 jq spawns, got $JQ_WIRED: $(cat "$JQ_LOG")" +fi +if [[ -s "$TEL_GATE" ]] && jq -e '.data.tool == "Write"' "$TEL_GATE" >/dev/null 2>&1; then + ok "telemetry-gate/wired: envelope delivered with data.tool intact" +else + fail "telemetry-gate/wired: envelope missing or data.tool wrong: $(cat "$TEL_GATE" 2>/dev/null)" +fi +rm -f "$TEL_GATE" + echo echo "PASS=$PASS FAIL=$FAIL" [[ $FAIL -eq 0 ]] diff --git a/plugins/ruff-format/.claude-plugin/plugin.json b/plugins/ruff-format/.claude-plugin/plugin.json index 42dc94af6..eb6af8e55 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.1.2", + "version": "0.1.3", "description": "Auto-format and lint Python on edit via Ruff, only when a Ruff config governs the repo — using the consuming repo's own Ruff config.", "author": { "name": "Melodic Software", diff --git a/plugins/ruff-format/hooks/hook-utils.sh b/plugins/ruff-format/hooks/hook-utils.sh index 3f218ce00..1c003435a 100644 --- a/plugins/ruff-format/hooks/hook-utils.sh +++ b/plugins/ruff-format/hooks/hook-utils.sh @@ -132,6 +132,16 @@ hook::ctx_flush() { hook::ctx_reset } +# Cheap telemetry opt-in probe — true iff a consumer wired a sink. Producers +# gate telemetry-payload construction on this (repo-relative path +# normalization, data JSON) so the unwired default path spawns zero +# telemetry-only subprocesses. Pure shell test, no subprocess. +# hook::emit_telemetry re-checks the sink itself, so skipping this probe +# costs only wasted payload work, never correctness. +hook::telemetry_enabled() { + [[ -n "${HOOK_TELEMETRY_SINK:-}" ]] +} + # Emit one telemetry envelope per hook run to the consumer-set sink. # 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.