diff --git a/plugins/ai-slop/.claude-plugin/plugin.json b/plugins/ai-slop/.claude-plugin/plugin.json index 0d26914cbb..e9bd84d1a6 100644 --- a/plugins/ai-slop/.claude-plugin/plugin.json +++ b/plugins/ai-slop/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "ai-slop", - "version": "0.3.4", + "version": "0.3.6", "description": "Detects and removes AI-writing tells (slop) in checked-in markdown prose: em dashes, emoji formatting, AI vocabulary, negative parallelisms, chatbot phrases, filler, stacked hedging, citation artifacts, and the rest of a catalog distilled from Wikipedia's Signs of AI writing. Read-only audit by default with a deterministic detector plus a judgment rubric; an explicit fix action rewrites findings behind a semantic-diff guard. Findings conform to the detector-findings convention so the review fanout fix relay can consume them.", "author": { "name": "Melodic Software", diff --git a/plugins/ai-slop/CHANGELOG.md b/plugins/ai-slop/CHANGELOG.md index aa83381872..019d08e86b 100644 --- a/plugins/ai-slop/CHANGELOG.md +++ b/plugins/ai-slop/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## [0.3.6] + +- **`emit-findings.sh` double-escaped a pipe the source already escaped, and left Location + absolute when git and `pwd` spelled the same repo root differently.** Both defects were + identified in #3180 and fixed for `docs-hygiene:audit-noise` in #3202; this copy was not + reached. A naive `gsub(/\|/, "\\|")` turns `a \| b` into `a \\| b`, which GFM reads as a + literal backslash followed by a live delimiter, so the row splits and the fix action + misreads it. `esc()` now walks each backslash run and adds a delimiter escape only when + the run length is even, so `a\\|b` (even) becomes a live-safe `a\\\|b` instead of + restoring the original. Separately, + `git rev-parse --show-toplevel` can answer Git's Windows-drive spelling while the caller is at `/tmp/…` + (Git Bash). This producer failed OPEN: Location stayed absolute and nothing reported it, + because an absolute path is still a well-formed cell. Root resolution now prefers the + caller's own `pwd` (minus git's `--show-prefix`) and keeps git's two spellings as + fallbacks. Shared code was considered and declined: plugins are portable and there is no + existing cross-plugin emit-findings library; the three copies now agree on the same two + helpers instead. + ## [0.3.4] - **The README now points back at the upstream ledger.** `docs/upstream/cursor-pstack.md` names diff --git a/plugins/ai-slop/skills/audit/scripts/detect.test.sh b/plugins/ai-slop/skills/audit/scripts/detect.test.sh index 8fafd56a62..1ef8e47a14 100755 --- a/plugins/ai-slop/skills/audit/scripts/detect.test.sh +++ b/plugins/ai-slop/skills/audit/scripts/detect.test.sh @@ -467,6 +467,45 @@ EOF bash "$EMIT" --from "$SYNTH" --out "$TEST_TMPDIR/findings/synth.md" --branch test-branch >/dev/null 2>&1 assert_contains "emit: pipe escaped in cell" "$(cat "$TEST_TMPDIR/findings/synth.md")" 'zero\|tolerance' +# A naive gsub double-escapes a pipe the SOURCE already escaped (`a \| b` -> +# `a \\| b`), which GFM reads as a literal backslash plus a LIVE delimiter. +# This repo writes literal `\|` in its own tables, so the case is real. +ALREADY="$TEST_TMPDIR/already-esc.txt" +cat >"$ALREADY" <<'EOF' +Finding: rule=ai-slop/audit/rule-em-dash file=doc.md line=1 fired=a\|b excerpt=text +Summary rule=ai-slop/audit/rule-em-dash findings=1 declined=0 disabled=0 +EOF +ALREADY_OUT="$TEST_TMPDIR/findings/already-esc.md" +bash "$EMIT" --from "$ALREADY" --out "$ALREADY_OUT" --branch test-branch >/dev/null 2>&1 +already_row="$(LC_ALL=C grep -m1 '^| 1 ' "$ALREADY_OUT")" +assert_not_contains "emit: an already-escaped pipe is not double-escaped" "$already_row" '\\\|' +assert_contains "emit: and survives as a single-escaped literal" "$already_row" 'a\|b' +already_delims="$(printf '%s' "$already_row" | sed 's/\\|//g' | awk -F'|' '{print NF - 1}')" +assert_contains "emit: so the row still parses as exactly 7 cells" "delims=$already_delims" "delims=8" + +# Repo-root spelling mismatch: git's toplevel and the caller's pwd can name +# the same directory differently (Git Bash). This producer fails OPEN — a +# mismatch used to leave Location absolute. A symlink makes pwd and +# --show-toplevel disagree on Linux too. +SPELL_REAL="$TEST_TMPDIR/spell-real" +SPELL_LINK="$TEST_TMPDIR/spell-link" +mkdir -p "$SPELL_REAL" +( + cd "$SPELL_REAL" || exit 1 + git init -q . + git config user.email t@example.com + git config user.name Test +) +ln -s "$SPELL_REAL" "$SPELL_LINK" +printf 'Finding: rule=ai-slop/audit/rule-em-dash file=%s/doc.md line=1 fired=zero excerpt=text\n' "$SPELL_LINK" >"$TEST_TMPDIR/spell.txt" +printf 'Summary rule=ai-slop/audit/rule-em-dash findings=1 declined=0 disabled=0\n' >>"$TEST_TMPDIR/spell.txt" +SPELL_OUT="$TEST_TMPDIR/findings/spell.md" +(cd "$SPELL_LINK" && bash "$EMIT" --from "$TEST_TMPDIR/spell.txt" --out "$SPELL_OUT" --branch test-branch) >/dev/null 2>&1 +spell_body="$(cat "$SPELL_OUT")" +assert_contains "emit: a pwd-spelled absolute path is relativized" "$spell_body" "| doc.md:1 |" +assert_not_contains "emit: Location carries no absolute prefix" "$spell_body" "$SPELL_LINK/doc.md" +assert_not_contains "emit: Location carries no git-toplevel prefix either" "$spell_body" "$SPELL_REAL/doc.md" + # An excerpt that itself contains file=/line= tokens (a doc describing this # format) must not spoof the Location cell — first-occurrence parsing. SPOOF="$TEST_TMPDIR/spoof.md" diff --git a/plugins/ai-slop/skills/audit/scripts/emit-findings.sh b/plugins/ai-slop/skills/audit/scripts/emit-findings.sh index c8ab97eaff..2f7fb458ca 100755 --- a/plugins/ai-slop/skills/audit/scripts/emit-findings.sh +++ b/plugins/ai-slop/skills/audit/scripts/emit-findings.sh @@ -112,7 +112,36 @@ mkdir -p "$(dirname "$OUT")" # elsewhere binds the FILE NAME (Windows-safe), never this frontmatter field. DATE_UTC="$(date -u +%Y-%m-%dT%H:%M:%SZ)" -LC_ALL=C awk -v branch="$BRANCH" -v date_utc="$DATE_UTC" ' +# Repo root, for relativizing Location when detect.sh handed us an absolute +# path. One directory has several SPELLINGS on Git Bash, and matching the +# wrong one leaves every Location absolute — an absolute path is still a +# well-formed cell, so the fail-open producer never reports it. Measured: +# `git rev-parse --show-toplevel` answers Git Bash's Windows spelling of the same temp repo +# while the caller reached the same directory as `/tmp/t/repo`. +# +# The PRIMARY anchor is derived from the caller's own `pwd` by removing the +# sub-path git reports for it. The git-reported forms stay as fallbacks. +# (This producer FAILs OPEN: a path that matches no spelling is left as-is. +# The claude-config sibling fails closed on the same mismatch.) +REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)" +REPO_ROOT_ALT="" +REPO_ROOT_PWD="" +if [[ -n "$REPO_ROOT" ]]; then + REPO_ROOT_ALT="$(cd "$REPO_ROOT" 2>/dev/null && pwd)" || REPO_ROOT_ALT="" + [[ "$REPO_ROOT_ALT" == "$REPO_ROOT" ]] && REPO_ROOT_ALT="" + git_prefix="$(git rev-parse --show-prefix 2>/dev/null || true)" + git_prefix="${git_prefix%/}" + cwd_now="$(pwd)" + if [[ -z "$git_prefix" ]]; then + REPO_ROOT_PWD="$cwd_now" + elif [[ "$cwd_now" == */"$git_prefix" ]]; then + REPO_ROOT_PWD="${cwd_now%/"$git_prefix"}" + fi + [[ "$REPO_ROOT_PWD" == "$REPO_ROOT" || "$REPO_ROOT_PWD" == "$REPO_ROOT_ALT" ]] && REPO_ROOT_PWD="" +fi + +LC_ALL=C awk -v branch="$BRANCH" -v date_utc="$DATE_UTC" \ + -v repo_root="$REPO_ROOT" -v repo_root_alt="$REPO_ROOT_ALT" -v repo_root_pwd="$REPO_ROOT_PWD" ' # Tier/Action mirror of the severity crosswalk (see header comment). function rule_tier(slug) { if (slug == "rule-knowledge-cutoff-disclaimer" || slug == "rule-llm-citation-artifacts" || @@ -136,7 +165,54 @@ LC_ALL=C awk -v branch="$BRANCH" -v date_utc="$DATE_UTC" ' return "Guarded rewrite via /ai-slop:audit fix (judgment; see crosswalk row)" } # Cell-escaping rule: literal | becomes \| inside Finding/Action cells. - function esc(s) { gsub(/\|/, "\\|", s); return s } + # + # IDEMPOTENT. A naive gsub double-escapes a pipe the SOURCE already escaped: + # `a \| b` becomes `a \\| b`, which GFM reads as a literal backslash followed + # by a LIVE delimiter — the cell splits and the fix action misreads the row. + # This repo writes literal `\|` in its own tables, so the case is real rather + # than theoretical. Escape by the parity of the complete backslash run before + # each pipe: an odd count already escapes the delimiter; an even count + # (including zero, and `\\|`) leaves it live in GFM and needs one more `\`. + function esc(s, out, i, n, c, bs) { + out = "" + n = length(s) + i = 1 + while (i <= n) { + c = substr(s, i, 1) + if (c == "\\") { + bs = 0 + while (i <= n && substr(s, i, 1) == "\\") { bs++; i++ } + if (i <= n && substr(s, i, 1) == "|") { + if (bs % 2 == 0) bs++ + while (bs--) out = out "\\" + out = out "|" + i++ + } else { + while (bs--) out = out "\\" + } + } else if (c == "|") { + out = out "\\|" + i++ + } else { + out = out c + i++ + } + } + return out + } + + # Prefer the caller pwd spelling, then git toplevel, then cd-then-pwd. + # Fail OPEN: a path matching no spelling is returned unchanged (absolute + # Location stays well-formed). That was this producer pre-fix mode. + function relativize(p) { + if (repo_root_pwd != "" && index(p, repo_root_pwd "/") == 1) + return substr(p, length(repo_root_pwd) + 2) + if (repo_root != "" && index(p, repo_root "/") == 1) + return substr(p, length(repo_root) + 2) + if (repo_root_alt != "" && index(p, repo_root_alt "/") == 1) + return substr(p, length(repo_root_alt) + 2) + return p + } /^Finding: / { # Split the excerpt off FIRST, on the first " excerpt=" occurrence, then @@ -157,7 +233,7 @@ LC_ALL=C awk -v branch="$BRANCH" -v date_utc="$DATE_UTC" ' lno = substr(head, ix + 6) head = substr(head, 1, ix - 1) ix = index(head, " file=") - file = substr(head, ix + 6) + file = relativize(substr(head, ix + 6)) slug = substr(head, 1, ix - 1) t = rule_tier(slug) row = "| " t " | high | " file ":" lno " | ai-slop:audit | " \ diff --git a/plugins/claude-config/.claude-plugin/plugin.json b/plugins/claude-config/.claude-plugin/plugin.json index 4f6699220c..efa084bfbb 100644 --- a/plugins/claude-config/.claude-plugin/plugin.json +++ b/plugins/claude-config/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "claude-config", - "version": "0.39.3", + "version": "0.39.4", "description": "Nine configuration-health skills (plus setup) for a repo's Claude Code configuration: audit (settings.json / .mcp.json / hooks / plugins / permissions drift), audit-automation-gaps (evidence-gated verdicts on automation gaps), audit-permission-grants (allow-rule / allowed-tools grants for auto-mode durability and portability), audit-permission-state (the permission rules actually in effect — every settings scope merged with per-rule provenance, what auto mode drops on entry, config written where nothing reads it, and which managed intents are enforced versus loosenable), draft-auto-mode-rules (interview and draft a paste-ready autoMode classifier block; prints only, never writes), audit-instructions (locally-owned instruction surfaces vs current model capability — proposes removals/rewrites of instructions the model no longer needs, and detects cross-surface instruction conflicts), audit-prompting-postures (the additive lane — posture guidance the prompting guide says a component's purpose needs but the component does not carry), audit-pass (one coordinated, ordered, resumable pass over a named target — three-scope inventory, run-time-derived exclusion set, stable finding identity, suppression memory, resume, one human gate — delegating every check to the plugin that owns it), and unhobble (the empirical bare-baseline experiment: reversibly strip a repo's standing instructions, log real stumbles against the current model, re-add only what evidence earns).", "author": { "name": "Melodic Software", diff --git a/plugins/claude-config/CHANGELOG.md b/plugins/claude-config/CHANGELOG.md index 9dea0be000..9918d645b3 100644 --- a/plugins/claude-config/CHANGELOG.md +++ b/plugins/claude-config/CHANGELOG.md @@ -3,6 +3,26 @@ All notable changes to the `claude-config` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.39.4] + +### Fixed + +- **`audit-instructions` `emit-findings.sh` double-escaped a pipe the source already escaped, + and declined every in-repo finding when git and `pwd` spelled the same repo root + differently.** Both defects were identified in #3180 and fixed for `docs-hygiene:audit-noise` + in #3202; this copy was not reached. A naive `gsub(/\|/, "\\|")` turns `a \| b` into + `a \\| b`, which GFM reads as a literal backslash followed by a live delimiter, so the row + splits and the fix action misreads it. `esc()` now walks each backslash run and adds a + delimiter escape only when the run length is even, so `a\\|b` stays a single cell. + Separately, `git rev-parse --show-toplevel` can answer Git's Windows-drive spelling while + the caller is at `/tmp/…` (Git Bash). This producer failed CLOSED: a path it could not + prove was under the root was counted as `outside-repo-root` and silently missed the + relay. Root resolution now prefers the caller's own `pwd` (minus git's `--show-prefix`) + and keeps git's two spellings as fallbacks, then applies the same fail-closed fence. + Shared code was considered and declined: plugins are portable and there is no existing + cross-plugin emit-findings library; the three copies now agree on the same two helpers + instead. + ## [0.39.3] ### Fixed diff --git a/plugins/claude-config/skills/audit-instructions/scripts/emit-findings.sh b/plugins/claude-config/skills/audit-instructions/scripts/emit-findings.sh index 0431fe11af..64042632ab 100755 --- a/plugins/claude-config/skills/audit-instructions/scripts/emit-findings.sh +++ b/plugins/claude-config/skills/audit-instructions/scripts/emit-findings.sh @@ -140,7 +140,33 @@ DATE_UTC="$(date -u +%Y-%m-%dT%H:%M:%SZ)" # Repo root, for relativizing Location. The fix action fences each remediation to # its finding's Location, and an absolute path is not portable to the checkout # that applies the fix. +# +# One directory has several SPELLINGS on Git Bash, and matching the wrong one +# silently declines a real in-repo finding — this producer FAILS CLOSED, so a +# path it cannot prove is under the root never reaches the relay. Measured: +# `git rev-parse --show-toplevel` answers Git Bash's Windows spelling of the same temp repo +# while the caller reached the same directory as `/tmp/t/repo`. +# +# The PRIMARY anchor is derived from the caller's own `pwd` by removing the +# sub-path git reports for it. The git-reported forms stay as fallbacks. +# (The ai-slop sibling fails OPEN on the same mismatch — Location stays +# absolute and nothing reports it.) REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)" +REPO_ROOT_ALT="" +REPO_ROOT_PWD="" +if [[ -n "$REPO_ROOT" ]]; then + REPO_ROOT_ALT="$(cd "$REPO_ROOT" 2>/dev/null && pwd)" || REPO_ROOT_ALT="" + [[ "$REPO_ROOT_ALT" == "$REPO_ROOT" ]] && REPO_ROOT_ALT="" + git_prefix="$(git rev-parse --show-prefix 2>/dev/null || true)" + git_prefix="${git_prefix%/}" + cwd_now="$(pwd)" + if [[ -z "$git_prefix" ]]; then + REPO_ROOT_PWD="$cwd_now" + elif [[ "$cwd_now" == */"$git_prefix" ]]; then + REPO_ROOT_PWD="${cwd_now%/"$git_prefix"}" + fi + [[ "$REPO_ROOT_PWD" == "$REPO_ROOT" || "$REPO_ROOT_PWD" == "$REPO_ROOT_ALT" ]] && REPO_ROOT_PWD="" +fi if [[ -n "$CARVEOUT" && ! "$CARVEOUT" =~ ^[0-9]+$ ]]; then echo "emit-findings.sh: --declined-carveout takes a non-negative integer" >&2 @@ -148,7 +174,8 @@ if [[ -n "$CARVEOUT" && ! "$CARVEOUT" =~ ^[0-9]+$ ]]; then fi LC_ALL=C awk \ - -v branch="$BRANCH" -v date_utc="$DATE_UTC" -v repo_root="$REPO_ROOT" -v carveout="$CARVEOUT" ' + -v branch="$BRANCH" -v date_utc="$DATE_UTC" -v repo_root="$REPO_ROOT" \ + -v repo_root_alt="$REPO_ROOT_ALT" -v repo_root_pwd="$REPO_ROOT_PWD" -v carveout="$CARVEOUT" ' function rule_id(id) { if (id == "I28-a") return "claude-config/audit-instructions/rule-coercive-emphasis" if (id == "I28-b") return "claude-config/audit-instructions/rule-blanket-tool-default" @@ -163,7 +190,54 @@ LC_ALL=C awk \ return "Replace the blanket default with the targeted condition it stood in for (\"Use [tool] when it would ...\"). The condition is the payload; do not delete the instruction." } # Cell-escaping rule: literal | becomes \| inside Finding/Action cells. - function esc(s) { gsub(/\|/, "\\|", s); return s } + # + # IDEMPOTENT. A naive gsub double-escapes a pipe the SOURCE already escaped: + # `a \| b` becomes `a \\| b`, which GFM reads as a literal backslash followed + # by a LIVE delimiter — the cell splits and the fix action misreads the row. + # This repo writes literal `\|` in its own tables, so the case is real rather + # than theoretical. Escape by the parity of the complete backslash run before + # each pipe: an odd count already escapes the delimiter; an even count + # (including zero, and `\\|`) leaves it live in GFM and needs one more `\`. + function esc(s, out, i, n, c, bs) { + out = "" + n = length(s) + i = 1 + while (i <= n) { + c = substr(s, i, 1) + if (c == "\\") { + bs = 0 + while (i <= n && substr(s, i, 1) == "\\") { bs++; i++ } + if (i <= n && substr(s, i, 1) == "|") { + if (bs % 2 == 0) bs++ + while (bs--) out = out "\\" + out = out "|" + i++ + } else { + while (bs--) out = out "\\" + } + } else if (c == "|") { + out = out "\\|" + i++ + } else { + out = out c + i++ + } + } + return out + } + + # Prefer the caller pwd spelling, then git toplevel, then cd-then-pwd. + # Empty return means the path is not under any known spelling of the root + # (fail closed — this producer pre-fix mode on a spelling mismatch). + function relativize_in_repo(p) { + if (repo_root_pwd != "" && index(p, repo_root_pwd "/") == 1) + return substr(p, length(repo_root_pwd) + 2) + if (repo_root != "" && index(p, repo_root "/") == 1) + return substr(p, length(repo_root) + 2) + if (repo_root_alt != "" && index(p, repo_root_alt "/") == 1) + return substr(p, length(repo_root_alt) + 2) + return "" + } # Emit a YAML scalar, quoting ONLY when the plain form would misparse. Git # accepts branch names beginning with a YAML indicator: "#foo" reads as a @@ -296,8 +370,8 @@ LC_ALL=C awk \ # carrying an absolute path, where the fix pass either edits a file outside # the working tree or consumes the finding without applying it. Neither is # acceptable, so such rows are declined here and stay in the human report. - if (repo_root == "" || index(file, repo_root "/") != 1) { declined_outofrepo[id]++; next } - loc = substr(file, length(repo_root) + 2) + loc = relativize_in_repo(file) + if (loc == "") { declined_outofrepo[id]++; next } excerpt = trim(text) if (length(excerpt) > 160) excerpt = substr(excerpt, 1, 157) "..." diff --git a/plugins/claude-config/skills/audit-instructions/scripts/emit-findings.test.sh b/plugins/claude-config/skills/audit-instructions/scripts/emit-findings.test.sh index eb97bf816b..94f3fc941c 100755 --- a/plugins/claude-config/skills/audit-instructions/scripts/emit-findings.test.sh +++ b/plugins/claude-config/skills/audit-instructions/scripts/emit-findings.test.sh @@ -403,6 +403,41 @@ assert_contains "literal pipes in the excerpt are escaped" "$ROW" '\|' COLS=$(printf '%s\n' "$ROW" | sed 's/\\|//g' | awk -F'|' '{print NF}') assert_eq "an excerpt containing pipes still parses as one 7-column row" "9" "$COLS" +# --- Case 13b: cell escaping is idempotent ----------------------------------- +# A naive gsub double-escapes a pipe the SOURCE already escaped (`a \| b` -> +# `a \\| b`), which GFM reads as a literal backslash plus a LIVE delimiter. +PIPEF2="$CRLFREPO/already-pipe.md" +# shellcheck disable=SC2016 # backticks and \| are fixture content, not a subshell +printf 'CRITICAL: run `a \| b` before pushing.\n' >"$PIPEF2" +# Feed a synthetic scan row whose excerpt (the source line) already contains \|. +printf '%s\n' "$PIPEF2:1:I28-a" >"$TEST_TMPDIR/already-pipe.txt" +(cd "$CRLFREPO" && bash "$EMIT" --from "$TEST_TMPDIR/already-pipe.txt" \ + --out "$TEST_TMPDIR/already-pipe-out.md" --branch testbranch >/dev/null 2>&1) +ALREADY_ROW=$(LC_ALL=C grep -m1 '^| [0-9]' "$TEST_TMPDIR/already-pipe-out.md") +assert_not_contains "an already-escaped pipe is not double-escaped" "$ALREADY_ROW" '\\\|' +assert_contains "and survives as a single-escaped literal" "$ALREADY_ROW" '\|' +ALREADY_COLS=$(printf '%s\n' "$ALREADY_ROW" | sed 's/\\|//g' | awk -F'|' '{print NF}') +assert_eq "so the row still parses as one 7-column row" "9" "$ALREADY_COLS" + +# --- Case 13c: repo-root spelling mismatch does not fail-close an in-repo file +# This producer fails CLOSED: a path it cannot prove is under the root is +# declined. On Git Bash, git's toplevel and the caller's pwd can name the +# same directory differently, which used to decline every in-repo hit. +# A symlink makes the two spellings disagree on Linux too. +SPELL_REAL="$TEST_TMPDIR/spell-real" +SPELL_LINK="$TEST_TMPDIR/spell-link" +mkdir -p "$SPELL_REAL" +git -C "$SPELL_REAL" init -q 2>/dev/null +printf -- '# Body\n\nCRITICAL: You MUST always do this.\n' >"$SPELL_REAL/doc.md" +ln -sfn "$SPELL_REAL" "$SPELL_LINK" +bash "$SCAN" --body-only "$SPELL_LINK/doc.md" >"$TEST_TMPDIR/spell.txt" +(cd "$SPELL_LINK" && bash "$EMIT" --from "$TEST_TMPDIR/spell.txt" \ + --out "$TEST_TMPDIR/spell-find.md" --branch testbranch >/dev/null 2>&1) +SPELL_OUT=$(cat "$TEST_TMPDIR/spell-find.md") +assert_contains "a pwd-spelled in-repo path is emitted, not declined" "$SPELL_OUT" "doc.md:3" +assert_not_contains "and is not counted as out-of-repo" "$SPELL_OUT" "reason=outside-repo-root" +assert_not_contains "Location carries no absolute prefix" "$SPELL_OUT" "$SPELL_LINK/doc.md" + # --- Summary ----------------------------------------------------------------- printf '\n' if [[ "$FAILED" -gt 0 ]]; then