Skip to content
Merged
2 changes: 1 addition & 1 deletion plugins/markdown-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": "markdown-format",
"version": "0.6.2",
"version": "0.6.3",
"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
22 changes: 22 additions & 0 deletions plugins/markdown-format/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,28 @@
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.6.3]

### Fixed

- **Out-of-tree Markdown is no longer linted when `CLAUDE_PROJECT_DIR` is
unset.** In an autonomous session whose working directory is not a repository,
`CLAUDE_PROJECT_DIR` is unset and the hook previously linted the `.md`
wherever it lived — including a lane's temporary comment-body composed outside
any repository (e.g. for `gh issue comment --body-file`), firing repo-doc rules
(MD041, MD013) that do not apply to it. The hook now falls back to
git-working-tree membership when `CLAUDE_PROJECT_DIR` is unset: a file under no
git working tree is skipped, while a repository file edited in such a session
is still linted. Membership is decided on the physical path (symlinks
resolved), matching the set-`CLAUDE_PROJECT_DIR` guard, so an in-repository
symlink to an out-of-tree file cannot pull the external target into `--fix`.
Where no canonicalizer is available the membership test fails closed: a
symlink whose physical path could not be resolved is skipped rather than
admitted on its lexical parent. The membership probe also clears Git's
repository-selection and discovery environment variables, so an inherited
`GIT_DIR`/`GIT_WORK_TREE` cannot answer for a directory that is not in a
working tree. Behavior when `CLAUDE_PROJECT_DIR` is set is unchanged.

## [0.6.2]

### Changed
Expand Down
48 changes: 48 additions & 0 deletions plugins/markdown-format/hooks/markdown-format.sh
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,54 @@ case "$FILE" in
*) exit 0 ;;
esac

# Does <dir> sit inside a git working tree? Git's repository-selection and
# discovery environment variables are cleared first: an inherited GIT_DIR or
# GIT_WORK_TREE (a repository wrapper that launched the session) overrides
# discovery outright, so `git -C <out-of-tree dir>` would answer with the
# overridden repository and admit an external file. GIT_COMMON_DIR,
# GIT_CEILING_DIRECTORIES and GIT_DISCOVERY_ACROSS_FILESYSTEM skew the same
# probe in the other direction. The verdict must come from the directory alone,
# never from ambient state. Names per the official environment list —
# https://git-scm.com/docs/git, "The Git Repository" and "Git Discovery".
in_git_working_tree() {
(
unset GIT_DIR GIT_WORK_TREE GIT_COMMON_DIR GIT_CEILING_DIRECTORIES \
GIT_DISCOVERY_ACROSS_FILESYSTEM
git -C "$1" rev-parse --show-toplevel

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle missing Git before treating the file as out-of-tree

When CLAUDE_PROJECT_DIR is unset on a POSIX host without git on PATH, this command fails exactly like a negative membership result, so every Markdown edit is silently skipped even when the file is in a repository and jq and markdownlint-cli2 are available. Previously hook::repo_root tolerated Git being unavailable by falling back to the file directory, and neither the README requirements nor the setup check requires Git; either preserve that behavior or make Git an explicit, visibly checked prerequisite rather than interpreting command-not-found as out-of-tree.

Useful? React with 👍 / 👎.

) >/dev/null 2>&1
}

# 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
# tree (a lane's comment-body composed for gh --body-file) must not be linted
# with rules that do not apply to it. When CLAUDE_PROJECT_DIR is set,
# hook::read_file_path already enforced membership. This scoping is local to
# markdown-format on purpose — the shared guard stays location-agnostic for
# hooks whose value does not depend on repository membership.
#
# Membership is tested on the PHYSICAL path, matching hook::read_file_path: an
# in-repository symlink to an out-of-tree file would otherwise pass on its
# lexical parent while --fix rewrites the external target under repo rules.
#
# hook::physical_path degrades to the unchanged lexical path when no
# canonicalizer resolves it (neither realpath nor readlink -f present, or both
# failing). That degradation is safe for the shared prefix guard but not here:
# the lexical parent of an escaping symlink IS a working tree, so admitting it
# hands the external target to --fix. A symlink whose physical path came back
# unchanged is therefore the observable signature of failed canonicalization —
# checking the outcome rather than probing for a resolver also covers a
# resolver that exists but fails — and this scope fails closed on it.
if [[ -z "${CLAUDE_PROJECT_DIR:-}" ]]; then
FILE_PHYSICAL="$(hook::physical_path "$FILE")"
if [[ -L "$FILE" && "$FILE_PHYSICAL" == "$FILE" ]]; then
exit 0
fi
if ! in_git_working_tree "$(dirname "$FILE_PHYSICAL")"; 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.
REPO_ROOT="$(hook::repo_root "$(dirname "$FILE")")"
Expand Down
133 changes: 133 additions & 0 deletions plugins/markdown-format/hooks/markdown-format.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,139 @@ else
fail "missing .md not skipped (rc=$RC_M out=$OUT_M)"
fi

