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 +34 -0
- package/README.md +8 -2
- package/bin/claudemd-lint.js +83 -3
- package/hooks/banned-vocab.patterns +7 -1
- package/package.json +1 -1
- package/scripts/lib/lint.js +181 -5
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
|
|
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
|
---
|
package/bin/claudemd-lint.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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.
|
|
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": {
|
package/scripts/lib/lint.js
CHANGED
|
@@ -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 (
|
|
122
|
+
if (isFence(line)) {
|
|
112
123
|
if (inFence) { inFence = false; continue; }
|
|
113
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 } = {}) {
|