claudemd-cli 0.87.0 → 0.89.0

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/README.md CHANGED
@@ -76,7 +76,7 @@ Once installed, hooks run silently in the background. Verbose log: `~/.claude/lo
76
76
  | Trigger | Hook | What happens |
77
77
  |---|---|---|
78
78
  | `git commit` with banned vocab (e.g. `significantly`, `70% faster`, `should work`) | `banned-vocab-check` | Blocks the commit with a message pointing to the §10-V spec rule. |
79
- | Bash command with `rm -rf $VAR` (unvalidated expansion), unpinned `npx <pkg>`, or a fetch piped into an interpreter (`curl … \| sh`) | `pre-bash-safety-check` (v0.5.0+) | Blocks at PreToolUse:Bash per §8 SAFETY. Bypass via `[allow-rm-rf-var]` / `[allow-npx-unpinned]` / `[allow-curl-sh]` token in the command, or pin/validate the variable. |
79
+ | Bash command with `rm -rf $VAR` (unvalidated expansion), unpinned `npx <pkg>`, or a fetch piped into an interpreter (`curl … \| sh`) | `pre-bash-safety-check` (v0.5.0+) | Blocks at PreToolUse:Bash per §8 SAFETY. Bypass via `[allow-rm-rf-var]` / `[allow-npx-unpinned]` / `[allow-curl-sh]` token in the command, or pin/validate the variable. The `rm` arm is **wider than the `-rf` shorthand**: it fires on any `-r` / `-R` / `-f` / `-F` short flag or `--recursive` / `--force` long form (so `rm -f "$VAR/one-file"` is blocked too), and on `find "$VAR" -delete` / `find "$VAR" -exec rm …`. For an `rm` the deny message names the flag it matched (`rm -f`, `rm --force`, …) rather than a fixed `rm -rf`; the two `find` forms share one class label, `find … -delete/-exec rm`. |
80
80
  | `git push` while the pushed branch's CI is red | `ship-baseline-check` | Blocks the push (2-second `gh run list` timeout; fail-open if `gh` absent or times out). |
81
81
  | Bash command matching ship/push/deploy/release with an unread matched `MEMORY.md` entry | `memory-read-check` | Blocks the command with a list of memory files to Read first. |
82
82
  | Session end with `~/.claude/tmp/` growth > 20 entries | `residue-audit` | Advisory stderr warning; never blocks. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudemd-cli",
3
- "version": "0.87.0",
3
+ "version": "0.89.0",
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": {
@@ -80,6 +80,40 @@ function posixClassesToJs(regex) {
80
80
  return out;
81
81
  }
82
82
 
83
+ // Compiling the patterns is per-call work whose result never varies: the
84
+ // pattern string plus the fixed `i` flag fully determine the regex, so the
85
+ // same string always compiles to the same matcher. scan() runs once per
86
+ // assistant turn in the audit family, which recompiled the same 23 patterns
87
+ // on every one of them — 184,000 compilations for an 8,000-turn run.
88
+ //
89
+ // The key is the pattern STRING, deliberately not the pattern object: two
90
+ // callers can hold different objects (a fresh readPatterns() array per run)
91
+ // carrying the same regex, and the same regex can arrive with a different
92
+ // `reason` or `isRatio`. Those two fields stay with the caller's object and
93
+ // are read from it below — only the compiled matcher is shared.
94
+ //
95
+ // A pattern that does not compile caches as null, so the fail-open skip in
96
+ // scan() keeps costing one lookup instead of one throw per call.
97
+ //
98
+ // Sharing one compiled regex across calls is stateless HERE because scan()
99
+ // matches with String.match, which resets lastIndex even for a global regex.
100
+ // That is the precondition, not an accident: moving this to .test() or
101
+ // .exec() would make a cached `g`-flagged matcher resume from the previous
102
+ // call's offset. A test for it was tried and deleted — with .match it cannot
103
+ // be driven red, so the constraint lives in this comment instead.
104
+ const RE_CACHE = new Map();
105
+ function compilePattern(regex) {
106
+ if (RE_CACHE.has(regex)) return RE_CACHE.get(regex);
107
+ let re;
108
+ try {
109
+ re = new RegExp(posixClassesToJs(regex), 'i');
110
+ } catch {
111
+ re = null; // bad regex — skip (fail-open)
112
+ }
113
+ RE_CACHE.set(regex, re);
114
+ return re;
115
+ }
116
+
83
117
  // stripIdentifiers — remove code / identifier / path regions before §10-V
84
118
  // matching so a filename, branch, or backtick span quoting a high-fire word is
85
119
  // not read as a value claim. `\b` treats '-', '/', '.' as word boundaries, so
@@ -382,12 +416,8 @@ export function scan(text, { excludeRatio = false, patterns, sanitize = false }
382
416
  const hits = [];
383
417
  for (const p of pats) {
384
418
  if (excludeRatio && p.isRatio) continue;
385
- let re;
386
- try {
387
- re = new RegExp(posixClassesToJs(p.regex), 'i');
388
- } catch {
389
- continue; // bad regex — skip (fail-open)
390
- }
419
+ const re = compilePattern(p.regex);
420
+ if (!re) continue; // bad regex — skip (fail-open)
391
421
  const m = scanText.match(re);
392
422
  if (m) hits.push({ match: m[0], regex: p.regex, reason: p.reason, isRatio: p.isRatio });
393
423
  }