diff --git a/plugins/markdown-format/.claude-plugin/plugin.json b/plugins/markdown-format/.claude-plugin/plugin.json index fe360431e..81a94b6e3 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.6", + "version": "0.11.7", "description": "Auto-format and lint Markdown on edit via markdownlint-cli2 — 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 c5776e9b7..decc1bd27 100644 --- a/plugins/markdown-format/CHANGELOG.md +++ b/plugins/markdown-format/CHANGELOG.md @@ -3,6 +3,57 @@ 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.7] + +### Fixed + +- **Two no-git cases 0.11.1 left open: a file below the root with no `CLAUDE_PROJECT_DIR`, and the + opt-in pre-check.** 0.11.1 resolved the root from `CLAUDE_PROJECT_DIR` when the git probe could not + answer. That covers an anchored session, but not the configuration the fix is about: the + working-tree membership scope is gated on `CLAUDE_PROJECT_DIR` being **unset**, and the no-git + regression fixture runs unset — so a root read off that variable cannot serve it, and a nested + `.md` on a git-less host with no harness anchor was still skipped silently. The opt-in + **pre-check**, which runs before `jq` exists, still resolved its root the old way as well: with + `git` and `jq` both absent, a nested file made it read a repository that had opted in as one that + never did, swallowing the `jq` notice it was owed. + + The root is now resolved from the filesystem when git cannot answer, by the walk git's own + discovery performs: upward from the edited file for a `.git` entry, accepted as a directory for an + ordinary clone or as a **file** for a linked worktree or submodule + ([gitrepository-layout](https://git-scm.com/docs/gitrepository-layout)). git's answer is returned + untouched whenever git produced one, so a host that has git is unaffected. `CLAUDE_PROJECT_DIR` + remains below that as the last resort, for a project that is no working tree at all — an unpacked + archive, a vendored copy — and only ever as the walk's terminator, never to widen scope, so the + fail-closed reasoning in `markdownlint_config_discoverable` is unchanged. When nothing resolves, + the previous hint stands, which keeps 0.11.1's out-of-tree bound true. + +- **An escaping symlink can no longer hand its out-of-tree target to `--fix` on a git-less host.** + Resolving the root from the filesystem makes discovery SUCCEED where it previously failed, and + success is what puts a file in front of `--fix` — so for an in-repository symlink whose target + lives outside the tree, the repository's own config opened the gate and the linter followed the + link and rewrote a file outside the repository. Without git this scope could not ask + `in_git_working_tree` anything, so containment went unchecked entirely; a symlink is precisely the + shape whose lexical parent (inside the repository) and physical parent (outside it) disagree. + + Containment is now decided from the filesystem when git cannot answer, instead of being skipped. + The check runs only where the physical path differs from the lexical one — which for an ordinary + file it never does — so a git-less repository lints exactly as before; an undecidable *git* verdict + still lints, while an escape the filesystem can prove does not. Both operands are canonicalized + through `cd … && pwd -P`, the spelling `markdownlint_config_discoverable` and `CONFIG_ROOT` already + compare in: `hook::physical_path` resolves via `realpath`, which leaves `/tmp` as `/tmp` where + `pwd -P` resolves it to the underlying directory, so comparing one against the other would be a + spelling mismatch rather than a containment answer. + + The root-level form of the same escape was reachable before this release too — there the old + resolution already returned the repository root, so discovery already succeeded — and is closed by + the same check. + + This also retires the `"$REPO_ROOT" == "$(dirname "$FILE")"` guard, which was true only for a file + at the repository root: it spawned a second `git rev-parse` there wherever the payload's path + spelling matched git's own, and was false for every nested file, which is why 0.11.1's test file + records that the guard's inertness could not be made behaviourally observable. There is no longer + an untestable branch to observe. + ## [0.11.6] ### Changed diff --git a/plugins/markdown-format/hooks/markdown-format.sh b/plugins/markdown-format/hooks/markdown-format.sh index d8cbc8258..91b12dc50 100755 --- a/plugins/markdown-format/hooks/markdown-format.sh +++ b/plugins/markdown-format/hooks/markdown-format.sh @@ -94,6 +94,75 @@ markdownlint_config_discoverable() { done } +# Resolve the working-tree root for a path WITHOUT requiring git. +# +# When git cannot answer, hook::repo_root falls back to the HINT — the edited +# file's own directory. For a path genuinely outside every repository that is +# the right answer. Inside one it is not: every root-anchored walk in this hook +# then begins and ends in that directory, a repository whose markdownlint config +# sits at its root stops linting everything below the root, and the edit is +# skipped silently. That is the ordinary docs layout, so the case the membership +# scope below is meant to restore stays broken for most files in it. git is not +# a documented requirement of this hook — README "Requirements" lists Bash, jq +# and markdownlint-cli2 — so resolving the root must not need one. +# +# The fallback is recognized by its own signature: an answer equal to the hint, +# or to the hint with a trailing `.claude` stripped. hook::repo_root strips that +# suffix in BOTH spellings it can arrive in — `/.claude` and the Windows +# `\.claude` — so both are checked here; testing only the forward-slash form +# would leave a backslash-spelled hint unrecognized as a fallback and send it +# back out unresolved. git's answer is returned untouched whenever git produced +# one, so a host that HAS git is unaffected and no second probe is spawned to +# find that out. +# +# Otherwise the root comes from the walk git's own discovery performs: upward +# for a `.git` entry, accepted as a directory (ordinary clone) or as a FILE, +# which is what a linked worktree and a submodule write instead +# (https://git-scm.com/docs/gitrepository-layout, "$GIT_DIR", fetched +# 2026-08-09). Reading the filesystem rather than asking git also means an +# inherited GIT_DIR/GIT_WORK_TREE cannot make some other repository answer — +# the same protection in_git_working_tree and file_is_gitignored buy by +# unsetting those, here for free. +# +# CLAUDE_PROJECT_DIR is the last resort, for a project that is no working tree +# at all (an unpacked archive, a vendored copy): the harness's own answer to the +# same question, needing no git. It is only ever the walk's TERMINATOR, never a +# widening of scope — discovery still starts at the file — so the fail-closed +# reasoning in markdownlint_config_discoverable is unchanged. It ranks below the +# `.git` walk so the root stays the working-tree top, which is what git itself +# would have returned had it been available. +# +# When nothing resolves, the hint stands exactly as before, which is what keeps +# the out-of-tree bound documented under the membership scope true. +resolve_repo_root() { + local hint="$1" root dir parent + root="$(hook::repo_root "$hint")" + if [[ "$root" != "$hint" && "$root" != "${hint%/.claude}" && + "$root" != "${hint%\\.claude}" ]]; then + printf '%s' "$root" + return 0 + fi + # Physical, matching CONFIG_ROOT and markdownlint_config_discoverable, which + # both compare `pwd -P` results: a lexically-spelled root would never compare + # equal to the walk's cursor and the search would run past the repository. + if dir="$(cd "$hint" 2>/dev/null && pwd -P)"; then + while :; do + if [[ -e "$dir/.git" ]]; then + printf '%s' "$dir" + return 0 + fi + parent="$(dirname "$dir")" + [[ "$parent" != "$dir" ]] || break + dir="$parent" + done + fi + if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then + printf '%s' "$CLAUDE_PROJECT_DIR" + return 0 + fi + printf '%s' "$root" +} + # jq is required to parse Claude Code's hook payload and to emit structured # PostToolUse context. Absent → visible once-per-session skip notice on both # the agent and user channels, exit 0 — but only for a repository that opted @@ -116,7 +185,7 @@ if ! command -v jq >/dev/null 2>&1; then DECODED_FILE="${DECODED_FILE//\\\\/\\}" if [[ -f "$DECODED_FILE" ]] && ! markdownlint_config_discoverable "$DECODED_FILE" \ - "$(hook::repo_root "$(dirname "$DECODED_FILE")")"; then + "$(resolve_repo_root "$(dirname "$DECODED_FILE")")"; then # silent-skip-ok: this exit reports the opt-in verdict, not a jq verdict — # the repository never enabled this hook, so it is owed no notice about a # prerequisite for it. jq's absence stays visible for every repository that @@ -149,6 +218,20 @@ in_git_working_tree() { ) >/dev/null 2>&1 } +# Is $1's directory $2, or below it? Both sides go through `cd … && pwd -P`, so +# a link, a `..`, or a differing spelling on either side cannot make a +# containment answer disagree with the filesystem. Fails CLOSED — an +# undecidable containment answer is not a licence to write. +physically_inside() { + local file_dir root + file_dir="$(cd "$(dirname "$1")" 2>/dev/null && pwd -P)" || return 1 + root="$(cd "$2" 2>/dev/null && pwd -P)" || return 1 + case "$file_dir" in + "$root" | "$root"/*) return 0 ;; + *) return 1 ;; + esac +} + # markdownlint applies repo-doc rules, so when no CLAUDE_PROJECT_DIR anchors # membership (e.g. an autonomous session whose cwd is not a repo), scope to # git-working-tree containment instead: a scratch/temp .md outside any working @@ -181,61 +264,46 @@ in_git_working_tree() { # therefore lints, the same direction file_is_gitignored takes below for the # same reason. # -# Exposure of that fail-open is bounded by the consumer opt-in gate, not by -# this scope: without git, hook::repo_root cannot resolve a working-tree top -# and falls back to the edited file's own directory, so -# markdownlint_config_discoverable searches that single directory — a scratch -# `/tmp/comment-body.md` still does not lint unless `/tmp` itself carries a -# markdownlint config. The noise class this scope exists to stop stays stopped -# wherever git can actually answer. +# The fail-open is bounded, but NOT by the opt-in gate alone, and the escaping +# symlink is exactly where that distinction bites. Without git this scope cannot +# ask `in_git_working_tree` anything, so containment goes unchecked — and a +# symlink is the one shape whose lexical parent (inside the repository, where +# the root config lives) and physical parent (outside it) disagree. Discovery +# then opens the gate on the repository's own config and `--fix` follows the +# link and rewrites a file outside the tree. +# +# So when git cannot answer, containment is decided from the filesystem instead +# — the same source resolve_repo_root already uses — rather than skipped. This +# does NOT re-break the git-less host: the check runs only when the physical +# path differs from the lexical one, which for an ordinary file it never does, +# so a git-less repository lints exactly as before. An undecidable GIT verdict +# still lints; an escape that the filesystem can prove does not. +# +# What remains bounded by the opt-in gate is the ordinary out-of-tree file: a +# scratch `/tmp/comment-body.md` sits under no working tree, so the walk finds +# no `.git`, discovery searches its own directory alone, and it does not lint +# unless `/tmp` itself carries a markdownlint config. +# +# REPO_ROOT is resolved before the scope rather than after it because the scope +# now needs it. It depends only on FILE, so moving it earlier changes nothing +# about its value, and it is computed once for both. +REPO_ROOT="$(resolve_repo_root "$(dirname "$FILE")")" + if [[ -z "${CLAUDE_PROJECT_DIR:-}" ]]; then FILE_PHYSICAL="$(hook::physical_path "$FILE")" if [[ -L "$FILE" && "$FILE_PHYSICAL" == "$FILE" ]]; then exit 0 fi - if command -v git >/dev/null 2>&1 && - ! in_git_working_tree "$(dirname "$FILE_PHYSICAL")"; then + if command -v git >/dev/null 2>&1; then + if ! in_git_working_tree "$(dirname "$FILE_PHYSICAL")"; then + exit 0 + fi + elif [[ "$FILE_PHYSICAL" != "$FILE" ]] && + ! physically_inside "$FILE_PHYSICAL" "$REPO_ROOT"; then exit 0 fi fi -# Resolve repo root early — needed for CWD-anchored config discovery and for -# computing the schema-required repo-relative path in data.file. -# -# Without git, hook::repo_root has no working-tree top to return and falls back -# to the HINT — the edited file's own directory. That silently narrows config -# discovery's upward walk to a single directory, so a repository whose -# markdownlint config sits at its root stops linting every file below the root: -# the ordinary docs layout, and the case the membership-scope fix above is -# otherwise supposed to restore. `CLAUDE_PROJECT_DIR` is the harness's own -# answer to the same question and needs no git, so prefer it when the git probe -# came back empty-handed. It is used ONLY as the walk's terminator, never to -# widen scope: discovery still starts at the file and still stops at a root, so -# the fail-closed reasoning in markdownlint_config_discoverable is unchanged. -# -# The test is whether git ACTUALLY resolves a toplevel here, not whether a -# `git` word exists: `command -v git` answers yes for a shell function, for a -# stub on PATH, and for a real binary standing in a directory that is no -# repository — all cases where hook::repo_root still returns the hint. Probing -# the capability directly is the only form that covers them. -# The probe clears the git discovery/selection environment for the same reason -# in_git_working_tree and file_is_gitignored do: an inherited GIT_DIR or -# GIT_WORK_TREE from whatever launched the session would let some OTHER -# repository answer the question, and here a spurious success is the harmful -# direction — it withholds the CLAUDE_PROJECT_DIR fallback and leaves the walk -# terminating at the file's own directory, which is the bug this block exists -# to fix. Cleared in a subshell so the surrounding process keeps its own -# environment. -REPO_ROOT="$(hook::repo_root "$(dirname "$FILE")")" -if [[ -n "${CLAUDE_PROJECT_DIR:-}" && "$REPO_ROOT" == "$(dirname "$FILE")" ]] && - ! ( - unset GIT_DIR GIT_WORK_TREE GIT_COMMON_DIR GIT_CEILING_DIRECTORIES \ - GIT_DISCOVERY_ACROSS_FILESYSTEM - git -C "$(dirname "$FILE")" rev-parse --show-toplevel - ) >/dev/null 2>&1; then - REPO_ROOT="$CLAUDE_PROJECT_DIR" -fi - # 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 diff --git a/plugins/markdown-format/hooks/markdown-format.test.sh b/plugins/markdown-format/hooks/markdown-format.test.sh index 3803524ee..a1b342525 100755 --- a/plugins/markdown-format/hooks/markdown-format.test.sh +++ b/plugins/markdown-format/hooks/markdown-format.test.sh @@ -509,6 +509,139 @@ else fail "git absent: nested .md skipped — config discovery never left its own directory (rc=$RC_NOGIT_NEST out=$OUT_NOGIT_NEST)" fi +# The same nesting with CLAUDE_PROJECT_DIR UNSET. That is not an exotic variant: +# the membership scope this PR exists to fix is itself gated on +# `[[ -z "${CLAUDE_PROJECT_DIR:-}" ]]`, and the primary no-git fixture above runs +# unset — so unset is the configuration the fix is ABOUT, and a root resolved +# from CLAUDE_PROJECT_DIR cannot serve it. Nothing but the filesystem can anchor +# the root here. The scope above already declines to skip when git cannot +# answer, so this run reaches config discovery and stands or falls on root +# resolution alone. +NOGIT_NESTED_UNSET="$REPO/docs/fixtureNoGitNestedUnanchored.md" +printf '# No Git Nested Unanchored\n\n* star item\n' >"$NOGIT_NESTED_UNSET" +OUT_NOGIT_NEST_U="$(run_hook_no_git "$NOGIT_NESTED_UNSET")" +RC_NOGIT_NEST_U=$? +if [[ $RC_NOGIT_NEST_U -eq 0 ]] && grep -q '^- star item$' "$NOGIT_NESTED_UNSET"; then + ok "git absent + CLAUDE_PROJECT_DIR unset: a NESTED .md still reaches the root config" +else + fail "git absent + unset: nested .md skipped, nothing anchored the root (rc=$RC_NOGIT_NEST_U out=$OUT_NOGIT_NEST_U)" +fi + +# The last resort, and the only case where CLAUDE_PROJECT_DIR decides the root: +# a project that is no working tree at all — an unpacked archive, a vendored +# copy — where the filesystem walk finds no `.git` to stop at. Every other +# fixture here lives in a real git tree, so nothing else can reach this branch. +# The fixture is deliberately NOT under $REPO for that reason. +NOVCS="$WORK/no-vcs-project" +mkdir -p "$NOVCS/docs" +cat >"$NOVCS/.markdownlint.json" <<'JSON' +{ "MD004": { "style": "dash" } } +JSON +NOVCS_FIXTURE="$NOVCS/docs/archived.md" +printf '# Archived\n\n* star item\n' >"$NOVCS_FIXTURE" +OUT_NOVCS="$(cd "$UNRELATED" && printf '{"tool_input":{"file_path":"%s"}}' "$NOVCS_FIXTURE" | + env BASH_ENV="$NO_GIT_ENV" CLAUDE_PROJECT_DIR="$NOVCS" \ + CLAUDE_PLUGIN_OPTION_MARKDOWN_FORMAT_ENABLED=true bash "$HOOK")" +RC_NOVCS=$? +if [[ $RC_NOVCS -eq 0 ]] && grep -q '^- star item$' "$NOVCS_FIXTURE"; then + ok "no working tree anywhere: CLAUDE_PROJECT_DIR is the root of last resort" +else + fail "no working tree: nested .md skipped despite CLAUDE_PROJECT_DIR (rc=$RC_NOVCS out=$OUT_NOVCS)" +fi + +# make_symlink → 0 only if a REAL symlink now exists at . +# Plain `ln -s` under Git Bash's default MSYS settings COPIES the file, which is +# why the escape cases earlier in this file skip on Windows. nativestrict asks +# for a real NTFS symlink and succeeds wherever the host allows it (Developer +# Mode, or the create-symlink privilege), so the escape shapes below are +# exercised on hosts the bare-`ln -s` probe writes off. +make_symlink() { + MSYS=winsymlinks:nativestrict ln -s "$1" "$2" 2>/dev/null || + ln -s "$1" "$2" 2>/dev/null + [[ -L "$2" ]] +} + +# Escaping symlink with git ABSENT. The escape cases earlier run with git +# present, where in_git_working_tree decides containment on the physical path +# and skips. Without git that question could not be asked at all, so containment +# went unchecked — and resolving the root from the filesystem is precisely what +# makes discovery SUCCEED here, opening the repository's own config for a file +# whose bytes live outside the tree. Both nestings are covered: nested (reachable +# only once the root walk exists) and root-level (reachable before it too). +# +# ASSERTED ON LINK SURVIVAL, not on the target's bytes, and the difference is +# the whole discriminating power of these two cases. The stub linter at the top +# of this file rewrites with `sed -i`, which renames a temp over the path and so +# REPLACES a symlink with a regular file while leaving the target untouched — +# verified on this host. Real markdownlint-cli2 is a Node process whose +# fs.writeFile follows the link and rewrites the target instead. So under this +# stub an unchanged target proves nothing (it is unchanged either way), while a +# surviving symlink proves the hook never ran --fix on it at all. +NOGIT_OUTSIDE="$WORK/outside-nogit" +mkdir -p "$NOGIT_OUTSIDE" + +for _case in nested root; do + case "$_case" in + nested) _link="$REPO/docs/escapeNoGitNested.md" ;; + root) _link="$REPO/escapeNoGitRoot.md" ;; + # Unreachable from the loop above, and present because CI runs ShellCheck with + # no severity floor, so SC2249 (info) fails the lane. Exiting rather than + # falling through: a silently unhandled case here would report a PASS for a + # symlink case that never ran. + *) + echo "unhandled escape case: $_case" >&2 + exit 1 + ;; + esac + _ext="$NOGIT_OUTSIDE/external-$_case.md" + printf '# External\n\n* star item\n' >"$_ext" + if make_symlink "$_ext" "$_link"; then + _ext_before="$(cat "$_ext")" + OUT_ESC="$(run_hook_no_git "$_link")" + RC_ESC=$? + if [[ $RC_ESC -eq 0 && -L "$_link" && "$(cat "$_ext")" == "$_ext_before" ]]; then + ok "git absent: a $_case escaping symlink is skipped, not handed to --fix" + else + fail "git absent: --fix reached a $_case symlink's out-of-tree target (rc=$RC_ESC link-intact=$([[ -L "$_link" ]] && echo yes || echo no) out=$OUT_ESC)" + fi + rm -f "$_link" + else + ok "git absent $_case symlink-escape case SKIPPED (host cannot create real symlinks)" + fi +done + +# CONTROL for the three skips above: the containment check must admit as well as +# reject. Every git-absent symlink case in this file wants a SKIP, so a +# `physically_inside` that regressed to always-false — an off-by-one in its +# `case` prefix match, a stray early `return 1` — would satisfy all of them +# while silently costing every legitimately in-repo symlinked .md its --fix on a +# git-less host. Only a symlink whose target resolves INSIDE the repository +# separates the two. +# +# ASSERTED ON THE LINK BEING GONE, and that is the discriminating part. The stub +# linter rewrites with `sed -i`, which renames a temp over the path it was +# handed, so a --fix that ran REPLACES the symlink at $NOGIT_INSIDE_LINK with a +# regular file and leaves the target's bytes alone — the exact inverse of the +# escape cases above, which assert the link SURVIVES. Checking the target's +# bytes here would therefore fail against correct code. `! -L` plus the fixed +# bytes cannot be produced by a skip, which leaves the link intact and unfixed. +NOGIT_INSIDE_TARGET="$REPO/docs/insideNoGitTarget.md" +NOGIT_INSIDE_LINK="$REPO/docs/insideNoGitLink.md" +printf '# Inside\n\n* inside item\n' >"$NOGIT_INSIDE_TARGET" +if make_symlink "$NOGIT_INSIDE_TARGET" "$NOGIT_INSIDE_LINK"; then + OUT_INSIDE="$(run_hook_no_git "$NOGIT_INSIDE_LINK")" + RC_INSIDE=$? + if [[ $RC_INSIDE -eq 0 && ! -L "$NOGIT_INSIDE_LINK" ]] && + grep -q '^- inside item$' "$NOGIT_INSIDE_LINK"; then + ok "git absent: a symlink resolving inside the repo still gets --fix" + else + fail "git absent: an in-repo symlinked .md was skipped instead of linted (rc=$RC_INSIDE link-intact=$([[ -L "$NOGIT_INSIDE_LINK" ]] && echo yes || echo no) out=$OUT_INSIDE)" + fi + rm -f "$NOGIT_INSIDE_LINK" +else + ok "git absent in-repo symlink control SKIPPED (host cannot create real symlinks)" +fi + # The scope must still fire when git CAN answer — that half is the out-of-tree # case above, which runs with git present and asserts the skip. # @@ -808,6 +941,42 @@ else fail "missing jq in a config-less repo emitted output (rc=$RC_NO_JQ_NOCFG out=$OUT_NO_JQ_NOCFG)" fi +# --- Missing jq AND missing git, nested file: the notice must still be owed --- +# The opt-in pre-check resolves its own repo root, on the one path that runs +# before jq exists. It is the same resolution as the authoritative gate's and it +# carries the same git dependency, but no other case in this file can see it: +# every jq-absence case above runs with git present, and every git-absence case +# runs with jq present. Together the two shims put a nested file on that path, +# where a root that collapses to the file's own directory reads a repository +# that DID opt in as one that never did — and then swallows the jq notice it is +# owed, silently. The config-less pair above pins the other direction, so the +# assertion cannot pass by the hook simply having stopped warning. +NO_JQ_NO_GIT_ENV="$WORK/no-jq-no-git.bashenv" +cat >"$NO_JQ_NO_GIT_ENV" <<'EOF' +command() { + if [[ "${1:-}" == "-v" && ("${2:-}" == "jq" || "${2:-}" == "git") ]]; then + return 1 + fi + builtin command "$@" +} +git() { + printf 'bash: git: command not found\n' >&2 + return 127 +} +EOF +PD_NO_JQ_NOGIT="$(mktemp -d "$WORK/pd.XXXXXX")" +NOJQ_NESTED="$REPO/docs/fixtureNoJqNoGitNested.md" +printf '# No Jq No Git Nested\n\n* star item\n' >"$NOJQ_NESTED" +OUT_NO_JQ_NOGIT="$(cd "$UNRELATED" && printf '{"tool_input":{"file_path":"%s"}}' "$NOJQ_NESTED" | + env -u CLAUDE_PROJECT_DIR BASH_ENV="$NO_JQ_NO_GIT_ENV" CLAUDE_PLUGIN_DATA="$PD_NO_JQ_NOGIT" CLAUDE_PLUGIN_OPTION_MARKDOWN_FORMAT_ENABLED=true bash "$HOOK")" +RC_NO_JQ_NOGIT=$? +if [[ $RC_NO_JQ_NOGIT -eq 0 ]] && + printf '%s' "$OUT_NO_JQ_NOGIT" | grep -q 'jq not found on PATH'; then + ok "missing jq + missing git, nested .md -> the opt-in pre-check still finds the root config and the notice is emitted" +else + fail "missing jq + missing git, nested .md -> notice swallowed, the pre-check read an opted-in repo as opted-out (rc=$RC_NO_JQ_NOGIT out=$OUT_NO_JQ_NOGIT)" +fi + # --- Repository-config trust gate: risky config blocks lint until approved --- # Uses the official persistent plugin-data surface so separate hook processes # share the approval marker. A code-loading configuration must SKIP the lint