claudemd-cli 0.67.1 → 0.68.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,40 @@ All notable changes to the `claudemd` plugin. This changelog tracks plugin artif
8
8
  - **Canonical spec version source**: `spec/CLAUDE.md` top-line title (`# AI-CODING-SPEC vX.Y.Z — Core`) + `spec/CLAUDE-changelog.md` top `##` entry.
9
9
  - **Plugin semver vs spec semver** are independent: plugin patch (0.2.0 → 0.2.1) may ship when spec is unchanged (this release); plugin minor (0.1.9 → 0.2.0) ships when spec minor updates (v0.2.0 shipped spec v6.10.0).
10
10
 
11
+ ## [0.68.1] - 2026-08-16
12
+
13
+ Patch found by dogfooding the *user journey* rather than the scripts: a sandbox HOME, a versioned marketplace cache dir, real event JSON piped into the hooks from that dir, and the detached background bootstrap polled for. Sixteen phases — install, health check, enforcement, auto-upgrade, stale-registration, upstream notice, self-heal, uninstall, collision with another plugin, truncated cache, degraded machine, kill switches, concurrency — land as `tests/integration/user-journey.test.sh`. Two of the three defects share one root: a precondition validated at its *point of use*, deep inside a mutation sequence, instead of in the pre-flight block that exists for exactly that. No spec change; no behavior change on any healthy install.
14
+
15
+ - **fix: an unparseable `~/.claude/settings.json` left a half-installed claudemd that never self-healed.** `install.js` pre-flight validated the PLUGIN side — spec files complete, `hooks/hooks.json` present and non-empty — under a comment reading *"Fail before touching anything the user owns"*. The one USER-side precondition was not in that block: `readSettings()` ran ~80 lines later, **after** `createBackup()` had `renameSync`'d the user's personal `~/.claude/CLAUDE.md` into `backup-<ts>/` and the spec had overwritten it. A trailing comma from a hand-edit — the common shape for this file, which is shared real estate with Claude Code's own settings — therefore produced: personal `CLAUDE.md` moved, spec installed, **manifest never written**. The manifest is what the SessionStart bootstrap keys on, so every subsequent session re-spawned the same doomed background install. The check now sits with its two siblings, before anything moves. What changes for the user: no file of theirs is touched by the refused install, and the message naming the actual repair reaches `~/.claude/logs/claudemd-bootstrap.log` — the file the in-session banner already points at. The banner text itself is unchanged and still leads with `/claudemd-refresh`, which does not fix JSON syntax; `/claudemd-doctor` names the real cause, as it did before. RED-verified: pre-fix the refused install overwrote the personal file and left an orphan `backup-*` dir; post-fix both are untouched and repairing the JSON is sufficient to get unstuck.
16
+ - **fix: the same file made the plugin impossible to uninstall.** `uninstall.js` called `readSettings()` at the top of its side-effect sequence, so the same trailing comma aborted the whole run: exit 1, manifest still present, state dir still present, spec disposition never reached — with an error that named the file but offered no way forward. Since v0.1.5 the hooks live in the plugin's own `hooks/hooks.json` and `settings.json` normally carries **zero** claudemd entries, which makes that eviction the least load-bearing step in the function. It now degrades to a **reported** skip — new `settingsWarning` field in the JSON result (an additive field, hence still a patch) plus a stderr `[claudemd] WARN:` line — and the manifest / state / spec disposition completes. The user's unparseable file is left byte-for-byte as they wrote it; a silent skip would have been worse than the abort. The pre-tag review caught that the first cut of this was **honest about the wrong half**: `removeStatusline()` reads the same file and fails on the same input, so claudemd kept owning the statusLine while a warning that named only "hook entries" implied otherwise. Both residues are now named, and a `statusline.action === 'error'` — which previously existed only in the returned JSON, surfaced nowhere on the human path — emits its own stderr WARN. `commands/claudemd-uninstall.md` was pointing the slash command at a field name that does not exist (`warning`, not `settingsWarning`) and is corrected to enumerate all three, with the note that `specAction: "keep"` alone does not mean the uninstall was complete.
17
+ - **fix: `X=$(cd "$(mktemp -d)" && pwd -P)` fails OPEN, and five tracked shell files used it.** The one-liner reads as "make a sandbox, resolve its physical path" (the `pwd -P` is needed because macOS `mktemp` returns a `/var` symlink). But when `mktemp` fails, the inner substitution is empty, `cd ""` is a bash **no-op returning 0**, and `pwd -P` prints the *current* directory — so the variable becomes the repo root and the suite's own `rm -rf "$X"` or EXIT trap deletes the working tree. A `[[ -n "$X" ]]` guard is inert against it: the string is non-empty, just wrong. Verified: `TMPDIR=/nonexistent bash -c 'X=$(cd "$(mktemp -d)" && pwd -P); echo "$X"'` prints the cwd. The trigger is not hypothetical — this repo's own `/tmp`-writes gate cites *"Read-only file system wherever /tmp is not writable (agent sandbox, hardened CI image)"* as an observed condition, and `mktemp` honors `TMPDIR`. It is also the exact §8 clause the plugin's `pre-bash-safety-check.sh` denies for everyone else. Three of the five sites pre-date this release (`upgrade-lifecycle.test.sh` ×2, `session-end-check.test.sh`); all five are now two statements with an explicit `|| exit 1`, and `run-all.sh` gains a class gate over every tracked `.sh` so the next person reaching for the one-liner is stopped. Control-verified: the gate fires on a planted occurrence and ignores it inside a comment.
18
+ - **fix: every `npm test` run leaked 4 sandbox dirs into the user's real `TMPDIR`.** Two in `sampling-audit.test.js` (DRIFT-1 / DRIFT-1b `mkdtempSync` with no disposal at all, while the test immediately above them disposes in a `finally`), two in `toggle.test.js` — where the `mkdtempSync` was inlined into a `spawnSync` env literal, so the path was never bound to a name and could not be removed even in principle. They accumulate forever; 48 had piled up in one working scratchpad. This is the §8.V4 residue class the plugin's own Stop hook exists to flag, shipped inside the plugin's own test suite.
19
+ - **test: `run-all.sh` now runs the Node leg under an isolated `TMPDIR` and fails on anything left behind.** Counting `mkdtempSync` against `rmSync` per file does **not** find this class — a `beforeEach`/`afterEach` pair covers many tests, so the counts legitimately disagree, and the two worst offenders sat in files whose ratios looked fine (`sampling-audit` read 6 vs 25). Running the suite in a known-empty dir and inventorying what survives is the only reading that means anything. Node's own `node-compile-cache` is allowlisted by **exact name**: the first cut allowlisted `node-*`, which — as the pre-tag review demonstrated with a planted `node-fixture-XXXX` — silently ignores anything a real leak could be named, the gate being wider than its stated subject. `find -printf` is deliberately avoided (GNU-only; on the macOS leg it would have produced an empty, always-passing list). Control-verified in both directions: a planted leftover turns the gate red, and the real suite went 4 leftovers → 0.
20
+ - Tests 776 → 780 (+4, 0 regressions); integration suites 3 → 4; shellcheck 54 files clean at warning+; bash 3.2 construct gate clean; fail-open-mktemp gate clean over 54 files; `version-cascade-check` ok. The new E2E suite emits 152 assertions over 16 phases. An independent pre-tag review of the staged diff returned one blocker (the fail-open `mktemp` above) and three should-fixes, all folded in here rather than deferred; it also established by reverting `install.js` + `uninstall.js` in a repo copy that the new Phase 15 genuinely goes RED on the pre-fix code, and re-derived the test counts and leak reproduction independently. Two of its findings were assertions in the new suite that **could not fail** — a residue check matching `*.tmp` when the product only ever writes `*.tmp-<pid>` / `*.tmp.<pid>` / `*.tail.$$`, at a depth the check did not reach; and a warm-vs-cold hook sweep whose comment claimed a distinction the measurements do not show. Both are corrected rather than removed.
21
+
22
+ ## [0.68.0] - 2026-08-16
23
+
24
+ End-to-end dogfooding pass driven from the *external* surfaces — the npm CLI and a real `git commit-msg` hook — rather than from the test suite. Two of the four defects were unreachable from inside Claude Code, which is why five audits had not seen them: the CLI's documented pre-commit entry point had never been exercised against a **real** `COMMIT_EDITMSG`, and the Node sanitizer had never been handed a file large enough to expose its complexity class. Minor, not patch: two banned-vocab patterns now deny shapes that previously passed (released-artifact checklist below), and `lint` gains flags.
25
+
26
+ **⚠ Users upgrading — what changes.** One commit-message shape that passed before now blocks:
27
+
28
+ | Shape | Before | After |
29
+ |---|---|---|
30
+ | `perf: 3× faster parser` (unicode `×`, U+00D7) | passed | denied — `3x faster` (ASCII) was already denied |
31
+
32
+ It is a shape the spec already names as banned; the gate simply did not implement it. **Action required: none** — if it fires on a message you consider legitimate, the existing escapes apply unchanged: rewrite with numbers (`240ms → 72ms (3× faster)` clears the ratio class via the baseline exemption), add `[allow-banned-vocab]` to the commit message, or set `DISABLE_BANNED_VOCAB_HOOK=1`. `hooks/banned-vocab.patterns` is a plain text file — deleting the row is a supported opt-out. **Revert path**: pin 0.67.1, or `CLAUDEMD_ALLOW_DOWNGRADE=1 node scripts/install.js` from a 0.67.1 checkout.
33
+
34
+ A second candidate pattern, `应该可以`, was written and **reverted before shipping** — see the CHECK 5 entry below. Nothing changes for Chinese commit messages.
35
+
36
+ - **fix: `claudemd-cli lint .git/COMMIT_EDITMSG` no longer denies commits over words the author never wrote.** A `commit-msg` hook receives the message file *before* git's cleanup pass. Verified against git 2.43.0: the hook was handed **26 lines** — git's `#` template/status block plus, under `git commit -v`, the entire staged diff below the `>8` scissors line — while the message git then stored was **1 line**. `lint` scanned all 26, so a banned word living in the user's own staged diff (or in git's status block naming a file like `comprehensive.js`) blocked a clean message, with a bypass note pointing at text the word does not appear in. This is the usage the CLI's `--help` and README document for pre-commit hooks. `stripGitCommitComments()` now reproduces git's cleanup (truncate at the cut line, then drop column-0 comment lines); it deliberately **keeps indented `#` lines**, because git keeps them — over-stripping would trade a false positive for a false negative. Auto-detected by filename (`COMMIT_EDITMSG` / `MERGE_MSG` / `SQUASH_MSG` / `TAG_EDITMSG` / `NOTES_EDITMSG`), never content-sniffed, so `lint --file notes.md` keeps scanning markdown headings. New `--commit-msg` / `--no-commit-msg` / `--comment-char <c>` flags; `--json` reports `commitMsgCleanup`. README's own pre-commit example was itself the FP vector (`--stdin < "$1"` hands over bytes with no filename to key off) and now passes the path. Verified end-to-end through a real git hook, 4/4 arms: clean subject commits, a banned word in the real message still blocks, the escape hatch works in the message, and the escape hatch in a `#` line does **not** rescue — because git discards that line.
37
+ - **fix: the Node identifier-strip was quadratic and hung the CLI on large input.** Both `<class-run><required-delimiter>` clauses in `lint.js#stripIdentifiers` retried from every offset inside a delimiter-free character run and rescanned the run each time. Measured: 4k→6ms, 8k→24ms, 16k→94ms, 32k→376ms, 64k→1495ms — a clean 4× per doubling — and `lint --file` on a 500KB single-token blob ran **past a 30s timeout**. The bash engines are bounded by construction (POSIX sed does not backtrack; the hook caps input at `tail -c 4096`); the Node path — `lint --file`, `audit <transcript>`, `sampling-audit` — caps nothing, so a CI job wired to the CLI would hang rather than fail. Clause 3 takes a run-start lookbehind; clause 4 could **not**, and a differential harness is what established that rather than the reasoning: its trailing class `[a-z0-9]` is a strict subset of the run class, so a match can end mid-run (`_a9Zaz.a|Z9Z_.a`) and the next legitimate match starts after a run char — the lookbehind dropped it, leaving the identifier unstripped and *more* text exposed to the detector, i.e. the v0.23.19 deny-loop regression direction. Clause 4 is now an explicit single-pass scan. 200k class-run: **51474ms → 2.45ms**; the original 500KB repro: **>30s → 0.037s**. New `sanitize-anchor-equivalence.test.js` diffs old vs new spellings over a 4000-probe seeded corpus (byte-identical, both clauses), pins the rejected lookbehind as a named regression probe, and carries controls proving the harness can detect a known-different regex.
38
+ - **fix: the fence terminator guard allocated the whole tail array per fence line.** `lines.slice(i + 1).some(isFence)` short-circuits in `.some` but not in `.slice`: 10k lines → 6ms, 20k → 49ms, 40k → 397ms. The predicate "is there a fence after i?" is `lastFence > i`, precomputed once. 80k fence-dense lines: **11ms**. Semantics pinned in both directions (unterminated fence stays literal text; a closed fence body is still stripped).
39
+ - **fix: the ratio pattern missed the spec's own spelling** (`hooks/banned-vocab.patterns`). Core §10 names its quick-check terms and, in the same sentence, declares `banned-vocab.patterns` the *full enumeration* — so a named term no pattern matches breaks the spec on its own words, silently (nothing denies, so nothing reaches rule-hits). `N× faster` — the spec's spelling, with U+00D7 — matched nothing: the ratio row was ASCII-`x`-only, and `tests/fixtures/banned-vocab-canonical.json` had carried a `"drift acknowledged"` note about exactly this since v6.21.2. Written as an alternation `(x|×)` rather than `[x×]`: a multibyte char inside a bracket expression is one **byte** per position under a C locale. Verified against the bash engine under `C`, `C.UTF-8`, `en_US.UTF-8` and `zh_CN.UTF-8`, all identical to the JS engine. Baseline-anchored `240ms → 72ms (3× faster)` still passes; `3×faster` (no space) correctly does not fire.
40
+ - **reverted before ship: a `应该可以` pattern.** It was written for the same reason, and the pre-tag review measured it denying `fix: 越权用户不应该可以访问后台` (negation) and `feat: 主题应该可以自定义` (requirement prose) as readily as the hedge sense — roughly a coin flip on legitimate messages. POSIX ERE has no lookbehind and a multibyte bracket negation is byte-wise under a C locale, so "not preceded by 不" is not portably expressible in the bash engine. It was also an **inconsistent standard**: this release's own CHECK 5 rationale excludes §7's `能跑` / `it runs` for exactly this false-positive class. A gate that is a coin flip on legitimate work blocks real commits, which is the failure mode this whole release exists to remove. The term is now registered in `spec-coherence-audit.js#ACKNOWLEDGED_UNMECHANIZED` with its measured FP shapes, so CHECK 5 names the waiver in its report instead of passing silently — and a waiver without a demonstrated FP is not accepted.
41
+ - **feat: `spec-coherence-audit.js` gains CHECK 5 — §10 quick-check terms ↔ `banned-vocab.patterns` coverage.** This file's own header had listed the check as *"deferred to v0.13.0"*; it had not landed 54 minor versions later, and the drift it was meant to catch was live. Placeholder terms (`N× faster (no baseline)`) are probed with a substituted value and the probe string is reported, so a verdict is auditable rather than magic. The subject is matched across a **reflowed** bullet, not one physical line — the review showed a `[^\n]*` capture silently narrowing 8 terms to 5 when the 中文 span moved to line 2, i.e. the gate shrinking instead of failing, and this repo reflows the spec routinely. §7 Iron Law #2's own phrasing list is **counted, never raised** (`ironLaw2Unenforced`): §7 does not declare `banned-vocab.patterns` as its enumeration. Crucially the check is now wired to `npm test` via an `uncoveredCount === 0` assertion — as first written it emitted MEDIUM findings while `--strict` exits only on CRITICAL/HIGH and nothing in CI invoked the script, so the drift it exists to catch could have been re-introduced with the suite still green. Human output also stops rendering array stats as `[object Object]`.
42
+ - **fix (review follow-ups, folded into this release): three defects in the commit-msg cleanup itself.** (1) Comment stripping was unconditional, but git only strips `#` lines under `cleanup=strip|scissors` — the **editor** path; under `-m` / `-F` / `--cleanup=whitespace|verbatim` those lines are KEPT in the stored commit, so the first cut muted real violations in `git commit -F release-notes.md`. Stripping is now conditional on a git-authored template being present, detected by locale-proof structure (the `#`+TAB status file list, or ≥3 contiguous comment lines ending at EOF) and biased toward scanning more text when unsure. (2) The cut-line regex was far looser than git's exact literal, so a hand-typed `# -- >8 --` silently truncated the scan; it now requires the `<c> ` + 20-or-more-dashes + ` >8 ` framing. (3) `--comment-char` used `indexOf`, so a repeated flag fed its second value into the scanned text as if the user had submitted it — repeats now exit 2, and the value must be a single **ASCII** char (`length` counts UTF-16 units, so `×` had been accepted). All three RED-verified and re-checked end-to-end through a real `.git/hooks/commit-msg`, 10/10 arms.
43
+ - Tests 739 → 776 (+37, 0 regressions); shellcheck 53 files clean; `lint-argv` 0 hits; `version-cascade-check` ok. Independent pre-tag review additionally brute-forced the two sanitizer rewrites over **33,943,509** inputs (exhaustive to length 7–8 over the boundary alphabets, plus a randomized token corpus) with **0 divergences** from the pre-fix spellings, each harness carrying a control proving it can detect a known-different regex.
44
+
11
45
  ## [0.67.1] - 2026-08-16