# --- Git-tree scoping: CLAUDE_PROJECT_DIR unset, file outside any git tree ----
# Regression for out-of-tree scratchpad-lint noise: with CLAUDE_PROJECT_DIR
# unset (run_hook already unsets it), a .md written outside any git working tree
# (a lane's temp comment-body) is skipped entirely — no --fix applied, no
# findings. A temp dir that happens to sit inside a git tree on this host would
# pass vacuously, so assert the fixture is genuinely out-of-tree first. The
# in-tree counterpart (unset dir, file inside a git tree still linted) is
# covered by every fixture above: they all live in $REPO, a git working tree.
OUTOFTREE="$(mktemp -d)"
if git -C "$OUTOFTREE" rev-parse --show-toplevel >/dev/null 2>&1; then
ok "out-of-tree scratchpad case SKIPPED (temp dir sits inside a git tree on this host)"
else
SCRATCH="$OUTOFTREE/comment-body.md"
# A fixable issue (MD004 star marker + MD047 missing final newline) the hook
# WOULD apply if it ran — so an unmodified file proves the skip.
printf '# Comment\n\n* bullet' >"$SCRATCH"
SCRATCH_BEFORE="$(cat "$SCRATCH")"
OUT_SCRATCH="$(run_hook "$SCRATCH")"
RC_SCRATCH=$?
if [[ $RC_SCRATCH -eq 0 && -z "$OUT_SCRATCH" ]]; then
ok "out-of-tree scratchpad .md skipped (exit 0, no findings)"
else
fail "out-of-tree scratchpad .md not skipped (rc=$RC_SCRATCH out=$OUT_SCRATCH)"
fi
if [[ "$(cat "$SCRATCH")" == "$SCRATCH_BEFORE" ]]; then
ok "out-of-tree scratchpad .md left unmodified (no --fix)"
else
fail "out-of-tree scratchpad .md was modified: $(cat "$SCRATCH")"
fi

# Inherited repository overrides must not decide membership. GIT_DIR and
# GIT_WORK_TREE override Git's discovery outright, so `git -C <out-of-tree
# dir>` answers with the overridden repository — the same out-of-tree file
# would be admitted and linted under that repository's rules. The fixture
# carries an unfixable MD024 so a hook that DID run is visible in stdout.
SCRATCH_GITDIR="$OUTOFTREE/comment-body-gitdir.md"
printf '# Comment\n\n## Section\n\ntext\n\n## Section\n\n* bullet\n' >"$SCRATCH_GITDIR"
OUT_GITDIR="$(cd "$UNRELATED" && printf '{"tool_input":{"file_path":"%s"}}' "$SCRATCH_GITDIR" |
env -u CLAUDE_PROJECT_DIR GIT_DIR="$REPO/.git" GIT_WORK_TREE="$REPO" \
CLAUDE_PLUGIN_OPTION_MARKDOWN_FORMAT_ENABLED=true bash "$HOOK")"
RC_GITDIR=$?
if [[ $RC_GITDIR -eq 0 && -z "$OUT_GITDIR" ]]; then
ok "inherited GIT_DIR/GIT_WORK_TREE does not admit an out-of-tree .md"
else
fail "inherited GIT_DIR/GIT_WORK_TREE admitted an out-of-tree .md (rc=$RC_GITDIR out=$OUT_GITDIR)"
fi
if grep -q '^\* bullet$' "$SCRATCH_GITDIR"; then
ok "out-of-tree .md unmodified under inherited GIT_DIR (no --fix)"
else
fail "out-of-tree .md was fixed under inherited GIT_DIR: $(cat "$SCRATCH_GITDIR")"
fi

