diff --git a/docs/conventions/hook-telemetry/CHANGELOG.md b/docs/conventions/hook-telemetry/CHANGELOG.md new file mode 100644 index 000000000..91934d785 --- /dev/null +++ b/docs/conventions/hook-telemetry/CHANGELOG.md @@ -0,0 +1,18 @@ +# Hook Telemetry Contract — Changelog + +Notable changes to the hook-telemetry envelope contract. The envelope is versioned by `schema_version` +(SemVer) and evolves independently of per-hook `data` schemas, which churn additively (README "Forward +compatibility"). Removal, rename, or type-change of a field is a major `schema_version` bump; a field is +marked deprecated here for one minor cycle before removal. + +## 1.0 — 2026-06-24 + +Initial published contract. + +- Common envelope: `schema_version`, `timestamp`, `hook`, `hook_event`, `status`, `duration_ms`, `data`. +- `status`: documented value set `ok | error | skipped | blocked` — a documented open string, not a closed + JSON-Schema enum (mirrors `hook_event`), so a future value never trips a schema-derived validator. +- First per-hook `data` schema: `markdown-format` (`tool`, `file`, `findings`). +- Sink path resolution: `HOOK_TELEMETRY_SINK` is a single executable path, absolute or relative to the + consuming repo root (producer-resolved). Relative is the portable, tracked-wiring form. Not an envelope + field, so no `schema_version` change. diff --git a/docs/conventions/hook-telemetry/README.md b/docs/conventions/hook-telemetry/README.md new file mode 100644 index 000000000..4fc241cb7 --- /dev/null +++ b/docs/conventions/hook-telemetry/README.md @@ -0,0 +1,155 @@ +# Hook Telemetry Convention + +A versioned, marketplace-wide contract for plugin hooks to emit structured execution telemetry. A hook +(the **producer**) emits one JSON envelope per run to a **sink** the consuming repo sets via +`HOOK_TELEMETRY_SINK`. The signal it carries — **this hook's own `duration_ms`, outcome, and findings** — +is what Claude Code's native OTEL cannot provide (CC reports an aggregate `total_duration_ms` across all +hooks and excludes third-party plugin content). + +This directory is the source of truth: `envelope.schema.json` (common fields), `data/.schema.json` +(per-hook payload), `CHANGELOG.md` (version history), `examples/` (worked fixtures). + +## Mediator boundary + +The producer and sink are decoupled — neither imports the other. The producer writes an envelope to stdin +of whatever `HOOK_TELEMETRY_SINK` names; the sink interprets it. This lets sinks be written independently +(medley maps the envelope into its own event store) and lets a plugin ship telemetry that simply no-ops +where no sink is configured. + +- **`HOOK_TELEMETRY_SINK` unset → no-op.** The hook behaves exactly as it did before telemetry: same stdout, + same exit code. Telemetry is purely additive. +- **Fire-and-forget.** The producer dispatches the sink in the background and returns immediately; it never + waits on, nor fails because of, the sink. +- **Best-effort / lossy.** A slow or down sink drops the event silently. This contract is for observability + ("find the slow hook"), **not** for must-not-lose data. Do not build accounting, billing, or audit-of-record + on it. + +## Sink path resolution + +`HOOK_TELEMETRY_SINK` is a **single executable path** — absolute, or relative to the consuming repo root. +The producer resolves it before dispatch: + +- **Absolute** (POSIX `/…` or Windows `X:\` / `X:/`) — used as-is. +- **Relative** (e.g. `.claude/hooks/hook-telemetry-sink.sh`) — joined onto the consuming repo root the + producer already resolves for `data.file` (falling back to `$CLAUDE_PROJECT_DIR`); skipped fail-open if + neither anchor is available, since a drifted hook CWD would resolve it incorrectly. + +Relative is the portable, team-shared wiring form. Claude Code injects `settings.json` `env` values +**literally** — no `${VAR}` expansion (that is a `.mcp.json`-only feature) — so a relative path committed in +`settings.json` is the only clone-portable, worktree-safe way to wire a sink without a per-machine absolute +path. To pass arguments, wrap the sink in a script: the value is exec'd as a single command. + +## The envelope (common fields) + +Every event, from every hook, carries these seven fields. All are required and always present. + +| Field | Type | Meaning | +|-------|------|---------| +| `schema_version` | string (SemVer) | Version of this envelope contract. | +| `timestamp` | string (RFC 3339, UTC) | Instant the hook finished. True UTC — the `Z` is not a local-time lie. | +| `hook` | string | Producer hook id, e.g. `markdown-format`. **Not** CC's `hook_name` (event:matcher). Keys data-schema discovery. | +| `hook_event` | string | The triggering event: `PostToolUse`, `SessionStart`, `ConfigChange`, `WorktreeCreate`, … A **free string**, not an enum — the event vocabulary grows, and custom events exist. | +| `status` | string | Universal execution outcome (documented value set, not a closed enum). See below. | +| `duration_ms` | integer (≥ 0) | **This hook's** runtime in milliseconds. Not CC's aggregate `total_duration_ms`. | +| `data` | object | Per-hook payload; always present (at minimum `{}`). See "Per-hook data". | + +Naming is snake_case throughout and aligns with Claude Code's own field names where the concept matches +(`hook_event`), and deliberately diverges where it does not (`hook` ≠ `hook_name`, `duration_ms` ≠ +`total_duration_ms`) so a name never misleads. + +### `status` — the universal outcome (documented value set) + +`ok | error | skipped | blocked` — a **documented open string, not a closed JSON-Schema enum** (same encoding +as `hook_event`, and for the same reason: the set grows). A closed enum would *reject* a future value at +validation time, contradicting the tolerate-unknown rule below. These four express the outcome of *any* hook, +validated against the full medley hook set (formatters, guards, audit, action hooks): + +| Value | Meaning | +|-------|---------| +| `ok` | The hook ran and did its job. | +| `error` | The hook ran but failed internally. | +| `skipped` | The hook did not apply (no-op — wrong file type, disabled, nothing to do). | +| `blocked` | The hook intentionally blocked the operation (guard hooks: git-safety, branch-protection, secret-pattern-detection, …). Distinct from `error`. | + +Domain detail — *what* a hook found, not *whether* it ran — lives in `data`, never in `status`. Lint +findings are `data.findings`, because "found issues" is specific to formatters and not a universal outcome. +`cancelled` is intentionally excluded: a killed hook cannot self-report. + +## Per-hook data + +`data` carries everything not universal to all hooks. Its shape per producer is published at +`data/.schema.json`, **discovered by the envelope's `hook` value**. For `hook: "markdown-format"`, +read `data/markdown-format.schema.json` (`tool`, `file`, `findings`). + +- **`data` is always present** — an object, possibly `{}`. +- **Generic sinks ignore `data`** and consume only the common envelope. +- **Unknown `hook`** (no matching data schema) → record the common fields, ignore `data`. Never hard-fail. + +## Forward compatibility (don't break clients) + +The whole point of a published contract is that independently-written sinks keep working as producers evolve. +Two rules make that hold: + +1. **`data` evolves additive-only** — new keys may be added; existing keys are never silently removed, + renamed, or type-changed (those require a deprecation cycle and a major bump). +2. **Consumers MUST ignore unknown keys AND MUST tolerate unknown enum values.** A sink reading an envelope + from a newer producer must skip keys it does not recognize, and must treat an unrecognized `status` (or + any future enum value) as a catch-all rather than crashing. Ignore-unknown-keys alone is not enough — + enum growth (e.g. a future `status`) needs the tolerate-unknown-enum rule too. + +Both schema files set `additionalProperties: true`, which encodes only the **unknown-keys-tolerated** half of +these rules. The rest is **policy, enforced by review and the deprecation cycle below — not by the schema**: +`additionalProperties` says nothing about existing keys never being removed/renamed/type-changed, and a closed +`enum` would actively *reject* a new `status` value (which is why `status` is a documented open string, like +`hook_event`). Do not over-trust the schema as the enforcement boundary; it is the contract a reviewer reads. + +## Deprecation policy + +A field is marked **deprecated** in `CHANGELOG.md` for one minor cycle before it is removed. Removal, rename, +or type-change of any field is a major `schema_version` bump. This gives consumers a release window to adapt +before a breaking change lands. + +## Versioning + +`schema_version` (SemVer) versions the **envelope**. Per-hook `data` schemas churn independently under the +additive / ignore-unknown rules above and are **not** separately version-stamped (deferred as YAGNI). The +envelope version and a hook's `data` shape are decoupled on purpose. + +A per-`data` version signal is **deferred, not designed out**: today a hook's `data` shape is discovered only +by the `hook` value, so a breaking `data` change would carry no version marker short of a whole-envelope major +bump. **Trigger** — when a producer first needs a *breaking* `data` change, add an optional per-payload schema +identifier (a `data_schema` URI, à la CloudEvents `dataschema`) rather than bumping the envelope. It is +additive (optional field), so it ships without breaking existing consumers. + +## Schemas are contract-docs, not machine-enforced + +The JSON schemas here are **not machine-enforced** — no validator is wired into producer or sink. They are +the human-readable, reviewable contract; conformance is checked by hand and by `jq` required-key assertions +(producers and sinks each carry their own). Treat the schemas as the authority a reviewer reads, not a +runtime gate. + +## Adoption (adopt-by-copy) + +The emit function is **co-located in each plugin's `hooks/hook-utils.sh`** — plugins are runtime-isolated +under `${CLAUDE_PLUGIN_ROOT}`, so there is no shared library to import. The first implementer +(`markdown-formatter`) carries its own copy. **The moment a second hook needs to emit, extract a canonical +copy and add a drift-check in the same change** — copy once, then consolidate, so the two copies never drift +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: + +- 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); +- never crashes and never writes to stdout — it runs fire-and-forget, exec'd as a single command per event. + +That is the whole consumer contract: any number of independently-written sinks can subscribe to the same +producers without coordinating with them or each other. + +## Implementers + +| Producer | `hook` value | Data schema | +|----------|--------------|-------------| +| `markdown-formatter` plugin | `markdown-format` | `data/markdown-format.schema.json` | diff --git a/docs/conventions/hook-telemetry/data/markdown-format.schema.json b/docs/conventions/hook-telemetry/data/markdown-format.schema.json new file mode 100644 index 000000000..cd718165f --- /dev/null +++ b/docs/conventions/hook-telemetry/data/markdown-format.schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/melodic-software/claude-code-plugins/main/docs/conventions/hook-telemetry/data/markdown-format.schema.json", + "title": "markdown-format telemetry data", + "description": "Per-hook `data` payload for the markdown-format hook. Discovered from the envelope `hook` value \"markdown-format\". Evolves additive-only.", + "type": "object", + "required": ["tool", "file", "findings"], + "additionalProperties": true, + "properties": { + "tool": { + "type": "string", + "description": "Claude Code tool that triggered the hook (Write or Edit)." + }, + "file": { + "type": "string", + "description": "Path of the formatted markdown file, relative to the consuming repo root." + }, + "findings": { + "type": "array", + "items": { "type": "string" }, + "description": "Unfixable markdownlint violations remaining after --fix, one per line. Empty array = clean." + } + } +} diff --git a/docs/conventions/hook-telemetry/envelope.schema.json b/docs/conventions/hook-telemetry/envelope.schema.json new file mode 100644 index 000000000..d5159b096 --- /dev/null +++ b/docs/conventions/hook-telemetry/envelope.schema.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/melodic-software/claude-code-plugins/main/docs/conventions/hook-telemetry/envelope.schema.json", + "title": "Hook Telemetry Envelope", + "description": "Common envelope a marketplace plugin hook (producer) emits to a consumer-set sink. Versioned by schema_version. Per-hook detail lives in `data`, described by data/.schema.json keyed on the `hook` value. Contract-doc only: no validator is wired (see README).", + "type": "object", + "required": [ + "schema_version", + "timestamp", + "hook", + "hook_event", + "status", + "duration_ms", + "data" + ], + "additionalProperties": true, + "properties": { + "schema_version": { + "type": "string", + "description": "SemVer version of this envelope contract." + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "RFC 3339 UTC instant the hook finished. True UTC (TZ=UTC) — the Z suffix is not a local-time lie." + }, + "hook": { + "type": "string", + "description": "Producer hook id, e.g. \"markdown-format\". NOT Claude Code's hook_name (event:matcher). Keys data-schema discovery: data/.schema.json." + }, + "hook_event": { + "type": "string", + "description": "Free string naming the triggering event (PostToolUse, SessionStart, ConfigChange, WorktreeCreate, ...). Deliberately not an enum." + }, + "status": { + "type": "string", + "description": "Universal execution outcome. Documented values: ok | error | skipped | blocked. Deliberately a documented open string, NOT a closed enum (mirrors hook_event) — the value set grows, and a closed enum would reject a future value, contradicting the tolerate-unknown rule. Consumers MUST treat an unrecognized value as a catch-all, never hard-fail. See README \"status\"." + }, + "duration_ms": { + "type": "integer", + "minimum": 0, + "description": "This hook's own runtime in milliseconds. NOT Claude Code's aggregate total_duration_ms." + }, + "data": { + "type": "object", + "additionalProperties": true, + "description": "Per-hook payload; always present (at minimum {}). Shape described by data/.schema.json. Generic sinks ignore this and consume only the common fields." + } + } +} diff --git a/docs/conventions/hook-telemetry/examples/markdown-format.json b/docs/conventions/hook-telemetry/examples/markdown-format.json new file mode 100644 index 000000000..b7b253f8e --- /dev/null +++ b/docs/conventions/hook-telemetry/examples/markdown-format.json @@ -0,0 +1,15 @@ +{ + "schema_version": "1.0", + "timestamp": "2026-06-23T22:37:29Z", + "hook": "markdown-format", + "hook_event": "PostToolUse", + "status": "ok", + "duration_ms": 142, + "data": { + "tool": "Write", + "file": "docs/foo.md", + "findings": [ + "docs/foo.md:12 MD013/line-length Line length [Expected: 80; Actual: 118]" + ] + } +} diff --git a/plugins/markdown-formatter/hooks/hook-utils.sh b/plugins/markdown-formatter/hooks/hook-utils.sh index 3ced670c9..efe874946 100644 --- a/plugins/markdown-formatter/hooks/hook-utils.sh +++ b/plugins/markdown-formatter/hooks/hook-utils.sh @@ -89,6 +89,94 @@ hook::ctx_flush() { hook::ctx_reset } +# 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. +# Opt-in guard: HOOK_TELEMETRY_SINK unset or empty → return 0 immediately. +# Fail-open: jq absent → return 0 immediately. +# +# Usage: +# hook::emit_telemetry [repo_root] +# +# Value of $EPOCHREALTIME captured by the caller before work began. +# Handles both '.' and ',' as the decimal separator (LC_NUMERIC). +# Pre-built JSON object for the `data` field. +# Optional consuming-repo root, used to resolve a RELATIVE +# HOOK_TELEMETRY_SINK. The caller passes the root it already +# resolved for data.file; ignored when the sink is absolute. +# +# Sink path resolution: HOOK_TELEMETRY_SINK may be absolute OR relative to the +# consuming repo root. Absolute (POSIX /… or Windows X:\ / X:/) is used as-is; a +# relative value is joined onto (or $CLAUDE_PROJECT_DIR when no root +# is passed), and skipped fail-open if neither is available. Relative is the +# portable, team-shared wiring form: CC injects settings.json env values +# literally (no ${VAR} expansion), so a relative path tracked in settings.json is +# the only clone-portable, worktree-safe option. +# +# NEVER writes to fd1 (the hook's stdout / additionalContext channel). +hook::emit_telemetry() { + # Opt-in guard. + [[ -n "${HOOK_TELEMETRY_SINK:-}" ]] || return 0 + # Fail-open when jq is absent. + command -v jq >/dev/null 2>&1 || return 0 + + local hook_id="$1" + local hook_event="$2" + local status="$3" + local start_epoch="$4" + local data_json="$5" + local repo_root="${6:-}" + + # Compute duration_ms from caller's $EPOCHREALTIME snapshot to now. + # Both '.' and ',' separators handled; 10# prefix prevents octal misreading + # of fractional parts with leading zeros (e.g. .045123 → 10#045123 = 45123). + local now=$EPOCHREALTIME + local s_s="${start_epoch%[.,]*}" s_f="${start_epoch#*[.,]}" + 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. Redirect jq stderr to /dev/null; output goes to a local + # variable — never to fd1. + local envelope + 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" \ + --argjson data "$data_json" \ + '{schema_version:$schema_version,timestamp:$timestamp,hook:$hook,hook_event:$hook_event,status:$status,duration_ms:$duration_ms,data:$data}' \ + 2>/dev/null) || return 0 + + # 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 + # relative value with no anchor is skipped fail-open — never exec a path the + # drifted hook CWD would resolve incorrectly. + local sink="$HOOK_TELEMETRY_SINK" + case "$sink" in + /* | [A-Za-z]:[/\\]*) ;; + *) + local root="${repo_root:-${CLAUDE_PROJECT_DIR:-}}" + [[ -n "$root" ]] || return 0 + sink="${root%/}/$sink" + ;; + esac + + # Fire-and-forget: pipe the envelope to the sink in a background subshell. + # The subshell's stdout AND stderr are redirected to /dev/null so the sink + # cannot write to the hook's fd1 (the additionalContext channel) and the + # backgrounded subshell does not hold a copy of the hook's fd1 open — which + # would block any command substitution wrapping the hook until the sink exits + # (the "C1 fd1-inheritance blocker"). The sink is quoted — it is a single + # executable path (wrap in a script to pass arguments). + printf '%s\n' "$envelope" | ("$sink" >/dev/null 2>&1) & +} + # Print cross-host hook JSON to stdout (exit 0). No-op when context is empty. # Shape: { hookSpecificOutput: { hookEventName[, additionalContext] } }. hook::emit_additional_context() { diff --git a/plugins/markdown-formatter/hooks/hook-utils.test.sh b/plugins/markdown-formatter/hooks/hook-utils.test.sh new file mode 100755 index 000000000..aa33a56d7 --- /dev/null +++ b/plugins/markdown-formatter/hooks/hook-utils.test.sh @@ -0,0 +1,293 @@ +#!/usr/bin/env bash +# Unit tests for hook-utils.sh: sourced-lib tests for hook::emit_telemetry. +# +# Tests source hook-utils.sh directly and drive hook::emit_telemetry in +# isolation. No subprocess invocation of markdown-format.sh — black-box +# integration tests live in markdown-format.test.sh. + +set -uo pipefail + +HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +PASS=0 +FAIL=0 +fail() { + echo "FAIL: $*" >&2 + FAIL=$((FAIL + 1)) +} +ok() { + echo "ok: $*" + PASS=$((PASS + 1)) +} + +# --- Source the lib under test ----------------------------------------------- +# shellcheck source=hook-utils.sh +source "$HOOK_DIR/hook-utils.sh" + +# make_sink → prints the path to a single-executable stub sink that +# copies its stdin to . The contract requires HOOK_TELEMETRY_SINK to be +# a single executable path (not a command-with-args), so tests point it at a +# stub script rather than `tee FILE`. +make_sink() { + local s + s="$(mktemp)" + cat >"$s" <"$1" +EOF + chmod +x "$s" + printf '%s' "$s" +} + +# --- Test 1: HOOK_TELEMETRY_SINK unset → returns 0, no output ---------------- +unset HOOK_TELEMETRY_SINK 2>/dev/null || true +out=$(hook::emit_telemetry "markdown-format" "PostToolUse" "ok" "$EPOCHREALTIME" '{"tool":"Write","file":"foo.md","findings":[]}' 2>/dev/null) +rc=$? +if [[ $rc -eq 0 ]]; then + ok "sink unset: returns 0" +else + fail "sink unset: expected 0, got $rc" +fi +if [[ -z "$out" ]]; then + ok "sink unset: no output" +else + fail "sink unset: unexpected output: $out" +fi + +# --- Test 2: jq absent → emit skipped, no error, returns 0 ------------------ +# Shadow jq with a function that returns 127 to simulate absence. +# The HOOK_TELEMETRY_SINK env var and the jq shadow are scoped to the subshell +# so they never pollute the outer test context. +# shellcheck disable=SC2030,SC2031 +out_nojq=$( + export HOOK_TELEMETRY_SINK="cat" + ( + unset _MDFMT_HOOK_UTILS_LOADED 2>/dev/null || true + jq() { return 127; } + export -f jq + source "$HOOK_DIR/hook-utils.sh" + hook::emit_telemetry "markdown-format" "PostToolUse" "ok" "$EPOCHREALTIME" '{"tool":"Write","file":"foo.md","findings":[]}' 2>/dev/null + ) +) +rc_nojq=$? +if [[ $rc_nojq -eq 0 ]]; then + ok "jq absent: returns 0" +else + fail "jq absent: expected 0, got $rc_nojq" +fi +if [[ -z "$out_nojq" ]]; then + ok "jq absent: no output" +else + fail "jq absent: unexpected output: $out_nojq" +fi + +# --- Test 3: envelope shape matches schema (7 required common fields + data) -- +SINK_FILE="$(mktemp)" +cleanup_t3() { rm -f "$SINK_FILE"; } +trap cleanup_t3 EXIT + +# Use a file-writing sink to capture the envelope. +# shellcheck disable=SC2031 # prior subshell export is intentionally scoped there +HOOK_TELEMETRY_SINK="$(make_sink "$SINK_FILE")" +export HOOK_TELEMETRY_SINK +data_json='{"tool":"Write","file":"docs/foo.md","findings":["docs/foo.md:12 MD013/line-length"]}' +start=$EPOCHREALTIME +hook::emit_telemetry "markdown-format" "PostToolUse" "ok" "$start" "$data_json" 2>/dev/null +# Allow the background process to finish +sleep 0.2 +unset HOOK_TELEMETRY_SINK + +if [[ -s "$SINK_FILE" ]]; then + ok "envelope shape: sink received data" + # Validate all 7 required common fields + for field in schema_version timestamp hook hook_event status duration_ms data; do + if jq -e "has(\"$field\")" "$SINK_FILE" >/dev/null 2>&1; then + ok "envelope shape: field '$field' present" + else + fail "envelope shape: field '$field' missing. envelope=$(cat "$SINK_FILE")" + fi + done + # Validate data sub-fields + for subfield in tool file findings; do + if jq -e ".data | has(\"$subfield\")" "$SINK_FILE" >/dev/null 2>&1; then + ok "envelope shape: data.$subfield present" + else + fail "envelope shape: data.$subfield missing. data=$(jq .data "$SINK_FILE")" + fi + done + # Validate schema_version + sv=$(jq -r '.schema_version' "$SINK_FILE") + if [[ "$sv" == "1.0" ]]; then + ok "envelope: schema_version is 1.0" + else + fail "envelope: schema_version expected 1.0, got $sv" + fi + # Validate hook id + hook_val=$(jq -r '.hook' "$SINK_FILE") + if [[ "$hook_val" == "markdown-format" ]]; then + ok "envelope: hook is markdown-format" + else + fail "envelope: hook expected markdown-format, got $hook_val" + fi + # Validate hook_event + he=$(jq -r '.hook_event' "$SINK_FILE") + if [[ "$he" == "PostToolUse" ]]; then + ok "envelope: hook_event is PostToolUse" + else + fail "envelope: hook_event expected PostToolUse, got $he" + fi + # Validate status + st=$(jq -r '.status' "$SINK_FILE") + if [[ "$st" == "ok" ]]; then + ok "envelope: status is ok" + else + fail "envelope: status expected ok, got $st" + fi +else + fail "envelope shape: sink file empty — emit did not fire or sink did not write" + for field in schema_version timestamp hook hook_event status duration_ms data; do + fail "envelope shape: field '$field' not verifiable (no envelope)" + done + for subfield in tool file findings; do + fail "envelope shape: data.$subfield not verifiable (no envelope)" + done +fi + +# --- Test 4: timestamp is UTC RFC3339 with Z suffix -------------------------- +if [[ -s "$SINK_FILE" ]]; then + ts=$(jq -r '.timestamp' "$SINK_FILE") + if [[ "$ts" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ ]]; then + ok "timestamp: matches RFC3339 UTC pattern" + else + fail "timestamp: does not match pattern: $ts" + fi + # Verify it is genuinely UTC: timestamp hour must align with UTC hour (±1 for boundary) + # We verify by checking the TZ=UTC prefix actually produces UTC + utc_h=$(TZ=UTC date +%H) + ts_h="${ts:11:2}" + # Allow ±1h boundary tolerance + h_diff=$(( ${ts_h#0} - ${utc_h#0} )) + h_diff=${h_diff#-} + if [[ $h_diff -le 1 ]]; then + ok "timestamp: UTC hour aligns with system UTC" + else + fail "timestamp: hour drift vs UTC: ts_h=$ts_h utc_h=$utc_h diff=$h_diff" + fi +else + fail "timestamp: no envelope to check" + fail "timestamp: UTC alignment not verifiable" +fi + +# --- Test 5: duration_ms is a non-negative integer --------------------------- +if [[ -s "$SINK_FILE" ]]; then + dur=$(jq '.duration_ms' "$SINK_FILE") + if jq -e '.duration_ms | type == "number" and . >= 0 and floor == .' "$SINK_FILE" >/dev/null 2>&1; then + ok "duration_ms: is non-negative integer (value=$dur)" + else + fail "duration_ms: not a non-negative integer: $dur" + fi +else + fail "duration_ms: no envelope to check" +fi + +rm -f "$SINK_FILE" +trap - EXIT + +# --- Test 6: status "skipped" passes through --------------------------------- +SINK_FILE2="$(mktemp)" +HOOK_TELEMETRY_SINK="$(make_sink "$SINK_FILE2")" +export HOOK_TELEMETRY_SINK +hook::emit_telemetry "markdown-format" "PostToolUse" "skipped" "$EPOCHREALTIME" '{"tool":"","file":"","findings":[]}' 2>/dev/null +sleep 0.2 +unset HOOK_TELEMETRY_SINK + +if [[ -s "$SINK_FILE2" ]]; then + st2=$(jq -r '.status' "$SINK_FILE2") + if [[ "$st2" == "skipped" ]]; then + ok "status skipped: correctly emitted" + else + fail "status skipped: got $st2" + fi +else + fail "status skipped: no envelope written" +fi +rm -f "$SINK_FILE2" + +# --- Test 7: RELATIVE sink resolves against the passed repo_root (6th arg) ---- +# This is the portable/tracked-wiring path: a relative HOOK_TELEMETRY_SINK joined +# onto the consuming repo root the caller passes. +ROOT7="$(mktemp -d)" +mkdir -p "$ROOT7/.claude/hooks" +REL7=".claude/hooks/sink.sh" +OUT7="$(mktemp)" +cat >"$ROOT7/$REL7" <"$OUT7" +EOF +chmod +x "$ROOT7/$REL7" +export HOOK_TELEMETRY_SINK="$REL7" +hook::emit_telemetry "markdown-format" "PostToolUse" "ok" "$EPOCHREALTIME" '{"tool":"Write","file":"x.md","findings":[]}' "$ROOT7" 2>/dev/null +sleep 0.2 +unset HOOK_TELEMETRY_SINK +if [[ -s "$OUT7" ]] && [[ "$(jq -r '.hook' "$OUT7")" == "markdown-format" ]]; then + ok "relative sink: resolved against repo_root arg, envelope delivered" +else + fail "relative sink: not resolved against repo_root arg (out empty or wrong)" +fi +rm -rf "$ROOT7" "$OUT7" + +# --- Test 8: RELATIVE sink resolves against CLAUDE_PROJECT_DIR when no root arg -- +ROOT8="$(mktemp -d)" +mkdir -p "$ROOT8/.claude/hooks" +OUT8="$(mktemp)" +cat >"$ROOT8/.claude/hooks/sink.sh" <"$OUT8" +EOF +chmod +x "$ROOT8/.claude/hooks/sink.sh" +export HOOK_TELEMETRY_SINK=".claude/hooks/sink.sh" +CLAUDE_PROJECT_DIR="$ROOT8" hook::emit_telemetry "markdown-format" "PostToolUse" "ok" "$EPOCHREALTIME" '{"tool":"Write","file":"x.md","findings":[]}' 2>/dev/null +sleep 0.2 +unset HOOK_TELEMETRY_SINK +if [[ -s "$OUT8" ]]; then + ok "relative sink: resolved against CLAUDE_PROJECT_DIR fallback" +else + fail "relative sink: CLAUDE_PROJECT_DIR fallback did not resolve" +fi +rm -rf "$ROOT8" "$OUT8" + +# --- Test 9: RELATIVE sink with NO anchor → fail-open skip (return 0, no write) -- +OUT9="$(mktemp)" +rm -f "$OUT9" # ensure absent +export HOOK_TELEMETRY_SINK="relative/no/anchor/sink.sh" +( + unset CLAUDE_PROJECT_DIR 2>/dev/null || true + hook::emit_telemetry "markdown-format" "PostToolUse" "ok" "$EPOCHREALTIME" '{"tool":"Write","file":"x.md","findings":[]}' 2>/dev/null +) +rc9=$? +sleep 0.2 +unset HOOK_TELEMETRY_SINK +if [[ $rc9 -eq 0 ]] && [[ ! -s "$OUT9" ]]; then + ok "relative sink, no anchor: fail-open skip (rc 0, no write)" +else + fail "relative sink, no anchor: expected skip (rc=$rc9, out exists=$([[ -s "$OUT9" ]] && echo y || echo n))" +fi +rm -f "$OUT9" + +# --- Test 10: ABSOLUTE sink passes through unchanged -------------------------- +OUT10="$(mktemp)" +ABS10="$(make_sink "$OUT10")" # mktemp path is absolute +export HOOK_TELEMETRY_SINK="$ABS10" +hook::emit_telemetry "markdown-format" "PostToolUse" "ok" "$EPOCHREALTIME" '{"tool":"Write","file":"x.md","findings":[]}' "/some/ignored/root" 2>/dev/null +sleep 0.2 +unset HOOK_TELEMETRY_SINK +if [[ -s "$OUT10" ]] && [[ "$(jq -r '.hook' "$OUT10")" == "markdown-format" ]]; then + ok "absolute sink: passes through unchanged (repo_root ignored)" +else + fail "absolute sink: did not pass through" +fi +rm -f "$ABS10" "$OUT10" + +echo +echo "PASS=$PASS FAIL=$FAIL" +[[ $FAIL -eq 0 ]] diff --git a/plugins/markdown-formatter/hooks/markdown-format.sh b/plugins/markdown-formatter/hooks/markdown-format.sh index 088a83786..a31e1f176 100755 --- a/plugins/markdown-formatter/hooks/markdown-format.sh +++ b/plugins/markdown-formatter/hooks/markdown-format.sh @@ -8,44 +8,99 @@ set -uo pipefail -# Read inherited fd0 directly (bare jq) — NEVER `/dev/null) +TOOL="${TOOL:-}" + +# 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. +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"/}" + fi +else + FILE_REL="${FILE#"$REPO_ROOT"/}" +fi + MDLINT=() if command -v markdownlint-cli2 >/dev/null 2>&1; then MDLINT=(markdownlint-cli2) elif command -v npx >/dev/null 2>&1; then MDLINT=(npx markdownlint-cli2) else + # markdownlint unavailable — emit skipped telemetry then exit cleanly. + data_json=$(jq -n \ + --arg tool "$TOOL" \ + --arg file "$FILE_REL" \ + --argjson findings '[]' \ + '{tool:$tool,file:$file,findings:$findings}' 2>/dev/null) || data_json="{\"tool\":\"$TOOL\",\"file\":\"$FILE_REL\",\"findings\":[]}" + hook::emit_telemetry "markdown-format" "PostToolUse" "skipped" "$start" "$data_json" "$REPO_ROOT" exit 0 fi -# Run markdownlint-cli2 from the linted file's repo root. Its config -# auto-discovery is CWD-anchored, and the hook's CWD is not guaranteed to be -# the file's repo root, so a drifted CWD would silently lose the repo's -# markdownlint config cascade and fall back to tool defaults. Running from the -# repo root applies the consumer's own rules. repo_root is file-anchored. -REPO_ROOT="$(hook::repo_root "$(dirname "$FILE")")" - if FIX_OUTPUT=$(cd "$REPO_ROOT" && "${MDLINT[@]}" --fix "$FILE" 2>&1); then + # Clean after fix — emit ok with empty findings. + data_json=$(jq -n \ + --arg tool "$TOOL" \ + --arg file "$FILE_REL" \ + --argjson findings '[]' \ + '{tool:$tool,file:$file,findings:$findings}' 2>/dev/null) || data_json="{\"tool\":\"$TOOL\",\"file\":\"$FILE_REL\",\"findings\":[]}" + hook::emit_telemetry "markdown-format" "PostToolUse" "ok" "$start" "$data_json" "$REPO_ROOT" exit 0 fi +# Residual findings — surface via additionalContext, then emit ok with findings. +# ctx_append receives all output lines (human-readable context for Claude Code). +# FINDINGS_JSON is filtered to violation lines only — schema requires +# "Unfixable markdownlint violations remaining after --fix, one per line"; +# banner/diagnostic lines (version, Finding:, Linting:, Summary:) are excluded. +# Violation lines are identified by the " MD/" rule-code pattern. hook::ctx_reset hook::ctx_append "markdown-format: $(basename "$FILE") has markdownlint findings:" +FINDINGS_JSON='[]' while IFS= read -r line; do hook::ctx_append " $line" + if [[ "$line" =~ [[:space:]]MD[0-9]+/ ]]; then + FINDINGS_JSON=$(printf '%s' "$FINDINGS_JSON" | jq --arg l "$line" '. + [$l]' 2>/dev/null) || true + fi done <<<"$FIX_OUTPUT" hook::ctx_flush PostToolUse + +data_json=$(jq -n \ + --arg tool "$TOOL" \ + --arg file "$FILE_REL" \ + --argjson findings "$FINDINGS_JSON" \ + '{tool:$tool,file:$file,findings:$findings}' 2>/dev/null) || data_json="{\"tool\":\"$TOOL\",\"file\":\"$FILE_REL\",\"findings\":[]}" +hook::emit_telemetry "markdown-format" "PostToolUse" "ok" "$start" "$data_json" "$REPO_ROOT" exit 0 diff --git a/plugins/markdown-formatter/hooks/markdown-format.test.sh b/plugins/markdown-formatter/hooks/markdown-format.test.sh index 7ebd387a6..0f33ee2ce 100755 --- a/plugins/markdown-formatter/hooks/markdown-format.test.sh +++ b/plugins/markdown-formatter/hooks/markdown-format.test.sh @@ -34,6 +34,21 @@ UNRELATED="$(mktemp -d)" cleanup() { rm -rf "$WORK" "$UNRELATED"; } trap cleanup EXIT +# make_sink → path to an executable single-command stub sink running +# (which reads the envelope on stdin). The contract requires +# HOOK_TELEMETRY_SINK to be a single executable path, not a command-with-args, +# so tests point it at a stub script. Stubs live under $WORK so the trap reaps them. +make_sink() { + local s + s="$(mktemp -p "$WORK" sink.XXXXXX)" + { + printf '#!/usr/bin/env bash\n' + printf '%s\n' "$1" + } >"$s" + chmod +x "$s" + printf '%s' "$s" +} + # --- Build the throwaway consumer repo -------------------------------------- REPO="$WORK/consumer" mkdir -p "$REPO" @@ -155,6 +170,202 @@ else fail "kill switch failed (rc=$RC_K out=$OUT_K)" fi +# ============================================================================ +# Phase 2: hook telemetry tests +# ============================================================================ + +# --- Telemetry sink unset → hook stdout + exit identical to pre-change ------ +# Additive-safety proof: HOOK_TELEMETRY_SINK unset must produce byte-identical +# stdout and exit code to the pre-Phase-2 baseline. + +# Re-run fixture A with sink unset — must still produce empty stdout, exit 0. +printf '# Title A2\r\n\r\nSome text\r\n\r\n- item one\r\n- item two' >"$REPO/fixtureA2.md" +OUT_A_NOSINK="$(cd "$UNRELATED" && printf '{"tool_input":{"file_path":"%s"}}' "$REPO/fixtureA2.md" \ + | env -u CLAUDE_PROJECT_DIR -u HOOK_TELEMETRY_SINK HOOK_MARKDOWN_FORMAT_ENABLED=true bash "$HOOK")" +RC_A_NOSINK=$? +if [[ $RC_A_NOSINK -eq 0 ]]; then ok "telemetry/sink-unset: exit 0 (parity)"; else fail "telemetry/sink-unset: expected 0, got $RC_A_NOSINK"; fi +if [[ -z "$OUT_A_NOSINK" ]]; then ok "telemetry/sink-unset: empty stdout (parity)"; else fail "telemetry/sink-unset: stdout not empty: $OUT_A_NOSINK"; fi + +# Re-run fixture B with sink unset — must still emit additionalContext, exit 0. +printf '# Doc B2\n\n## Section\n\ntext\n\n## Section\n\n* star item\n' >"$REPO/fixtureB2.md" +OUT_B_NOSINK="$(cd "$UNRELATED" && printf '{"tool_input":{"file_path":"%s"},"tool_name":"Edit"}' "$REPO/fixtureB2.md" \ + | env -u CLAUDE_PROJECT_DIR -u HOOK_TELEMETRY_SINK HOOK_MARKDOWN_FORMAT_ENABLED=true bash "$HOOK")" +RC_B_NOSINK=$? +if [[ $RC_B_NOSINK -eq 0 ]]; then ok "telemetry/sink-unset B: exit 0 (parity)"; else fail "telemetry/sink-unset B: expected 0, got $RC_B_NOSINK"; fi +if printf '%s' "$OUT_B_NOSINK" | jq -e '.hookSpecificOutput.additionalContext' >/dev/null 2>&1; then + ok "telemetry/sink-unset B: additionalContext present (parity)" +else + fail "telemetry/sink-unset B: no additionalContext: $OUT_B_NOSINK" +fi +# Emit must NOT appear in hook stdout (no envelope JSON alongside additionalContext) +if printf '%s' "$OUT_B_NOSINK" | jq -e '.schema_version' >/dev/null 2>&1; then + fail "telemetry/sink-unset B: envelope leaked to hook stdout" +else + ok "telemetry/sink-unset B: no envelope in hook stdout" +fi + +# --- Stub sink: real edit → schema-valid envelope with status ok and findings - +TEL_FILE="$(mktemp)" +STUB_SINK="$(make_sink "cat >\"$TEL_FILE\"")" + +# Fixture with unfixable finding (MD024 duplicate heading): status ok + findings populated. +printf '# Doc T\n\n## Section\n\ntext\n\n## Section\n\nmore text\n' >"$REPO/fixtureT.md" +# shellcheck disable=SC2034 # stdout captured for timing correctness; content checked via TEL_FILE +_OUT_T="$(cd "$UNRELATED" && printf '{"tool_input":{"file_path":"%s"},"tool_name":"Write"}' "$REPO/fixtureT.md" \ + | env -u CLAUDE_PROJECT_DIR HOOK_MARKDOWN_FORMAT_ENABLED=true HOOK_TELEMETRY_SINK="$STUB_SINK" bash "$HOOK")" +RC_T=$? +sleep 0.3 # allow background sink to flush + +if [[ $RC_T -eq 0 ]]; then ok "telemetry/stub-sink: hook exit 0"; else fail "telemetry/stub-sink: hook exit $RC_T"; fi + +if [[ -s "$TEL_FILE" ]]; then + ok "telemetry/stub-sink: envelope received" + # Validate all 7 required common fields + for field in schema_version timestamp hook hook_event status duration_ms data; do + if jq -e "has(\"$field\")" "$TEL_FILE" >/dev/null 2>&1; then + ok "telemetry/envelope: $field present" + else + fail "telemetry/envelope: $field missing. file=$(cat "$TEL_FILE")" + fi + done + # status must be "ok" + TEL_STATUS="$(jq -r '.status' "$TEL_FILE")" + if [[ "$TEL_STATUS" == "ok" ]]; then ok "telemetry/envelope: status ok"; else fail "telemetry/envelope: status expected ok, got $TEL_STATUS"; fi + # data.findings must contain exactly the MD024 violation line (not banner noise). + # Schema: "Unfixable markdownlint violations remaining after --fix, one per line." + # Banner lines (version, Finding:, Linting:, Summary:) must be excluded. + TEL_FINDINGS_LEN="$(jq '.data.findings | length' "$TEL_FILE")" + if [[ "$TEL_FINDINGS_LEN" -eq 1 ]]; then + ok "telemetry/envelope: findings has exactly 1 item (violation only, no banner noise)" + else + fail "telemetry/envelope: findings expected 1 violation, got $TEL_FINDINGS_LEN: $(jq '.data.findings' "$TEL_FILE")" + fi + # The single finding must name the MD024 rule. + if jq -e '.data.findings[0] | test("MD024")' "$TEL_FILE" >/dev/null 2>&1; then + ok "telemetry/envelope: findings[0] names MD024" + else + fail "telemetry/envelope: findings[0] does not name MD024: $(jq '.data.findings[0]' "$TEL_FILE")" + fi + # data.tool must be "Write" (from the input JSON) + TEL_TOOL="$(jq -r '.data.tool' "$TEL_FILE")" + if [[ "$TEL_TOOL" == "Write" ]]; then ok "telemetry/envelope: data.tool is Write"; else fail "telemetry/envelope: data.tool expected Write, got $TEL_TOOL"; fi + # data.file must be repo-relative (schema: "relative to the consuming repo root"). + # A relative path does not start with / or a Windows drive letter. + TEL_FILE_VAL="$(jq -r '.data.file' "$TEL_FILE")" + if [[ -n "$TEL_FILE_VAL" && "$TEL_FILE_VAL" != /* && "$TEL_FILE_VAL" != ?:* ]]; then + ok "telemetry/envelope: data.file is repo-relative ($TEL_FILE_VAL)" + else + fail "telemetry/envelope: data.file expected repo-relative, got: $TEL_FILE_VAL" + fi + # schema_version must be "1.0" + TEL_SV="$(jq -r '.schema_version' "$TEL_FILE")" + if [[ "$TEL_SV" == "1.0" ]]; then ok "telemetry/envelope: schema_version 1.0"; else fail "telemetry/envelope: schema_version expected 1.0, got $TEL_SV"; fi + # duration_ms must be non-negative integer + if jq -e '.duration_ms | type == "number" and . >= 0 and floor == .' "$TEL_FILE" >/dev/null 2>&1; then + ok "telemetry/envelope: duration_ms is non-negative integer" + else + fail "telemetry/envelope: duration_ms invalid: $(jq .duration_ms "$TEL_FILE")" + fi +else + fail "telemetry/stub-sink: no envelope written to sink" + for field in schema_version timestamp hook hook_event status duration_ms data status findings tool file schema_version duration_ms; do + : # counters already accounted by the outer if branch counting + done + fail "telemetry/envelope: status (no envelope)" + fail "telemetry/envelope: findings (no envelope)" + fail "telemetry/envelope: data.tool (no envelope)" + fail "telemetry/envelope: data.file (no envelope)" + fail "telemetry/envelope: schema_version (no envelope)" + fail "telemetry/envelope: duration_ms (no envelope)" +fi +rm -f "$TEL_FILE" + +# --- Stub sink: clean file → status ok, findings empty array ----------------- +TEL_CLEAN="$(mktemp)" +STUB_CLEAN="$(make_sink "cat >\"$TEL_CLEAN\"")" +printf '# Clean Doc\n\nSome text.\n' >"$REPO/fixtureClean.md" +# shellcheck disable=SC2034 # stdout captured for timing correctness; content checked via TEL_CLEAN +_OUT_CLEAN="$(cd "$UNRELATED" && printf '{"tool_input":{"file_path":"%s"},"tool_name":"Write"}' "$REPO/fixtureClean.md" \ + | env -u CLAUDE_PROJECT_DIR HOOK_MARKDOWN_FORMAT_ENABLED=true HOOK_TELEMETRY_SINK="$STUB_CLEAN" bash "$HOOK")" +RC_CLEAN=$? +sleep 0.3 + +if [[ $RC_CLEAN -eq 0 ]]; then ok "telemetry/clean: hook exit 0"; else fail "telemetry/clean: hook exit $RC_CLEAN"; fi +if [[ -s "$TEL_CLEAN" ]]; then + STATUS_CLEAN="$(jq -r '.status' "$TEL_CLEAN")" + if [[ "$STATUS_CLEAN" == "ok" ]]; then ok "telemetry/clean: status ok"; else fail "telemetry/clean: status expected ok, got $STATUS_CLEAN"; fi + FINDINGS_CLEAN="$(jq '.data.findings | length' "$TEL_CLEAN")" + if [[ "$FINDINGS_CLEAN" -eq 0 ]]; then ok "telemetry/clean: findings empty array"; else fail "telemetry/clean: findings should be empty, got $FINDINGS_CLEAN items"; fi +else + fail "telemetry/clean: no envelope written" + fail "telemetry/clean: status (no envelope)" + fail "telemetry/clean: findings empty (no envelope)" +fi +rm -f "$TEL_CLEAN" + +# --- Stub sink non-zero exit → format + hook exit 0 unaffected --------------- +FAIL_SINK_FILE="$(mktemp)" +# A sink that exits non-zero but still writes (to prove hook ignores sink failure) +FAIL_SINK="$(make_sink "cat >\"$FAIL_SINK_FILE\"; exit 1")" +printf '# Failing Sink Doc\n\nSome text.\n' >"$REPO/fixtureFailSink.md" +# shellcheck disable=SC2034 # stdout captured for timing correctness; exit code is the assertion +_OUT_FS="$(cd "$UNRELATED" && printf '{"tool_input":{"file_path":"%s"},"tool_name":"Write"}' "$REPO/fixtureFailSink.md" \ + | env -u CLAUDE_PROJECT_DIR HOOK_MARKDOWN_FORMAT_ENABLED=true HOOK_TELEMETRY_SINK="$FAIL_SINK" bash "$HOOK")" +RC_FS=$? +sleep 0.3 + +if [[ $RC_FS -eq 0 ]]; then ok "telemetry/fail-sink: hook exit 0 despite sink failure"; else fail "telemetry/fail-sink: hook exit $RC_FS, expected 0"; fi +rm -f "$FAIL_SINK_FILE" + +# --- Slow sink (C1 detector): hook returns in <<3s --------------------------- +# A sink that sleeps 3s; the hook must return in well under 3s. +SLOW_SINK="$(make_sink "cat >/dev/null; sleep 3")" +printf '# Slow Sink Doc\n\nSome text.\n' >"$REPO/fixtureSlowSink.md" + +TS_SLOW_START=$EPOCHREALTIME +# shellcheck disable=SC2034 # stdout captured so the $(...) blocks until fd1 closes — proves no fd1 leak +_OUT_SLOW="$(cd "$UNRELATED" && printf '{"tool_input":{"file_path":"%s"},"tool_name":"Write"}' "$REPO/fixtureSlowSink.md" \ + | env -u CLAUDE_PROJECT_DIR HOOK_MARKDOWN_FORMAT_ENABLED=true HOOK_TELEMETRY_SINK="$SLOW_SINK" bash "$HOOK")" +RC_SLOW=$? +TS_SLOW_END=$EPOCHREALTIME +SS="${TS_SLOW_START%[.,]*}"; SF="${TS_SLOW_START#*[.,]}" +ES="${TS_SLOW_END%[.,]*}"; EF="${TS_SLOW_END#*[.,]}" +SLOW_MS=$(( (ES * 1000000 + 10#$EF - SS * 1000000 - 10#$SF) / 1000 )) + +echo " (C1 slow-sink elapsed: ${SLOW_MS}ms)" +if [[ $RC_SLOW -eq 0 ]]; then ok "telemetry/slow-sink: hook exit 0"; else fail "telemetry/slow-sink: hook exit $RC_SLOW"; fi +if [[ $SLOW_MS -lt 2000 ]]; then + ok "telemetry/slow-sink: returned in ${SLOW_MS}ms (<<3000ms = C1 passes)" +else + fail "telemetry/slow-sink: returned in ${SLOW_MS}ms — fd1 leak blocks (C1 FAIL)" +fi + +# --- Emit never leaks to hook stdout ----------------------------------------- +# hook stdout must contain ONLY the hookSpecificOutput JSON (for residual case) +# or be empty (clean case). Never the telemetry envelope. +TEL_LEAK="$(mktemp)" +LEAK_SINK="$(make_sink "cat >\"$TEL_LEAK\"")" +printf '# Leak Doc\n\n## Section\n\ntext\n\n## Section\n\nmore text\n' >"$REPO/fixtureLeakCheck.md" +OUT_LEAK="$(cd "$UNRELATED" && printf '{"tool_input":{"file_path":"%s"},"tool_name":"Write"}' "$REPO/fixtureLeakCheck.md" \ + | env -u CLAUDE_PROJECT_DIR HOOK_MARKDOWN_FORMAT_ENABLED=true HOOK_TELEMETRY_SINK="$LEAK_SINK" bash "$HOOK")" +sleep 0.3 + +# The hook stdout must NOT contain the telemetry envelope's top-level keys +if printf '%s' "$OUT_LEAK" | jq -e '.schema_version' >/dev/null 2>&1; then + fail "telemetry/stdout-leak: envelope schema_version found in hook stdout" +elif printf '%s' "$OUT_LEAK" | jq -e '.duration_ms' >/dev/null 2>&1; then + fail "telemetry/stdout-leak: envelope duration_ms found in hook stdout" +else + ok "telemetry/stdout-leak: no envelope in hook stdout" +fi +# Hook stdout must still contain additionalContext (findings present for this fixture) +if printf '%s' "$OUT_LEAK" | jq -e '.hookSpecificOutput.additionalContext' >/dev/null 2>&1; then + ok "telemetry/stdout-leak: additionalContext still present when sink set" +else + fail "telemetry/stdout-leak: additionalContext missing when sink set: $OUT_LEAK" +fi +rm -f "$TEL_LEAK" + echo echo "PASS=$PASS FAIL=$FAIL" [[ $FAIL -eq 0 ]]