12
46
 
13
47
  Governance bookkeeping patch. No hook, script, command, or spec-rule behavior change — the artifact delta is `spec/hard-rules.json` metadata, one tasks/ spec-artifact closure, and the manifests. It carries a version because 0.67.0's own CI gate (correctly) requires any `spec/` edit past the last tag to ship with a bump — this release is that gate's first customer.
package/README.md CHANGED
@@ -139,18 +139,24 @@ node bin/claudemd-lint.js audit ~/.claude/projects/<encoded>/<session>.jsonl
139
139
 
140
140
  | Subcommand | Purpose |
141
141
  |---|---|
142
- | `lint <text>` / `--stdin` | Scan commit-message text for §10-V banned vocab. Exit 0 clean / 1 hits. |
142
+ | `lint <text>` / `--file` / `--stdin` | Scan commit-message text for §10-V banned vocab. Exit 0 clean / 1 hits. |
143
143
  | `audit <jsonl-path>` | Scan all assistant-text turns in a Claude Code transcript jsonl. Skips `@ratio` patterns by default (chat prose has different baseline conventions); pass `--include-ratio` to include them. |
144
144
  | `--json` | JSON output (machine-readable for CI). |
145
+ | `--commit-msg` / `--no-commit-msg` | (`lint`) Force git commit-message cleanup on/off. Auto-ON for `COMMIT_EDITMSG` / `MERGE_MSG` / `SQUASH_MSG` / `TAG_EDITMSG` / `NOTES_EDITMSG`. |
146
+ | `--comment-char <c>` | (`lint`) git `core.commentChar`. Default `#`. |
145
147
  | `--version` / `--help` | Standard. |
