Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions lib/hook-utils.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
26 changes: 26 additions & 0 deletions lib/hook-utils.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 ]]
2 changes: 1 addition & 1 deletion plugins/bash-lint/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
10 changes: 10 additions & 0 deletions plugins/bash-lint/hooks/hook-utils.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion plugins/biome-format/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
10 changes: 10 additions & 0 deletions plugins/biome-format/hooks/hook-utils.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion plugins/markdown-formatter/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
10 changes: 10 additions & 0 deletions plugins/markdown-formatter/hooks/hook-utils.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
59 changes: 34 additions & 25 deletions plugins/markdown-formatter/hooks/markdown-format.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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

Expand All @@ -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
86 changes: 86 additions & 0 deletions plugins/markdown-formatter/hooks/markdown-format.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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" <<EOF
#!/usr/bin/env bash
printf '%s\n' "\$*" >>"$CYG_LOG"
printf '%s\n' "\${!#}"
EOF
cat >"$SHIM_DIR/jq" <<EOF
#!/usr/bin/env bash
printf '%s\n' "\$*" >>"$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 ]]
2 changes: 1 addition & 1 deletion plugins/ruff-format/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
10 changes: 10 additions & 0 deletions plugins/ruff-format/hooks/hook-utils.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading