claudemd-cli 0.71.3 → 0.72.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
@@ -371,7 +371,7 @@ claudemd/
371
371
  │ └── hooks.json # authoritative hook registration (v0.1.5+); CC expands ${CLAUDE_PLUGIN_ROOT} here
372
372
  ├── commands/ # 16 slash-command markdown files
373
373
  ├── bin/ # standalone CLI entrypoint (claudemd-lint.js → `npx claudemd-cli` on npmjs.org)
374
- ├── scripts/ # 18 Node.js scripts + scripts/lib/ (single-source registry, lint, etc.)
374
+ ├── scripts/ # 19 Node.js scripts + scripts/lib/ (single-source registry, lint, etc.)
375
375
  ├── spec/ # shipped v6.25.4 CLAUDE*.md trio + OPERATOR.md + hard-rules.json manifest
376
376
  ├── tests/ # hook shell tests + Node.js tests + integration + fixtures
377
377
  ├── docs/ # ADDING-NEW-HOOK.md + RULE-HITS-SCHEMA.md + superpowers/
@@ -111,9 +111,13 @@ function readCommitTemplate(sourcePath) {
111
111
 
112
112
  for (const cwd of starts) {
113
113
  try {
114
- const git = (...a) => spawnSync('git', a, {
115
- cwd, encoding: 'utf8', timeout: 5000, windowsHide: true,
116
- });
114
+ const git = (...a) =>
115
+ spawnSync('git', a, {
116
+ cwd,
117
+ encoding: 'utf8',
118
+ timeout: 5000,
119
+ windowsHide: true,
120
+ });
117
121
 
118
122
  const cfg = git('config', '--get', 'commit.template');
119
123
  if (cfg.status !== 0 || !cfg.stdout || !cfg.stdout.trim()) continue;
@@ -152,17 +156,17 @@ function lintCmd(rawArgs) {
152
156
  rawArgs,
153
157
  ['--json', '--stdin', '--commit-msg', '--no-commit-msg'],
154
158
  ['--file', '--comment-char'],
155
- 'lint',
159
+ 'lint'
156
160
  );
157
- const json = args.includes('--json'); // argv-lint:allow — validated upstream by validateAndExpandFlags
158
- const stdin = args.includes('--stdin'); // argv-lint:allow — validated upstream by validateAndExpandFlags
161
+ const json = args.includes('--json'); // argv-lint:allow — validated upstream by validateAndExpandFlags
162
+ const stdin = args.includes('--stdin'); // argv-lint:allow — validated upstream by validateAndExpandFlags
159
163
 
160
164
  // Commit-message cleanup mode. `--commit-msg` forces it on (needed for the
161
165
  // `cat "$1" | claudemd-cli lint --stdin` shape, where there is no filename
162
166
  // to key off), `--no-commit-msg` forces it off, otherwise it is inferred
163
167
  // from the input FILENAME below.
164
- const forceCommitMsg = args.includes('--commit-msg'); // argv-lint:allow — validated upstream by validateAndExpandFlags
165
- const denyCommitMsg = args.includes('--no-commit-msg'); // argv-lint:allow — validated upstream by validateAndExpandFlags
168
+ const forceCommitMsg = args.includes('--commit-msg'); // argv-lint:allow — validated upstream by validateAndExpandFlags
169
+ const denyCommitMsg = args.includes('--no-commit-msg'); // argv-lint:allow — validated upstream by validateAndExpandFlags
166
170
  if (forceCommitMsg && denyCommitMsg) {
167
171
  process.stderr.write('lint: choose one of --commit-msg or --no-commit-msg, not both\n');
168
172
  process.exit(2);
@@ -193,7 +197,7 @@ function lintCmd(rawArgs) {
193
197
 
194
198
  // --file <path> consumes the next non-flag arg.
195
199
  let filePath = null;
196
- const fileIdx = args.indexOf('--file'); // argv-lint:allow — validated upstream by validateAndExpandFlags
200
+ const fileIdx = args.indexOf('--file'); // argv-lint:allow — validated upstream by validateAndExpandFlags
197
201
  if (fileIdx !== -1) {
198
202
  const next = args[fileIdx + 1];
199
203
  if (!next || next.startsWith('--')) {
@@ -253,7 +257,9 @@ function lintCmd(rawArgs) {
253
257
  try {
254
258
  const st = fs.statSync(filePath);
255
259
  if (!st.isFile()) {
256
- process.stderr.write(`lint: '${filePath}' is not a regular file (got ${st.isDirectory() ? 'directory' : 'special file'})\n`);
260
+ process.stderr.write(
261
+ `lint: '${filePath}' is not a regular file (got ${st.isDirectory() ? 'directory' : 'special file'})\n`
262
+ );
257
263
  process.exit(2);
258
264
  }
259
265
  } catch (e) {
@@ -304,7 +310,9 @@ function lintCmd(rawArgs) {
304
310
  text = fs.readFileSync(arg, 'utf8');
305
311
  sourcePath = arg;
306
312
  } else if (looksLikePath) {
307
- process.stderr.write(`lint: '${arg}' is not a regular file (use --file PATH for explicit file scan or quote literal text)\n`);
313
+ process.stderr.write(
314
+ `lint: '${arg}' is not a regular file (use --file PATH for explicit file scan or quote literal text)\n`
315
+ );
308
316
  process.exit(2);
309
317
  }
310
318
  // Non-path-shape + non-file (e.g. a symlink loop, fifo) → fall through to text scan.
@@ -349,7 +357,9 @@ function lintCmd(rawArgs) {
349
357
  const ESCAPE_HATCH = '[allow-banned-vocab]';
350
358
  if (text.includes(ESCAPE_HATCH)) {
351
359
  if (json) {
352
- process.stdout.write(formatJSON({ scope: 'lint', text, hits: [], bypass: 'allow-banned-vocab', commitMsgCleanup }) + '\n');
360
+ process.stdout.write(
361
+ formatJSON({ scope: 'lint', text, hits: [], bypass: 'allow-banned-vocab', commitMsgCleanup }) + '\n'
362
+ );
353
363
  } else {
354
364
  process.stdout.write(`OK: §10-V scan bypassed via ${ESCAPE_HATCH}.\n`);
355
365
  }
@@ -361,7 +371,13 @@ function lintCmd(rawArgs) {
361
371
  // ... digit) OR the literal word `baseline`, ratio-class patterns (tagged
362
372
  // `@ratio` in their reason column) are suppressed. Non-ratio hedges /
363
373
  // adjectives still match.
364
- const HAS_NUMERIC_ARROW = /\d\S*\s*(?:→|->|=>)\s*\d/;
374
+ // `\S{0,64}`, not `\S*` (2026-09-02 audit R11-12): with an unbounded run and
375
+ // no arrow present, the unanchored scan retries from every offset and
376
+ // rescans the tail — quadratic, 710ms on a 20k digit run, and this runs on
377
+ // the RAW file before scan(), so lint.js:150-157's sanitizer fix for the same
378
+ // family never covered it. A digit-dense lockfile or hash dump hung the
379
+ // pre-commit hook. 64 is far past any real anchor (`p99 580ms→140ms` is 15).
380
+ const HAS_NUMERIC_ARROW = /\d\S{0,64}\s*(?:→|->|=>)\s*\d/;
365
381
  const HAS_BASELINE = /baseline/i;
366
382
  const baselineExempt = HAS_NUMERIC_ARROW.test(text) || HAS_BASELINE.test(text);
367
383
 
@@ -378,8 +394,8 @@ function lintCmd(rawArgs) {
378
394
 
379
395
  function auditCmd(rawArgs) {
380
396
  const args = validateAndExpandFlags(rawArgs, ['--json', '--include-ratio'], [], 'audit');
381
- const json = args.includes('--json'); // argv-lint:allow — validated upstream by validateAndExpandFlags
382
- const includeRatio = args.includes('--include-ratio'); // argv-lint:allow — validated upstream by validateAndExpandFlags
397
+ const json = args.includes('--json'); // argv-lint:allow — validated upstream by validateAndExpandFlags
398
+ const includeRatio = args.includes('--include-ratio'); // argv-lint:allow — validated upstream by validateAndExpandFlags
383
399
  const positional = args.filter(a => !a.startsWith('--'));
384
400
  const transcriptPath = positional[0];
385
401
 
@@ -397,7 +413,9 @@ function auditCmd(rawArgs) {
397
413
  try {
398
414
  const st = fs.statSync(transcriptPath);
399
415
  if (!st.isFile()) {
400
- process.stderr.write(`audit: '${transcriptPath}' is not a regular file (got ${st.isDirectory() ? 'directory' : 'special file'})\n`);
416
+ process.stderr.write(
417
+ `audit: '${transcriptPath}' is not a regular file (got ${st.isDirectory() ? 'directory' : 'special file'})\n`
418
+ );
401
419
  process.exit(2);
402
420
  }
403
421
  } catch (e) {
@@ -405,7 +423,17 @@ function auditCmd(rawArgs) {
405
423
  process.exit(2);
406
424
  }
407
425
 
408
- const jsonl = fs.readFileSync(transcriptPath, 'utf8');
426
+ // Mirrors the `lint --file` read path above (R11-11). The existsSync/statSync
427
+ // guards a few lines up both PASS on a mode-000 file, so an unreadable
428
+ // transcript reached this line and exited 1 with a V8 stack — colliding with
429
+ // the documented `1 = hits found`, which is what CI and pre-commit gate on.
430
+ let jsonl;
431
+ try {
432
+ jsonl = fs.readFileSync(transcriptPath, 'utf8');
433
+ } catch (e) {
434
+ process.stderr.write(`audit: failed to read ${transcriptPath}: ${e.message}\n`);
435
+ process.exit(2);
436
+ }
409
437
 
410
438
  // Silent-success guard: parseTranscript intentionally skips unparseable rows
411
439
  // (matches transcript-vocab-scan.sh). But if the WHOLE file fails to parse
@@ -419,12 +447,21 @@ function auditCmd(rawArgs) {
419
447
  let sawTypeField = false;
420
448
  for (const l of nonEmptyLines) {
421
449
  let row;
422
- try { row = JSON.parse(l); } catch { continue; }
450
+ try {
451
+ row = JSON.parse(l);
452
+ } catch {
453
+ continue;
454
+ }
423
455
  parsedAny = true;
424
- if (row && typeof row === 'object' && row.type !== undefined) { sawTypeField = true; break; }
456
+ if (row && typeof row === 'object' && row.type !== undefined) {
457
+ sawTypeField = true;
458
+ break;
459
+ }
425
460
  }
426
461
  if (!parsedAny) {
427
- process.stderr.write(`audit: no parseable JSON rows in ${transcriptPath} (expected JSONL transcript with one JSON object per line)\n`);
462
+ process.stderr.write(
463
+ `audit: no parseable JSON rows in ${transcriptPath} (expected JSONL transcript with one JSON object per line)\n`
464
+ );
428
465
  process.exit(2);
429
466
  }
430
467
  // Parseable JSON but NO row carries a `type` field → not a Claude Code
@@ -436,7 +473,9 @@ function auditCmd(rawArgs) {
436
473
  // literal-string-scan bugs. A legit transcript whose only rows are
437
474
  // non-assistant still has `type`, so it passes this gate and exits 0.
438
475
  if (!sawTypeField) {
439
- process.stderr.write(`audit: ${transcriptPath} parses as JSON but no row has a 'type' field — does not look like a Claude Code transcript (expected rows like {"type":"assistant",...}). Wrong file?\n`);
476
+ process.stderr.write(
477
+ `audit: ${transcriptPath} parses as JSON but no row has a 'type' field — does not look like a Claude Code transcript (expected rows like {"type":"assistant",...}). Wrong file?\n`
478
+ );
440
479
  process.exit(2);
441
480
  }
442
481
  }
@@ -452,8 +491,8 @@ function auditCmd(rawArgs) {
452
491
  if (skippedStringRows > 0) {
453
492
  process.stderr.write(
454
493
  `audit: warning: skipped ${skippedStringRows} assistant row(s) with string-shape ` +
455
- `message.content — not the Claude Code block-array shape, so their text was NOT scanned. ` +
456
- `Non-CC transcript export?\n`
494
+ `message.content — not the Claude Code block-array shape, so their text was NOT scanned. ` +
495
+ `Non-CC transcript export?\n`
457
496
  );
458
497
  }
459
498
  const patterns = readPatterns();
@@ -490,7 +529,8 @@ function main() {
490
529
  // meant `claudemd-cli lint --help` exited 2 with "unknown flag '--help'" — the
491
530
  // exact discoverability bug lib/argv.js#printHelpAndExit was written to fix,
492
531
  // which this CLI predates.
493
- if (argv.length === 0 || argv.includes('--help') || argv.includes('-h')) { // argv-lint:allow — help detection runs before subcommand routing; --help takes no value
532
+ // argv-lint:allow — help detection runs before subcommand routing; --help takes no value
533
+ if (argv.length === 0 || argv.includes('--help') || argv.includes('-h')) {
494
534
  process.stdout.write(USAGE + '\n');
495
535
  process.exit(argv.length === 0 ? 2 : 0);
496
536
  }
@@ -500,8 +540,10 @@ function main() {
500
540
  }
501
541
  const sub = argv[0];
502
542
  switch (sub) {
503
- case 'lint': return lintCmd(argv.slice(1));
504
- case 'audit': return auditCmd(argv.slice(1));
543
+ case 'lint':
544
+ return lintCmd(argv.slice(1));
545
+ case 'audit':
546
+ return auditCmd(argv.slice(1));
505
547
  default:
506
548
  process.stderr.write(`unknown subcommand: ${sub}\n${USAGE}\n`);
507
549
  process.exit(2);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudemd-cli",
3
- "version": "0.71.3",
3
+ "version": "0.72.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": {
@@ -26,10 +26,27 @@
26
26
  "test:scripts": "bash -c 'source tests/lib/env-hygiene.sh && claudemd_reset_test_env && node --test tests/scripts/*.test.js'",
27
27
  "test:hooks": "for t in tests/hooks/*.test.sh; do bash \"$t\" || exit 1; done",
28
28
  "lint:argv": "node scripts/lint-argv.js",
29
- "version-check": "node scripts/version-cascade-check.js"
29
+ "version-check": "node scripts/version-cascade-check.js",
30
+ "test:coverage": "c8 --all --src=bin --src=scripts --include='bin/**/*.js' --include='scripts/**/*.js' --reporter=text-summary --reporter=html --reports-dir=coverage npm run test:scripts",
31
+ "lint": "npm run lint:argv && npm run version-check && npm run lint:sh && npm run lint:js",
32
+ "lint:sh": "F=$(bash tests/lib/shell-files.sh) && shellcheck --severity=warning $F",
33
+ "lint:js": "eslint bin scripts tests eslint.config.js",
34
+ "format": "prettier --write bin scripts tests eslint.config.js",
35
+ "format:check": "prettier --check bin scripts tests eslint.config.js",
36
+ "metrics": "node scripts/baseline-metrics.js",
37
+ "check": "npm run lint && npm test"
30
38
  },
31
39
  "engines": {
32
40
  "node": ">=20"
33
41
  },
34
- "license": "MIT"
42
+ "license": "MIT",
43
+ "devDependencies": {
44
+ "@eslint/js": "10.0.1",
45
+ "acorn": "8.18.0",
46
+ "c8": "12.0.0",
47
+ "eslint": "10.9.1",
48
+ "globals": "17.12.0",
49
+ "jscpd": "5.1.1",
50
+ "prettier": "3.9.6"
51
+ }
35
52
  }
@@ -12,7 +12,10 @@
12
12
  // exit 1) so wrappers can tell parsing-shape errors from validation errors.
13
13
 
14
14
  export class ArgvError extends Error {
15
- constructor(message) { super(message); this.name = 'ArgvError'; }
15
+ constructor(message) {
16
+ super(message);
17
+ this.name = 'ArgvError';
18
+ }
16
19
  }
17
20
 
18
21
  // Discoverability helper: when `--help` or `-h` is the first non-empty arg
@@ -37,6 +40,39 @@ export function printHelpAndExit(argv, usage) {
37
40
  // despite help text promising a "positive integer", a silent contract
38
41
  // divergence (inverse of the `parseInt` truncation footgun). Mirrors the
39
42
  // `/^[1-9][0-9]*$/` guard already used for CLAUDEMD_BATCH_THRESHOLD in status.js.
43
+ // resolveDaysFlag / resolveDaysListFlag — the `--days` precedence, once.
44
+ //
45
+ // Five scripts carried the same expression with five different env-var names
46
+ // (CLAUDEMD_{AUDIT,RULES,SPARKLINE,SAMPLING,BYPASS}_DAYS), so the RULE — flag
47
+ // beats env, an EMPTY env falls back to the default, and the result must be a
48
+ // plain positive integer — lived nowhere and had to be re-derived by whoever
49
+ // added the sixth (2026-09-02 audit R11-13e). The per-script `--days` examples
50
+ // in the error text stay with each script; only the resolution moves here.
51
+ //
52
+ // Returns `{ raw, days }` (or `{ raw, windows }`), with the parsed value null on
53
+ // a bad shape, matching parsePositiveInt's contract: the CLI decides what to
54
+ // print and which exit code to use, a library does not call process.exit.
55
+ export function resolveDaysFlag(parsed, { env, dflt }) {
56
+ // `||` not `??` on the env read, deliberately: `CLAUDEMD_AUDIT_DAYS=` (set but
57
+ // empty, the shape an unset shell variable takes in a wrapper) means "no
58
+ // preference", not "the empty string is my answer".
59
+ const raw = parsed.values['--days'] ?? (process.env[env] || String(dflt));
60
+ return { raw, days: parsePositiveInt(raw) };
61
+ }
62
+
63
+ // The comma-separated variant (sparkline's three trend windows). `windows` is
64
+ // null when any element fails to parse or fewer than `min` survive — one null in
65
+ // the middle of a list is as useless as an unparseable scalar, and returning a
66
+ // partial list is how '1.5,2,3' silently became [1,2,3] with a wrong header.
67
+ export function resolveDaysListFlag(parsed, { env, dflt, min = 2 }) {
68
+ const raw = parsed.values['--days'] ?? (process.env[env] || String(dflt));
69
+ const windows = String(raw)
70
+ .split(',')
71
+ .map(x => parsePositiveInt(x));
72
+ const ok = windows.length >= min && !windows.some(w => w === null);
73
+ return { raw, windows: ok ? windows : null };
74
+ }
75
+
40
76
  export function parsePositiveInt(raw) {
41
77
  if (raw == null) return null;
42
78
  const s = String(raw).trim();
@@ -62,12 +98,18 @@ export function parseStrict(argv, { bools = [], values = [] } = {}) {
62
98
  const knownBool = new Set(bools);
63
99
  const knownValue = new Set(values);
64
100
  for (const a of argv) {
65
- if (knownBool.has(a)) { out.bools.add(a); continue; }
101
+ if (knownBool.has(a)) {
102
+ out.bools.add(a);
103
+ continue;
104
+ }
66
105
  if (a.startsWith('--') && a.includes('=')) {
67
106
  const eq = a.indexOf('=');
68
107
  const k = a.slice(0, eq);
69
108
  const v = a.slice(eq + 1);
70
- if (knownValue.has(k)) { out.values[k] = v; continue; }
109
+ if (knownValue.has(k)) {
110
+ out.values[k] = v;
111
+ continue;
112
+ }
71
113
  if (knownBool.has(k)) {
72
114
  throw new ArgvError(`Boolean flag '${k}' does not take a value (got '${a}').`);
73
115
  }
@@ -106,13 +148,18 @@ export function validateAndExpandFlags(args, knownBools, knownValues, sub) {
106
148
  const bools = new Set(knownBools);
107
149
  const values = new Set(knownValues);
108
150
  for (const a of args) {
109
- if (!a.startsWith('--')) { out.push(a); continue; }
151
+ if (!a.startsWith('--')) {
152
+ out.push(a);
153
+ continue;
154
+ }
110
155
  if (a.includes('=')) {
111
156
  const eq = a.indexOf('=');
112
157
  const k = a.slice(0, eq);
113
158
  const v = a.slice(eq + 1);
114
159
  if (bools.has(k)) {
115
- process.stderr.write(`${sub}: '${k}' is a boolean flag and does not take a value (got '${a}'). Drop the '=...' suffix.\n`);
160
+ process.stderr.write(
161
+ `${sub}: '${k}' is a boolean flag and does not take a value (got '${a}'). Drop the '=...' suffix.\n`
162
+ );
116
163
  process.exit(2);
117
164
  }
118
165
  if (values.has(k)) {
@@ -123,7 +170,10 @@ export function validateAndExpandFlags(args, knownBools, knownValues, sub) {
123
170
  process.stderr.write(`${sub}: unknown flag '${k}' (got '${a}').\n`);
124
171
  process.exit(2);
125
172
  }
126
- if (bools.has(a) || values.has(a)) { out.push(a); continue; }
173
+ if (bools.has(a) || values.has(a)) {
174
+ out.push(a);
175
+ continue;
176
+ }
127
177
  process.stderr.write(`${sub}: unknown flag '${a}'.\n`);
128
178
  process.exit(2);
129
179
  }
@@ -69,7 +69,7 @@ export function readPatterns(patternsFile = DEFAULT_PATTERNS_FILE) {
69
69
  const POSIX_TO_JS = [
70
70
  [/\[\[:space:\]\]/g, '\\s'],
71
71
  [/\[\[:digit:\]\]/g, '\\d'],
72
- [/\[\[:alnum:\]\]/g, 'A-Za-z0-9'], // typically already inside a [...]
72
+ [/\[\[:alnum:\]\]/g, 'A-Za-z0-9'], // typically already inside a [...]
73
73
  [/\[\[:alpha:\]\]/g, 'A-Za-z'],
74
74
  [/\[\[:upper:\]\]/g, 'A-Z'],
75
75
  [/\[\[:lower:\]\]/g, 'a-z'],
@@ -108,7 +108,7 @@ export function stripIdentifiers(text) {
108
108
  // guard: blanked text is a subset of before, so this can only EXPOSE
109
109
  // more text to the detector, never hide a claim.
110
110
  const lines = text.split('\n');
111
- const isFence = (l) => /^\s*```/.test(l);
111
+ const isFence = l => /^\s*```/.test(l);
112
112
  // "Is there a closing fence after i?" — precomputed once. The direct
113
113
  // `lines.slice(i + 1).some(isFence)` spelling allocates the entire tail array
114
114
  // on every fence line even though `.some` short-circuits, which is O(lines²):
@@ -117,21 +117,31 @@ export function stripIdentifiers(text) {
117
117
  // after i iff any fence line is after i — in O(1) after one O(lines) pass.
118
118
  let lastFence = -1;
119
119
  for (let i = lines.length - 1; i >= 0; i--) {
120
- if (isFence(lines[i])) { lastFence = i; break; }
120
+ if (isFence(lines[i])) {
121
+ lastFence = i;
122
+ break;
123
+ }
121
124
  }
122
125
  const kept = [];
123
126
  let inFence = false;
124
127
  for (let i = 0; i < lines.length; i++) {
125
128
  const line = lines[i];
126
129
  if (isFence(line)) {
127
- if (inFence) { inFence = false; continue; }
128
- if (lastFence > i) { inFence = true; continue; }
130
+ if (inFence) {
131
+ inFence = false;
132
+ continue;
133
+ }
134
+ if (lastFence > i) {
135
+ inFence = true;
136
+ continue;
137
+ }
129
138
  kept.push(line);
130
139
  continue;
131
140
  }
132
141
  if (!inFence) kept.push(line);
133
142
  }
134
- const stripped = kept.join('\n')
143
+ const stripped = kept
144
+ .join('\n')
135
145
  // 2. Inline backtick spans.
136
146
  .replace(/`[^`]*`/g, ' ')
137
147
  // 3. Slashed-path runs (branch names, file paths, URLs) — Path 2's rule.
@@ -165,31 +175,30 @@ export function stripIdentifiers(text) {
165
175
  //
166
176
  // The bash engines need no equivalent: POSIX sed does not backtrack and
167
177
  // the hook caps its input at `tail -c 4096`; the Node path caps nothing.
168
- .replace(/(?<![A-Za-z0-9._@~-])[A-Za-z0-9._@~-]*\/[A-Za-z0-9._/@~-]*/g, ' ')
169
- // 4. Bare dotted-file tokens (foo.js, comprehensive-parser.ts). JS-only
170
- // when written; the bash sanitizer carries the same clause since
171
- // 2026-08-16 and sanitize-stage-parity pins them together.
172
- // The extension must start with a LOWERCASE letter, which
173
- // (a) excludes decimals / versions ("3.5x", "v6.14") whose ".5x"/".14"
174
- // could otherwise swallow a baseline-less ratio claim → false negative,
175
- // and (b) excludes sentence-boundary typos ("comprehensive.Next", capital
176
- // after the dot) so a real claim isn't stripped. Only true `name.ext`
177
- // identifiers with a lowercase extension are removed.
178
- //
179
- // Clause 4 canNOT use clause 3's lookbehind: its trailing class
180
- // `[a-z0-9]` is a strict SUBSET of the leading run class, so a match can
181
- // end in the MIDDLE of a run (`_a9Zaz.a|Z9Z_.a` — the ext stops at the
182
- // uppercase Z) and the next legitimate match then starts at a position
183
- // whose predecessor IS a run char. A lookbehind drops that match and
184
- // leaves the identifier unstripped — more text exposed to the detector,
185
- // i.e. the FP deny-loop returning. Clause 3 is immune because its
186
- // trailing class is a SUPERSET of its leading one, so a match always
187
- // ends outside a leading-class run; that equivalence is measured, not
188
- // assumed, in sanitize-anchor-equivalence.test.js.
189
- //
190
- // So clause 4 runs as an explicit single-pass scan instead — same
191
- // semantics, O(n) instead of O(run²).
192
- ;
178
+ .replace(/(?<![A-Za-z0-9._@~-])[A-Za-z0-9._@~-]*\/[A-Za-z0-9._/@~-]*/g, ' ');
179
+ // 4. Bare dotted-file tokens (foo.js, comprehensive-parser.ts). JS-only
180
+ // when written; the bash sanitizer carries the same clause since
181
+ // 2026-08-16 and sanitize-stage-parity pins them together.
182
+ // The extension must start with a LOWERCASE letter, which
183
+ // (a) excludes decimals / versions ("3.5x", "v6.14") whose ".5x"/".14"
184
+ // could otherwise swallow a baseline-less ratio claim → false negative,
185
+ // and (b) excludes sentence-boundary typos ("comprehensive.Next", capital
186
+ // after the dot) so a real claim isn't stripped. Only true `name.ext`
187
+ // identifiers with a lowercase extension are removed.
188
+ //
189
+ // Clause 4 canNOT use clause 3's lookbehind: its trailing class
190
+ // `[a-z0-9]` is a strict SUBSET of the leading run class, so a match can
191
+ // end in the MIDDLE of a run (`_a9Zaz.a|Z9Z_.a` — the ext stops at the
192
+ // uppercase Z) and the next legitimate match then starts at a position
193
+ // whose predecessor IS a run char. A lookbehind drops that match and
194
+ // leaves the identifier unstripped — more text exposed to the detector,
195
+ // i.e. the FP deny-loop returning. Clause 3 is immune because its
196
+ // trailing class is a SUPERSET of its leading one, so a match always
197
+ // ends outside a leading-class run; that equivalence is measured, not
198
+ // assumed, in sanitize-anchor-equivalence.test.js.
199
+ //
200
+ // So clause 4 runs as an explicit single-pass scan instead — same
201
+ // semantics, O(n) instead of O(run²).
193
202
  return stripDottedFileTokens(stripped);
194
203
  }
195
204
 
@@ -201,10 +210,9 @@ export function stripIdentifiers(text) {
201
210
  // exactly, including the mid-run restart above: after a match the scan resumes
202
211
  // at the match end, which becomes the next candidate start even though its
203
212
  // predecessor is a run char.
204
- const isRunChar = (c) =>
205
- (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') ||
206
- c === '_' || c === '-';
207
- const isExtChar = (c) => (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9');
213
+ const isRunChar = c =>
214
+ (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c === '_' || c === '-';
215
+ const isExtChar = c => (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9');
208
216
 
209
217
  export function stripDottedFileTokens(text) {
210
218
  if (!text) return text;
@@ -213,7 +221,10 @@ export function stripDottedFileTokens(text) {
213
221
  let copied = 0;
214
222
  let i = 0;
215
223
  while (i < n) {
216
- if (!isRunChar(text[i])) { i++; continue; }
224
+ if (!isRunChar(text[i])) {
225
+ i++;
226
+ continue;
227
+ }
217
228
  const start = i;
218
229
  let e = i;
219
230
  while (e < n && isRunChar(text[e])) e++;
@@ -283,7 +294,7 @@ export function looksLikeGitMessageFile(filePath) {
283
294
  // `-m "$(cat notes.md)"` whenever the body carried a markdown heading.
284
295
  export function stripGitCommitComments(text, commentChar = '#', { templateLines } = {}) {
285
296
  if (!text) return text;
286
- const c = (typeof commentChar === 'string' && commentChar.length === 1) ? commentChar : '#';
297
+ const c = typeof commentChar === 'string' && commentChar.length === 1 ? commentChar : '#';
287
298
  const esc = c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
288
299
  const cutLine = new RegExp(`^${esc} -{20,} >8 -{20,}\\s*$`);
289
300
 
@@ -299,9 +310,7 @@ export function stripGitCommitComments(text, commentChar = '#', { templateLines
299
310
  // it discards them under the editor path's cleanup=strip. Author-typed
300
311
  // comment lines are absent from the template and stay in scope (P1-3).
301
312
  const fromTemplate = templateComments(templateLines, c);
302
- const body = fromTemplate.size
303
- ? lines.filter(l => !(l.startsWith(c) && fromTemplate.has(l)))
304
- : lines;
313
+ const body = fromTemplate.size ? lines.filter(l => !(l.startsWith(c) && fromTemplate.has(l))) : lines;
305
314
 
306
315
  // 3. Strip the remaining comment lines only when git wrote a status block or
307
316
  // a cut line here.
@@ -386,7 +395,11 @@ export function parseTranscript(jsonlText) {
386
395
  for (let i = 0; i < lines.length; i++) {
387
396
  if (!lines[i].trim()) continue;
388
397
  let row;
389
- try { row = JSON.parse(lines[i]); } catch { continue; }
398
+ try {
399
+ row = JSON.parse(lines[i]);
400
+ } catch {
401
+ continue;
402
+ }
390
403
  if (row.type !== 'assistant') continue;
391
404
  const content = row.message?.content || [];
392
405
  const texts = [];
@@ -414,7 +427,11 @@ export function countStringContentAssistantRows(jsonlText) {
414
427
  for (const line of jsonlText.split('\n')) {
415
428
  if (!line.trim()) continue;
416
429
  let row;
417
- try { row = JSON.parse(line); } catch { continue; }
430
+ try {
431
+ row = JSON.parse(line);
432
+ } catch {
433
+ continue;
434
+ }
418
435
  if (row?.type !== 'assistant') continue;
419
436
  const content = row.message?.content;
420
437
  if (typeof content === 'string' && content.trim().length > 0) count++;