146
148
 
147
149
  **Pre-commit example (`.git/hooks/commit-msg`)**:
148
150
 
149
151
  ```bash
150
152
  #!/usr/bin/env bash
151
- npx claudemd-cli lint --stdin < "$1" || exit 1
153
+ npx claudemd-cli lint "$1" || exit 1
152
154
  ```
153
155
 
156
+ > **Pass the path, not the bytes.** A `commit-msg` hook receives the message file *before* git's cleanup pass, so when you commit through an **editor** it still contains git's `#` template/status block and — under `git commit -v` — the whole staged diff below the `>8` scissors line. git discards that before storing the commit, so `lint` does too: hand it the **path** and it recognizes the filename and scans only what git will keep. A banned word sitting in your staged diff no longer blocks a clean message. If your hook pipes instead (`… --stdin < "$1"`), add `--commit-msg` to get the same cleanup.
157
+ >
158
+ > This is scoped to git-authored template text, not to `#` in general. Under `git commit -m` / `-F` / `--cleanup=whitespace|verbatim` git **keeps** column-0 `#` lines in the stored message, and so does `lint` — a `#` heading in a `-F release-notes.md` body stays in scope. Indented `# …` always stays in scope (git strips at column 0 only). Non-default `core.commentChar` → pass `--comment-char <c>`; `core.commentChar=auto` is not detected, so set it explicitly there.
159
+
154
160
  The CLI does NOT depend on `~/.claude/` state — pure stateless input → stdout/stderr + exit code. Same enforcement, anywhere Node 20+ runs.
