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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions plugins/typos-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": "typos-format",
"version": "0.6.15",
"version": "0.6.17",
"description": "Spell-check on edit via typos-cli, unconditionally \u2014 report-only by default, honoring the consuming repo's own typos configuration when one is present.",
"author": {
"name": "Melodic Software",
Expand All @@ -25,7 +25,7 @@
"typos_format_write_changes": {
"type": "boolean",
"title": "Apply corrections",
"description": "Rewrite the file in place. Off by default: findings are reported without modifying the file. Turning this on accepts last-writer-wins ordering with any sibling formatter hook that rewrites the same file.",
"description": "Rewrite the file in place for write-allowlisted extensions. Off by default: findings are reported without modifying the file. Turning this on accepts last-writer-wins ordering with any sibling formatter hook that rewrites the same file. Unknown extensions stay report-only.",
"default": false
}
}
Expand Down
26 changes: 26 additions & 0 deletions plugins/typos-format/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,32 @@
All notable changes to the `typos-format` plugin are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning.

## [0.6.17]

### Fixed

- **Write-skip disclosure names the real gate (#2650).** Lockfile basename denies,
extensionless paths, and extension allowlist misses each get a distinct skip note
instead of always blaming the extension allowlist.

## [0.6.16]

### Fixed

- **Write-mode allowlist isolates the Clojure data-notation extension** on its own `case` arm with `spellchecker:disable-line` so hygiene spell-check does not treat that extension as the English word "end". A disable-line comment cannot sit after a line-continuation `\` inside the multi-line pattern list without breaking bash parsing.

- **Write mode denies known lockfile basenames** even when the extension is otherwise
allowlisted (`package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`, `Cargo.lock`, …),
so generated dependency graphs stay report-only under `typos_format_write_changes`.

- **Write mode honors an explicit extension allowlist before `--write-changes` (#2650).** The
read-only scan stays language-agnostic (any edited file). Opt-in write mode no longer rewrites
unknown extensions, extensionless paths, or fixture/lock/binary-adjacent types — those stay
report-only even when `typos_format_write_changes` is true. `--write-changes` emits nothing for
a correction it applies, so an unbounded write path was unbounded blast radius; the allowlist is
defense in depth on top of `--force-exclude`. Denied paths still surface findings, with a note
that write was skipped for the extension.

## [0.6.15]

### Changed
Expand Down
25 changes: 15 additions & 10 deletions plugins/typos-format/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,23 @@ opt-in required.
on a consumer typos config existing. When a config IS present, typos' own
file-anchored discovery still finds and applies it (allowlist/exclude), in
its documented precedence order — this plugin never re-implements that walk.
- **No extension filter.** Unlike sibling formatter plugins (Ruff, Markdown),
typos is language-agnostic — it runs on any edited file.
- **Scan is language-agnostic; write is extension-scoped.** The read-only scan
runs on any edited file (unlike sibling formatter plugins). Opt-in write mode
only calls `--write-changes` for an explicit allowlist of source, prose, and
hand-edited config extensions — unknown extensions, extensionless paths, and
fixture/lock/binary-adjacent types stay report-only even when
`typos_format_write_changes` is on (#2650).
- **Report-only by default.** A dictionary autocorrect is a content mutation
you never asked for, and an unconditional writer here raced sibling
formatter hooks on the same file with no defined ordering (#1809). Out of
the box the hook reports findings and never modifies a file.
- **Fix in place is an opt-in.** With `typos_format_write_changes` set to
`true`, `typos --write-changes` applies every correction it has confidence
in. Residual findings — an entry with no known correction (e.g. a
blank-correction `extend-words` entry marking a term "disallowed"), or one
with more than one candidate correction — surface as advisory context, never
auto-applied.
- **Fix in place is an opt-in, then an allowlist.** With
`typos_format_write_changes` set to `true`, `typos --write-changes` applies
every correction it has confidence in — but only when the edited path's
extension is on the write allowlist. Residual findings — an entry with no
known correction (e.g. a blank-correction `extend-words` entry marking a
term "disallowed"), or one with more than one candidate correction — surface
as advisory context, never auto-applied.
- **Every applied rewrite is disclosed.** A correction changes the content of
your file, so the hook reports each one it applied — the word, its
replacement, and the line — to Claude *and* to you, capped at ten per run
Expand Down Expand Up @@ -102,7 +107,7 @@ Two `userConfig` options tune the hook itself:
| Option | Default | Effect |
|--------|---------|--------|
| `typos_format_enabled` | `true` | Kill switch — set `false` for a clean no-op. |
| `typos_format_write_changes` | `false` | Set `true` to apply corrections in place (accepting last-writer-wins with any sibling formatter hook on the same file). Default is report-only: findings are reported, no file is modified. |
| `typos_format_write_changes` | `false` | Set `true` to apply corrections in place for write-allowlisted extensions (accepting last-writer-wins with any sibling formatter hook on the same file). Default is report-only: findings are reported, no file is modified. Denied extensions stay report-only even when this is on. |

Set them interactively with `/plugin configure typos-format@<marketplace>`, or headless on the
install command:
Expand All @@ -126,7 +131,7 @@ reads it from.
| Option | Type | Default | Environment variable | Description |
| --- | --- | --- | --- | --- |
| `typos_format_enabled` | boolean | `true` | `CLAUDE_PLUGIN_OPTION_TYPOS_FORMAT_ENABLED` | Spell-check on edit of any file, unconditionally (report-only unless typos_format_write_changes is on) |
| `typos_format_write_changes` | boolean | `false` | `CLAUDE_PLUGIN_OPTION_TYPOS_FORMAT_WRITE_CHANGES` | Rewrite the file in place. Off by default: findings are reported without modifying the file. Turning this on accepts last-writer-wins ordering with any sibling formatter hook that rewrites the same file. |
| `typos_format_write_changes` | boolean | `false` | `CLAUDE_PLUGIN_OPTION_TYPOS_FORMAT_WRITE_CHANGES` | Rewrite the file in place for write-allowlisted extensions. Off by default: findings are reported without modifying the file. Turning this on accepts last-writer-wins ordering with any sibling formatter hook that rewrites the same file. Unknown extensions stay report-only. |

### How to set these

Expand Down
101 changes: 96 additions & 5 deletions plugins/typos-format/hooks/typos-format.sh
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
#!/usr/bin/env bash
# PostToolUse hook: spell-check via typos-cli (crate-ci/typos), REPORT-ONLY by
# default. Triggered on Write|Edit of ANY file — typos is language-agnostic,
# unlike the sibling ruff-format/markdown-format hooks, which are
# extension-scoped.
# default. Triggered on Write|Edit of ANY file for the read-only scan — typos
# is language-agnostic, unlike the sibling ruff-format/markdown-format hooks,
# which are extension-scoped. Write mode (`typos_format_write_changes`) is a
# different story: --write-changes is gated on an explicit extension allowlist
# (#2650), so an unknown/fixture/binary-adjacent extension stays report-only
# even when the opt-in is set.
#
# ADVISORY: always exits 0. Findings surface via additionalContext but never
# block the edit. A commit hook or CI is the hard gate.
Expand Down Expand Up @@ -95,7 +98,8 @@ INPUT=$(hook::buffer_stdin) || exit 0

# jq-free applicability pre-filter: never emit the jq notice when there is no
# file_path at all (e.g. a tool_input shape this hook cannot act on regardless).
# shellcheck disable=SC2034 # existence-only check; no extension filter to apply (typos is language-agnostic)
# shellcheck disable=SC2034 # existence-only check; scan has no extension filter
# (typos is language-agnostic). Write mode applies its own allowlist later (#2650).
RAW_FILE=$(hook::raw_file_path "$INPUT") || exit 0

# jq is load-bearing for input parsing; absent → visible once-per-session skip
Expand Down Expand Up @@ -214,6 +218,78 @@ fi
# opt-in spelling, so a typo'd or half-set option value stays report-only.
WRITE_CHANGES="${CLAUDE_PLUGIN_OPTION_TYPOS_FORMAT_WRITE_CHANGES:-false}"

# Write-mode extension allowlist (#2650). The read-only scan still runs on any
# file (typos is language-agnostic). --write-changes is the silent mutator —
# typos emits nothing for a correction it applies — so an unbounded write path
# is unbounded blast radius once a fixture, golden, lockfile, encoded blob, or
# other load-bearing exact-string file reaches this hook. Deny unknown
# extensions (and extensionless paths) even when the write opt-in is set: those
# stay report-only. The allowlist is source + prose + hand-edited config that
# sibling formatters in this marketplace already treat as rewrite-safe; it is
# deliberately NOT typos' full type list (that includes lock, cert, binary
# compressions, and many fixture-adjacent types).
# Generated lockfile basenames stay report-only even when their extension is
# otherwise rewrite-safe (*.json / *.yaml). A correctable token in package-lock
# or pnpm-lock must not mutate load-bearing generated data (#2650 review).
typos_write_lockfile_denied() {
local base
base="$(basename -- "$1")"
case "$base" in
package-lock.json | npm-shrinkwrap.json | yarn.lock | pnpm-lock.yaml | bun.lock | bun.lockb | Cargo.lock | poetry.lock | Pipfile.lock | composer.lock | Gemfile.lock | go.sum | Podfile.lock | flipper.lock | Package.resolved | packages.lock.json | project.assets.json)
return 0
;;
*)
return 1
Comment thread
kyle-sexton marked this conversation as resolved.
;;
esac
}

typos_write_ext_allowed() {
case "$1" in
*.edn) # spellchecker:disable-line
return 0
;;
*.md | *.mdc | *.mdx | *.markdown | *.txt | *.rst | *.adoc | *.asciidoc | *.org | \
*.py | *.pyi | *.rb | *.go | *.rs | *.java | *.kt | *.kts | *.scala | *.swift | \
*.js | *.jsx | *.mjs | *.cjs | *.ts | *.tsx | *.mts | *.cts | *.vue | *.svelte | \
*.c | *.h | *.cc | *.hh | *.cpp | *.hpp | *.cxx | *.hxx | *.cs | *.fs | *.fsx | \
*.sh | *.bash | *.zsh | *.fish | *.ps1 | *.psm1 | *.psd1 | \
*.html | *.htm | *.css | *.scss | *.sass | *.less | \
*.xml | *.yaml | *.yml | *.toml | *.json | *.jsonc | \
Comment thread
kyle-sexton marked this conversation as resolved.
*.sql | *.graphql | *.graphqls | *.proto | \
*.lua | *.r | *.R | *.pl | *.pm | *.php | *.ex | *.exs | *.erl | *.hrl | \
*.hs | *.elm | *.clj | *.cljs | *.lisp | *.el | \
*.tf | *.hcl | *.nix | *.cmake | *.mk | \
*.tex | *.bib | *.pod | *.rdoc)
return 0
;;
*)
return 1
;;
esac
}

# Opt-in write mode that lands on a denied extension degrades to report-only
# for THIS file. Flipping WRITE_CHANGES keeps the disclosure composer on the
# report-only path (mode statement, no residual-after-write phrasing) without
# a second messaging branch. WRITE_SKIP_REASON records why, for a one-line
# note on the agent channel when findings exist.
WRITE_SKIP_REASON=""
if [[ "$WRITE_CHANGES" == "true" ]]; then
if typos_write_lockfile_denied "$FILE"; then
WRITE_CHANGES=false
WRITE_SKIP_REASON=lockfile
elif ! typos_write_ext_allowed "$FILE"; then
WRITE_CHANGES=false
base=$(basename -- "$FILE")
if [[ "$base" != *.* ]]; then
WRITE_SKIP_REASON=extensionless
else
WRITE_SKIP_REASON=extension
fi
fi
fi

# Disclosure cap. A file with hundreds of corrections must not turn this hook's
# own report into the context flood it exists to prevent, so each list is capped
# and the remainder is summarized as a count.
Expand Down Expand Up @@ -485,7 +561,22 @@ if ((APPLIED_COUNT > 0)); then
fi
SYSMSG+=". Add any wrong rewrite to extend-words / extend-identifiers in the repo's typos config, or set the typos_format_write_changes option back to false (the default) for report-only mode."
Comment thread
kyle-sexton marked this conversation as resolved.
elif [[ "$WRITE_CHANGES" != "true" ]]; then
CTX+="typos-format is report-only — $BASE was NOT modified. Findings:"$'\n'
if [[ -n "$WRITE_SKIP_REASON" ]]; then
case "$WRITE_SKIP_REASON" in
lockfile)
skip_why="this path is a generated lockfile basename, so the file was NOT modified"
;;
extensionless)
skip_why="this path has no extension and is outside the write allowlist, so the file was NOT modified"
;;
*)
skip_why="this extension is outside the write allowlist, so the file was NOT modified"
;;
esac
CTX+="typos-format is report-only for $BASE — write mode is on, but ${skip_why}. Findings:"$'\n'
else
CTX+="typos-format is report-only — $BASE was NOT modified. Findings:"$'\n'
fi
fi

if ((RESIDUAL_COUNT > 0)); then
Expand Down
97 changes: 93 additions & 4 deletions plugins/typos-format/hooks/typos-format.test.sh
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
#!/usr/bin/env bash
# Black-box contract test for typos-format.sh (the typos-format plugin hook).
#
# Proves WIRING: the hook fires on any file (no extension filter) UNCONDITIONALLY
# — with or without a consumer typos config — is REPORT-ONLY by default
# (#1809's single-writer decision), applies typos' safe corrections in place
# only under the typos_format_write_changes opt-in, honors typos' own
# Proves WIRING: the hook's read-only scan fires on any file (no extension
# filter) UNCONDITIONALLY — with or without a consumer typos config — is
# REPORT-ONLY by default (#1809's single-writer decision), applies typos'
# safe corrections in place only under the typos_format_write_changes opt-in
# AND only for write-allowlisted extensions (#2650), honors typos' own
# typos.toml > _typos.toml > .typos.toml > Cargo.toml > pyproject.toml
# precedence when a config IS present, surfaces residual (unfixable) findings
# via additionalContext with remediation guidance, honors the kill switch, and
Expand Down Expand Up @@ -379,6 +380,94 @@ else
fail "stub/report-only: emitted a systemMessage without mutating anything"
fi

# --- Write opt-in + denied extension: still report-only (#2650) --------------
# --write-changes is silent for applied corrections, so an unbounded write path
# is unbounded blast radius. A fixture/golden/lock-adjacent extension must stay
# byte-identical even when typos_format_write_changes is true; findings are
# still reported.
printf 'this has teh typo\n' >"$STUB_REPO/fixture.snap" # spellchecker:disable-line
BEFORE_SNAP="$(cat "$STUB_REPO/fixture.snap")"
OUT_SNAP=$(run_stub "$STUB_REPO/fixture.snap")
RC_SNAP=$?
if [[ $RC_SNAP -eq 0 ]]; then ok "stub/write-ext-deny: exit 0"; else fail "stub/write-ext-deny: exit $RC_SNAP"; fi
if [[ "$(cat "$STUB_REPO/fixture.snap")" == "$BEFORE_SNAP" ]]; then
ok "stub/write-ext-deny: .snap left byte-identical under write opt-in"
else
fail "stub/write-ext-deny: denied extension was rewritten: $(cat "$STUB_REPO/fixture.snap")"
fi
CTX_SNAP=$(printf '%s' "$OUT_SNAP" | jq -r '.hookSpecificOutput.additionalContext // empty' 2>/dev/null)
if printf '%s' "$CTX_SNAP" | grep -qi 'allowlist' && printf '%s' "$CTX_SNAP" | grep -q 'teh'; then # spellchecker:disable-line
ok "stub/write-ext-deny: findings reported with write-allowlist skip note"
else
fail "stub/write-ext-deny: skip note or findings missing: $CTX_SNAP"
fi
if printf '%s' "$CTX_SNAP" | grep -q 'REWROTE'; then
fail "stub/write-ext-deny: claimed a rewrite for a denied extension: $CTX_SNAP"
else
ok "stub/write-ext-deny: no rewrite claimed"
fi
if [[ -z "$(printf '%s' "$OUT_SNAP" | jq -r '.systemMessage // empty' 2>/dev/null)" ]]; then
ok "stub/write-ext-deny: no user-channel mutation message"
else
fail "stub/write-ext-deny: emitted a systemMessage without mutating anything"
fi


# --- Write opt-in + lockfile basename: report-only even for *.json (#2650 P2) -
printf 'this has teh typo\n' >"$STUB_REPO/package-lock.json" # spellchecker:disable-line
BEFORE_LOCK="$(cat "$STUB_REPO/package-lock.json")"
OUT_LOCK=$(run_stub "$STUB_REPO/package-lock.json")
RC_LOCK=$?
if [[ $RC_LOCK -eq 0 ]]; then ok "stub/write-lockfile-deny: exit 0"; else fail "stub/write-lockfile-deny: exit $RC_LOCK"; fi
if [[ "$(cat "$STUB_REPO/package-lock.json")" == "$BEFORE_LOCK" ]]; then
ok "stub/write-lockfile-deny: package-lock.json left byte-identical under write opt-in"
else
fail "stub/write-lockfile-deny: lockfile was rewritten: $(cat "$STUB_REPO/package-lock.json")"
fi
CTX_LOCK=$(printf '%s' "$OUT_LOCK" | jq -r '.hookSpecificOutput.additionalContext // empty' 2>/dev/null)
if printf '%s' "$CTX_LOCK" | grep -qi 'lockfile basename' && printf '%s' "$CTX_LOCK" | grep -q 'teh'; then # spellchecker:disable-line
ok "stub/write-lockfile-deny: findings reported with lockfile skip note"
else
fail "stub/write-lockfile-deny: skip note or findings missing: $CTX_LOCK"
fi
if printf '%s' "$CTX_LOCK" | grep -q 'REWROTE'; then
fail "stub/write-lockfile-deny: claimed a rewrite for a lockfile: $CTX_LOCK"
else
ok "stub/write-lockfile-deny: no rewrite claimed"
fi

# Extensionless path is denied the same way (unknown extension).
printf 'this has teh typo\n' >"$STUB_REPO/LICENSE" # spellchecker:disable-line
BEFORE_LIC="$(cat "$STUB_REPO/LICENSE")"
OUT_LIC=$(run_stub "$STUB_REPO/LICENSE")
if [[ "$(cat "$STUB_REPO/LICENSE")" == "$BEFORE_LIC" ]]; then
ok "stub/write-ext-deny-extensionless: extensionless path left byte-identical under write opt-in"
else
fail "stub/write-ext-deny-extensionless: rewritten: $(cat "$STUB_REPO/LICENSE")"
fi
CTX_LIC=$(printf '%s' "$OUT_LIC" | jq -r '.hookSpecificOutput.additionalContext // empty' 2>/dev/null)
if printf '%s' "$CTX_LIC" | grep -qi 'no extension' && printf '%s' "$CTX_LIC" | grep -q 'teh'; then # spellchecker:disable-line
ok "stub/write-ext-deny-extensionless: findings reported with extensionless skip note"
else
fail "stub/write-ext-deny-extensionless: skip note or findings missing: $CTX_LIC"
fi

# Allowlisted prose still writes under the same opt-in (positive control for the
# gate — existing .txt cases cover this too; .md pins a second allowlisted class).
printf 'this has teh typo\n' >"$STUB_REPO/prose.md" # spellchecker:disable-line
OUT_MD=$(run_stub "$STUB_REPO/prose.md")
if grep -q ' the ' "$STUB_REPO/prose.md"; then
ok "stub/write-ext-allow: .md still rewritten under write opt-in"
else
fail "stub/write-ext-allow: allowlisted .md not rewritten: $(cat "$STUB_REPO/prose.md")"
fi
CTX_MD=$(printf '%s' "$OUT_MD" | jq -r '.hookSpecificOutput.additionalContext // empty' 2>/dev/null)
if printf '%s' "$CTX_MD" | grep -qF '"teh" -> "the"'; then # spellchecker:disable-line
ok "stub/write-ext-allow: allowlisted rewrite still disclosed"
else
fail "stub/write-ext-allow: disclosure missing: $CTX_MD"
fi

# --- Applied + residual in one run: both sections, one document --------------
printf 'this has teh typo and wnat and a disallowme term\n' >"$STUB_REPO/both.txt" # spellchecker:disable-line
OUT_BOTH=$(run_stub "$STUB_REPO/both.txt")
Expand Down