# Symlink escape: an IN-repository path whose target is the out-of-tree file.
# The lexical parent ($REPO) is a git tree, so a lexical membership test would
# admit it and --fix would rewrite the external target under repo rules. The
# guard must decide on the physical path, as hook::read_file_path does.
# Skipped where the host cannot create real symlinks (Git Bash without
# winsymlinks copies the file instead).
LINK="$REPO/escaping-link.md"
if ln -s "$SCRATCH" "$LINK" 2>/dev/null && [[ -L "$LINK" ]]; then
LINK_BEFORE="$(cat "$SCRATCH")"
OUT_LINK="$(run_hook "$LINK")"
RC_LINK=$?
if [[ $RC_LINK -eq 0 && -z "$OUT_LINK" ]]; then
ok "in-repo symlink to out-of-tree .md skipped (exit 0, no findings)"
else
fail "in-repo symlink to out-of-tree .md not skipped (rc=$RC_LINK out=$OUT_LINK)"
fi
if [[ "$(cat "$SCRATCH")" == "$LINK_BEFORE" ]]; then
ok "symlink target left unmodified (no --fix on the external file)"
else
fail "symlink target was modified: $(cat "$SCRATCH")"
fi
# Same escape with BOTH canonicalizers neutered, so hook::physical_path
# degrades to the unchanged lexical path — whose parent ($REPO) IS a git
# tree. A physical-path test alone would admit the link there and hand the
# external target to --fix, so the guard must fail closed on an unresolved
# symlink instead.
#
# The fixture carries a duplicate sibling heading (MD024, unfixable under
# this repo's config) so a hook that DID run emits additionalContext: the
# skip is proven by silence, not by an unchanged file. File content alone
# would be a vacuous witness — the stub's `sed -i` replaces the link with a
# regular file instead of writing through it, leaving the target intact
# even when --fix ran.
NO_CANON_ENV="$WORK/no-canonicalizer.bashenv"
cat >"$NO_CANON_ENV" <<'EOF'
realpath() { return 1; }
readlink() { return 1; }
EOF
run_hook_no_canon() {
(cd "$UNRELATED" && printf '{"tool_input":{"file_path":"%s"}}' "$1" |
env -u CLAUDE_PROJECT_DIR BASH_ENV="$NO_CANON_ENV" \
CLAUDE_PLUGIN_OPTION_MARKDOWN_FORMAT_ENABLED=true bash "$HOOK")
}
SCRATCH_NC="$OUTOFTREE/comment-body-nocanon.md"
printf '# Comment\n\n## Section\n\ntext\n\n## Section\n\n* bullet\n' >"$SCRATCH_NC"
LINK_NC="$REPO/escaping-link-nocanon.md"
ln -s "$SCRATCH_NC" "$LINK_NC"
OUT_NOCANON="$(run_hook_no_canon "$LINK_NC")"
RC_NOCANON=$?
if [[ $RC_NOCANON -eq 0 && -z "$OUT_NOCANON" ]]; then
ok "symlink escape fails closed when canonicalization is unavailable"
else
fail "symlink escape not skipped without canonicalizer (rc=$RC_NOCANON out=$OUT_NOCANON)"
fi
if [[ -L "$LINK_NC" ]]; then
ok "escaping symlink untouched without canonicalizer (still a symlink)"
else
fail "escaping symlink was rewritten by --fix without canonicalizer"
fi

# Control: the neutered canonicalizers must not disable the hook wholesale,
# or the silence above would prove nothing. The SAME fixture body in a
# REGULAR in-repository file still lints and still reports MD024.
NOCANON_REGULAR="$REPO/fixtureNoCanon.md"
printf '# Comment\n\n## Section\n\ntext\n\n## Section\n\n* bullet\n' >"$NOCANON_REGULAR"
OUT_NOCANON_REG="$(run_hook_no_canon "$NOCANON_REGULAR")"
RC_NOCANON_REG=$?
if [[ $RC_NOCANON_REG -eq 0 ]] &&
printf '%s' "$OUT_NOCANON_REG" | jq -e '.hookSpecificOutput.additionalContext | test("MD024")' >/dev/null 2>&1; then
ok "regular in-repo .md still linted without canonicalizer (control)"
else
fail "control failed: regular in-repo .md not linted without canonicalizer (rc=$RC_NOCANON_REG out=$OUT_NOCANON_REG)"
fi
rm -f "$LINK_NC"
else
ok "symlink-escape case SKIPPED (host cannot create real symlinks)"
fi
rm -f "$LINK"
fi
rm -rf "$OUTOFTREE"

# --- Repository-local markdownlint: use contained npm/Git Bash shim ---------
# Hide the PATH copy, then provide the extensionless POSIX shim npm installs
# beside its Windows .cmd launcher. The hook must execute it directly from the
Expand Down
Loading