155
161
 
156
162
  ---
@@ -24,6 +24,8 @@ import {
24
24
  countStringContentAssistantRows,
25
25
  formatHumanReadable,
26
26
  formatJSON,
27
+ stripGitCommitComments,
28
+ looksLikeGitMessageFile,
27
29
  } from '../scripts/lib/lint.js';
28
30
 
29
31
  const HERE = path.dirname(fileURLToPath(import.meta.url));
@@ -44,6 +46,11 @@ Flags:
44
46
  --include-ratio (audit only) Include @ratio patterns.
45
47
  Default OFF — chat prose has different
46
48
  baseline conventions from commit messages.
49
+ --commit-msg / --no-commit-msg (lint only) Force git commit-message
50
+ cleanup on / off. Auto-ON for files named
51
+ COMMIT_EDITMSG / MERGE_MSG / SQUASH_MSG /
52
+ TAG_EDITMSG / NOTES_EDITMSG.
53
+ --comment-char <c> (lint only) git core.commentChar. Default '#'.
47
54
 
48
55
  Notes:
49
56
  A bare \`lint <arg>\` whose only positional is an existing regular file
@@ -51,6 +58,12 @@ Notes:
51
58
  works as expected in pre-commit hooks. Pass --stdin or quote literal
52
59
  text to opt out.
53
60
 
61
+ A commit-msg hook is handed the RAW message file, which still holds git's
62
+ \`#\` template block and — under \`git commit -v\` — the staged diff below the
63
+ \`>8\` scissors line. git drops both before storing the commit, so cleanup
64
+ mode drops them too: only the text git will actually keep is scanned.
65
+ Piping instead of passing a path? Use \`--commit-msg --stdin\`.
66
+
54
67
  Exit codes:
55
68
  0 no hits
56
69
  1 one or more hits
@@ -105,10 +118,49 @@ function validateAndExpandFlags(args, knownBools, knownValues, sub) {
105
118
  }
106
119
 
107
120
  function lintCmd(rawArgs) {
108
- const args = validateAndExpandFlags(rawArgs, ['--json', '--stdin'], ['--file'], 'lint');
121
+ const args = validateAndExpandFlags(
122
+ rawArgs,
123
+ ['--json', '--stdin', '--commit-msg', '--no-commit-msg'],
124
+ ['--file', '--comment-char'],
125
+ 'lint',
126
+ );
109
127
  const json = args.includes('--json'); // argv-lint:allow — validated upstream by validateAndExpandFlags
110
128
  const stdin = args.includes('--stdin'); // argv-lint:allow — validated upstream by validateAndExpandFlags
111
129
 
130
+ // Commit-message cleanup mode. `--commit-msg` forces it on (needed for the
131
+ // `cat "$1" | claudemd-cli lint --stdin` shape, where there is no filename
132
+ // to key off), `--no-commit-msg` forces it off, otherwise it is inferred
133
+ // from the input FILENAME below.
134
+ const forceCommitMsg = args.includes('--commit-msg'); // argv-lint:allow — validated upstream by validateAndExpandFlags
135
+ const denyCommitMsg = args.includes('--no-commit-msg'); // argv-lint:allow — validated upstream by validateAndExpandFlags
136
+ if (forceCommitMsg && denyCommitMsg) {
137
+ process.stderr.write('lint: choose one of --commit-msg or --no-commit-msg, not both\n');
138
+ process.exit(2);
139
+ }
140
+ let commentChar = '#';
141
+ // ALL occurrences, not indexOf(): with only the first value slot filtered out
142
+ // of `positional`, `--comment-char ';' --comment-char significantly` fed the
143
+ // second value into the scanned text and reported a "hit" on a word the user
144
+ // never submitted — the same silent-value-swallow family validateAndExpandFlags
145
+ // exists to close, reopened one occurrence deep.
146
+ const ccIdxs = args.reduce((acc, a, i) => (a === '--comment-char' ? acc.concat(i) : acc), []); // argv-lint:allow — validated upstream by validateAndExpandFlags
147
+ if (ccIdxs.length > 1) {
148
+ process.stderr.write('lint: --comment-char given more than once — pass it exactly once\n');
149
+ process.exit(2);
150
+ }
151
+ const ccIdx = ccIdxs.length === 1 ? ccIdxs[0] : -1;
152
+ if (ccIdx !== -1) {
153
+ const next = args[ccIdx + 1];
154
+ // Single ASCII char: git's core.commentChar is one byte, and `length` counts
155
+ // UTF-16 units so a bare length check accepted `×` (and rejected `🙂` only
156
+ // because it is a surrogate pair).
157
+ if (!next || next.length !== 1 || next.codePointAt(0) > 0x7f) {
158
+ process.stderr.write('lint: --comment-char requires a single ASCII character (git core.commentChar)\n');
159
+ process.exit(2);
160
+ }
161
+ commentChar = next;
162
+ }
163
+
112
164
  // --file <path> consumes the next non-flag arg.
113
165
  let filePath = null;
114
166
  const fileIdx = args.indexOf('--file'); // argv-lint:allow — validated upstream by validateAndExpandFlags
@@ -123,6 +175,12 @@ function lintCmd(rawArgs) {
123
175
  const positional = args.filter((a, i) => {
124
176
  if (a.startsWith('--')) return false;
125
177
  if (fileIdx !== -1 && i === fileIdx + 1) return false;
178
+ // …and the value slot of every other value-taking flag, or
179
+ // `lint --comment-char ';' --stdin` would treat ';' as literal text to scan
180
+ // (and then trip the "--stdin and positional text are mutually exclusive"
181
+ // guard) — the same silent-value-swallow family validateAndExpandFlags exists to
182
+ // prevent.
183
+ if (ccIdx !== -1 && i === ccIdx + 1) return false;
126
184
  return true;
127
185
  });
128
186
 
@@ -141,6 +199,10 @@ function lintCmd(rawArgs) {
141
199
  }
142
200
 
143
201
  let text;
202
+ // Path the text was read FROM, when there is one — the auto-detect key for
203
+ // commit-message cleanup. stdin leaves it null (no filename to key off), which
204
+ // is exactly why --commit-msg exists.
205
+ let sourcePath = null;
144
206
  if (stdin) {
145
207
  try {
146
208
  text = fs.readFileSync(0, 'utf8');
@@ -170,6 +232,7 @@ function lintCmd(rawArgs) {
170
232
  }
171
233
  try {
172
234
  text = fs.readFileSync(filePath, 'utf8');
235
+ sourcePath = filePath;
173
236
  } catch (e) {
174
237
  process.stderr.write(`lint: failed to read ${filePath}: ${e.message}\n`);
175
238
  process.exit(2);
@@ -209,6 +272,7 @@ function lintCmd(rawArgs) {
209
272
  const st = fs.statSync(arg);
210
273
  if (st.isFile()) {
211
274
  text = fs.readFileSync(arg, 'utf8');
275
+ sourcePath = arg;
212
276
  } else if (looksLikePath) {
213
277
  process.stderr.write(`lint: '${arg}' is not a regular file (use --file PATH for explicit file scan or quote literal text)\n`);
214
278
  process.exit(2);
@@ -228,6 +292,22 @@ function lintCmd(rawArgs) {
228
292
  process.exit(2);
229
293
  }
230
294
 
295
+ // Git commit-message cleanup. A `commit-msg` hook is handed the RAW
296
+ // COMMIT_EDITMSG, which still carries git's `#` template/status block and —
297
+ // under `git commit -v` — the whole staged diff below the scissors line.
298
+ // git discards all of it before storing the message (verified against git
299
+ // 2.43.0: a 26-line COMMIT_EDITMSG stored a 1-line message), so scanning it
300
+ // raw denied commits over words the author never wrote — in their own staged
301
+ // diff, or in git's status block listing a file named e.g. `comprehensive.js`.
302
+ // The bypass note then pointed at a message the word does not appear in.
303
+ //
304
+ // Runs BEFORE the escape-hatch check on purpose: `[allow-banned-vocab]`
305
+ // sitting in a `#` line git will discard is not in the commit message either.
306
+ const commitMsgCleanup = !denyCommitMsg && (forceCommitMsg || looksLikeGitMessageFile(sourcePath));
307
+ if (commitMsgCleanup) {
308
+ text = stripGitCommitComments(text, commentChar);
309
+ }
310
+
231
311
  // Per-commit escape hatch — mirrors hooks/banned-vocab-check.sh:36. Without
232
312
  // this, `claudemd-cli lint --file=.git/COMMIT_EDITMSG` in a git pre-commit
233
313
  // hook silently disagreed with the in-CC bash hook: the same commit message
@@ -237,7 +317,7 @@ function lintCmd(rawArgs) {
237
317
  const ESCAPE_HATCH = '[allow-banned-vocab]';
238
318
  if (text.includes(ESCAPE_HATCH)) {
239
319
  if (json) {
240
- process.stdout.write(formatJSON({ scope: 'lint', text, hits: [], bypass: 'allow-banned-vocab' }) + '\n');
320
+ process.stdout.write(formatJSON({ scope: 'lint', text, hits: [], bypass: 'allow-banned-vocab', commitMsgCleanup }) + '\n');
241
321
  } else {
242
322
  process.stdout.write(`OK: §10-V scan bypassed via ${ESCAPE_HATCH}.\n`);
243
323
  }
@@ -255,7 +335,7 @@ function lintCmd(rawArgs) {
255
335
 
256
336
  const hits = scan(text, { excludeRatio: baselineExempt, sanitize: true });
257
337
  if (json) {
258
- process.stdout.write(formatJSON({ scope: 'lint', text, hits }) + '\n');
338
+ process.stdout.write(formatJSON({ scope: 'lint', text, hits, commitMsgCleanup }) + '\n');
259
339
  } else {
260
340
  const out = formatHumanReadable({ scope: 'lint', hits });
261
341
  if (hits.length === 0) process.stdout.write(out + '\n');
@@ -97,7 +97,13 @@
97
97
  相当不错|评价性形容词 — 描述具体属性
98
98
 
99
99
  # ─── Baseline-less ratios (EN) ──────────────────────────────────────────────
100
- \b[0-9]+x[[:space:]]+(faster|slower|better)\b|@ratio ratio without baseline cite before after numbers
100
+ # `x` OR `×` (U+00D7). Spec §10's own quick-check spells this term `N× faster`,
101
+ # so the ASCII-only form let the exact shape the rule names through: `3x faster`
102
+ # denied, `3× faster` passed. Written as an alternation rather than `[x×]`
103
+ # because a multibyte char inside a bracket expression is one BYTE per position
104
+ # under a C locale — `[0-9]+[x×]` would match `3`+`\xc3` there and then fail on
105
+ # the space. Alternation is the idiom the 中文 rows below already use.
106
+ \b[0-9]+(x|×)[[:space:]]+(faster|slower|better)\b|@ratio ratio without baseline — cite before → after numbers
101
107
 
102
108
  # ─── Baseline-less ratios (中文) ────────────────────────────────────────────
103
109
  [0-9]+%(更快|更慢|更好|更高效)|@ratio 无基线的比率 — 给出 before → after 数字
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudemd-cli",
3
- "version": "0.67.1",
3
+ "version": "0.68.1",
4
4
  "description": "Standalone CLI for §10-V banned-vocab + transcript scanning. Companion to the claudemd Claude Code plugin (github.com/sdsrss/claudemd) for use in git pre-commit hooks, GitHub Actions, and other agents.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -104,19 +104,30 @@ export function stripIdentifiers(text) {
104
104
  // guard: blanked text is a subset of before, so this can only EXPOSE
105
105
  // more text to the detector, never hide a claim.
106
106
  const lines = text.split('\n');
107
+ const isFence = (l) => /^\s*```/.test(l);
108
+ // "Is there a closing fence after i?" — precomputed once. The direct
109
+ // `lines.slice(i + 1).some(isFence)` spelling allocates the entire tail array
110
+ // on every fence line even though `.some` short-circuits, which is O(lines²):
111
+ // measured 10k lines → 6ms but 40k → 397ms (4× the input, 63× the time).
112
+ // `lastFence > i` is the same predicate — the max index of a fence line is
113
+ // after i iff any fence line is after i — in O(1) after one O(lines) pass.
114
+ let lastFence = -1;
115
+ for (let i = lines.length - 1; i >= 0; i--) {
116
+ if (isFence(lines[i])) { lastFence = i; break; }
117
+ }
107
118
  const kept = [];
108
119
  let inFence = false;
109
120
  for (let i = 0; i < lines.length; i++) {
110
121
  const line = lines[i];
111
- if (/^\s*```/.test(line)) {
122
+ if (isFence(line)) {
112
123
  if (inFence) { inFence = false; continue; }
113
- if (lines.slice(i + 1).some(l => /^\s*```/.test(l))) { inFence = true; continue; }
124
+ if (lastFence > i) { inFence = true; continue; }
114
125
  kept.push(line);
115
126
  continue;
116
127
  }
117
128
  if (!inFence) kept.push(line);
118
129
  }
119
- return kept.join('\n')
130
+ const stripped = kept.join('\n')
120
131
  // 2. Inline backtick spans.
121
132
  .replace(/`[^`]*`/g, ' ')
122
133
  // 3. Slashed-path runs (branch names, file paths, URLs) — Path 2's rule.
@@ -127,7 +138,30 @@ export function stripIdentifiers(text) {
127
138
  // matches an N/M shape at all (they are `N% faster` / `Nx faster` and the
128
139
  // 中文 equivalents). The premise did not hold; reverted rather than ship a
129
140
  // 2x cost on long class-character runs for a fix that was not one.
130
- .replace(/[A-Za-z0-9._@~-]*\/[A-Za-z0-9._/@~-]*/g, ' ')
141
+ //
142
+ // The leading lookbehind is a COMPLEXITY guard, not a semantic one.
143
+ // This clause is a `<class-run><required-delimiter>` shape and `/` is
144
+ // not in the leading class, so a shorter prefix of the run is always
145
+ // followed by another class char and can never satisfy the delimiter:
146
+ // backtracking inside the run never finds a match the maximal run
147
+ // missed. An unanchored /g regex still retries from every offset INSIDE
148
+ // the run and rescans it each time — O(run²). Measured pre-fix on
149
+ // delimiter-free input: 4k→6ms, 8k→24ms, 16k→94ms, 32k→376ms,
150
+ // 64k→1495ms (a clean 4× per doubling), and `lint --file` on a 500KB
151
+ // single-token blob ran past a 30s timeout. Rejecting non-run-start
152
+ // offsets in O(1) makes the pass linear (200k class-run: 51474ms →
153
+ // 2.5ms) with byte-identical output.
154
+ //
155
+ // Equivalence here rests on a second property that clause 4 does NOT
156
+ // share: this clause's trailing class is a SUPERSET of its leading one,
157
+ // so a match always ends outside a leading-class run and the next
158
+ // candidate start is never mid-run. Measured, not assumed —
159
+ // sanitize-anchor-equivalence.test.js diffs both spellings over a seeded
160
+ // corpus (it is what caught the clause-4 case below).
161
+ //
162
+ // The bash engines need no equivalent: POSIX sed does not backtrack and
163
+ // the hook caps its input at `tail -c 4096`; the Node path caps nothing.
164
+ .replace(/(?<![A-Za-z0-9._@~-])[A-Za-z0-9._@~-]*\/[A-Za-z0-9._/@~-]*/g, ' ')
131
165
  // 4. Bare dotted-file tokens (foo.js, comprehensive-parser.ts) — CLI
132
166
  // extension. The extension must start with a LOWERCASE letter, which
133
167
  // (a) excludes decimals / versions ("3.5x", "v6.14") whose ".5x"/".14"
@@ -135,7 +169,149 @@ export function stripIdentifiers(text) {
135
169
  // and (b) excludes sentence-boundary typos ("comprehensive.Next", capital
136
170
  // after the dot) so a real claim isn't stripped. Only true `name.ext`
137
171
  // identifiers with a lowercase extension are removed.
138
- .replace(/[A-Za-z0-9_-]+\.[a-z][a-z0-9]*/g, ' ');
172
+ //
173
+ // Clause 4 canNOT use clause 3's lookbehind: its trailing class
174
+ // `[a-z0-9]` is a strict SUBSET of the leading run class, so a match can
175
+ // end in the MIDDLE of a run (`_a9Zaz.a|Z9Z_.a` — the ext stops at the
176
+ // uppercase Z) and the next legitimate match then starts at a position
177
+ // whose predecessor IS a run char. A lookbehind drops that match and
178
+ // leaves the identifier unstripped — more text exposed to the detector,
179
+ // i.e. the FP deny-loop returning. Clause 3 is immune because its
180
+ // trailing class is a SUPERSET of its leading one, so a match always
181
+ // ends outside a leading-class run; that equivalence is measured, not
182
+ // assumed, in sanitize-anchor-equivalence.test.js.
183
+ //
184
+ // So clause 4 runs as an explicit single-pass scan instead — same
185
+ // semantics, O(n) instead of O(run²).
186
+ ;
187
+ return stripDottedFileTokens(stripped);
188
+ }
189
+
190
+ // Linear-time equivalent of /[A-Za-z0-9_-]+\.[a-z][a-z0-9]*/g → ' '.
191
+ //
192
+ // Every start offset inside one run shares the same greedy run END, so the
193
+ // regex's per-offset retry recomputes an answer that cannot differ — that is
194
+ // the O(run²) in the global form. Walking runs once reproduces the /g contract
195
+ // exactly, including the mid-run restart above: after a match the scan resumes
196
+ // at the match end, which becomes the next candidate start even though its
197
+ // predecessor is a run char.
198
+ const isRunChar = (c) =>
199
+ (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') ||
200
+ c === '_' || c === '-';
201
+ const isExtChar = (c) => (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9');
202
+
203
+ export function stripDottedFileTokens(text) {
204
+ if (!text) return text;
205
+ const n = text.length;
206
+ let out = '';
207
+ let copied = 0;
208
+ let i = 0;
209
+ while (i < n) {
210
+ if (!isRunChar(text[i])) { i++; continue; }
211
+ const start = i;
212
+ let e = i;
213
+ while (e < n && isRunChar(text[e])) e++;
214
+ // Need `.` then a LOWERCASE letter — the extension guard that keeps
215
+ // decimals/versions ("3.5x", "v6.14") and sentence boundaries intact.
216
+ if (e < n && text[e] === '.' && e + 1 < n && text[e + 1] >= 'a' && text[e + 1] <= 'z') {
217
+ let end = e + 2;
218
+ while (end < n && isExtChar(text[end])) end++;
219
+ out += text.slice(copied, start) + ' ';
220
+ copied = end;
221
+ i = end;
222
+ continue;
223
+ }
224
+ i = e;
225
+ }
226
+ return out + text.slice(copied);
227
+ }
228
+
229
+ // Git message files whose raw on-disk contents are NOT the stored commit
230
+ // message. A `commit-msg` hook is handed the file BEFORE git's cleanup pass,
231
+ // so it still carries the `#` template/status block and — under `git commit -v`
232
+ // — the entire staged diff below the scissors line. Verified against git 2.43.0:
233
+ // a 26-line COMMIT_EDITMSG stored a 1-line message.
234
+ const GIT_MSG_FILENAMES = new Set([
235
+ 'COMMIT_EDITMSG',
236
+ 'MERGE_MSG',
237
+ 'SQUASH_MSG',
238
+ 'TAG_EDITMSG',
239
+ 'NOTES_EDITMSG',
240
+ ]);
241
+
242
+ // Filename-scoped on purpose, never content-sniffed: a heuristic that stripped
243
+ // `#` lines from any file would silently mute markdown headings in
244
+ // `lint --file notes.md` — trading a false positive for a false negative.
245
+ export function looksLikeGitMessageFile(filePath) {
246
+ if (!filePath) return false;
247
+ return GIT_MSG_FILENAMES.has(path.basename(filePath));
248
+ }
249
+
250
+ // Reproduce git's own cleanup so the CLI's verdict matches the message git
251
+ // will actually store (builtin/commit.c): truncate at the cut line when one is
252
+ // present, then drop comment-prefixed lines (strbuf_stripspace).
253
+ //
254
+ // Three fidelity details, each of which a looser implementation gets wrong in
255
+ // the false-NEGATIVE direction — i.e. it would silently mute a real violation:
256
+ //
257
+ // • git matches the comment prefix at column 0 with no leading-whitespace
258
+ // tolerance, so ` # note` survives into the stored message and must stay
259
+ // scannable.
260
+ //
261
+ // • The cut line is an EXACT literal in git (`wt_status_locate_end` strcmps
262
+ // against comment-char + space + 24 dashes + ` >8 ` + 24 dashes), and git
263
+ // truncates there only under `-v` / `cleanup=scissors`. A loose
264
+ // `-{2,}`-style pattern let a hand-typed `# -- >8 --` drop the whole rest
265
+ // of the message from the scan. The dash count is ranged (20+) rather than
266
+ // pinned at 24 to tolerate other git versions, but the ` >8 ` framing and
267
+ // the leading `<c> ` are required.
268
+ //
269
+ // • **Comment stripping is conditional on a git-authored template being
270
+ // present.** git only strips `#` lines under cleanup=strip/scissors, which
271
+ // is the EDITOR path; under `-m` / `-F` / `--cleanup=whitespace|verbatim`
272
+ // the mode is `whitespace` and column-0 `#` lines are KEPT in the commit.
273
+ // Measured on git 2.43.0 (six shapes, per lint-commit-msg.test.js): the
274
+ // three user-supplied-message shapes all stored their `#` line, while both
275
+ // editor shapes stored none. Stripping unconditionally therefore muted a
276
+ // real violation in `git commit -F release-notes.md` or
277
+ // `-m "$(cat notes.md)"` whenever the body carried a markdown heading.
278
+ export function stripGitCommitComments(text, commentChar = '#') {
279
+ if (!text) return text;
280
+ const c = (typeof commentChar === 'string' && commentChar.length === 1) ? commentChar : '#';
281
+ const esc = c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
282
+ const cutLine = new RegExp(`^${esc} -{20,} >8 -{20,}\\s*$`);
283
+
284
+ // 1. Truncate at git's cut line (also a definitive template signal).
285
+ const all = text.split('\n');
286
+ const cutAt = all.findIndex(l => cutLine.test(l));
287
+ const lines = cutAt === -1 ? all : all.slice(0, cutAt);
288
+ const sawCutLine = cutAt !== -1;
289
+
290
+ // 2. Strip comment lines only when git wrote a template here.
291
+ if (!sawCutLine && !hasGitTemplate(lines, c)) return lines.join('\n');
292
+ return lines.filter(l => !l.startsWith(c)).join('\n');
293
+ }
294
+
295
+ // Locale-proof template detection. Both signals are structural — git localizes
296
+ // the LABELS ("Changes to be committed", "Please enter the commit message…")
297
+ // but not the `#`+TAB status prefix, and the intro paragraph is ≥3 comment
298
+ // lines in every translation.
299
+ //
300
+ // Deliberately conservative: when neither signal fires we scan MORE text, so a
301
+ // misdetection costs a false positive (visible, bypassable) rather than a
302
+ // silent miss. `commit.status=false` editor commits emit zero comment lines
303
+ // (measured), so the undetected case there strips nothing anyway.
304
+ function hasGitTemplate(lines, c) {
305
+ // git's status file list: `#\tmodified: path`
306
+ if (lines.some(l => l.startsWith(c + '\t'))) return true;
307
+ // The intro paragraph: ≥3 contiguous comment lines ending at EOF (trailing
308
+ // blanks ignored). A hand-written `-m` message carries one such line, not a
309
+ // run of three terminating the file.
310
+ let i = lines.length - 1;
311
+ while (i >= 0 && lines[i].trim() === '') i--;
312
+ let run = 0;
313
+ while (i >= 0 && lines[i].startsWith(c)) { run++; i--; }
314
+ return run >= 3;
139
315
  }
140
316
 
141
317
  export function scan(text, { excludeRatio = false, patterns, sanitize = false } = {}) {