claudemd-cli 0.71.4 → 0.73.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.
@@ -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
  }
@@ -384,8 +394,8 @@ function lintCmd(rawArgs) {
384
394
 
385
395
  function auditCmd(rawArgs) {
386
396
  const args = validateAndExpandFlags(rawArgs, ['--json', '--include-ratio'], [], 'audit');
387
- const json = args.includes('--json'); // argv-lint:allow — validated upstream by validateAndExpandFlags
388
- 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
389
399
  const positional = args.filter(a => !a.startsWith('--'));
390
400
  const transcriptPath = positional[0];
391
401
 
@@ -403,7 +413,9 @@ function auditCmd(rawArgs) {
403
413
  try {
404
414
  const st = fs.statSync(transcriptPath);
405
415
  if (!st.isFile()) {
406
- 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
+ );
407
419
  process.exit(2);
408
420
  }
409
421
  } catch (e) {
@@ -435,12 +447,21 @@ function auditCmd(rawArgs) {
435
447
  let sawTypeField = false;
436
448
  for (const l of nonEmptyLines) {
437
449
  let row;
438
- try { row = JSON.parse(l); } catch { continue; }
450
+ try {
451
+ row = JSON.parse(l);
452
+ } catch {
453
+ continue;
454
+ }
439
455
  parsedAny = true;
440
- 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
+ }
441
460
  }
442
461
  if (!parsedAny) {
443
- 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
+ );
444
465
  process.exit(2);
445
466
  }
446
467
  // Parseable JSON but NO row carries a `type` field → not a Claude Code
@@ -452,12 +473,27 @@ function auditCmd(rawArgs) {
452
473
  // literal-string-scan bugs. A legit transcript whose only rows are
453
474
  // non-assistant still has `type`, so it passes this gate and exits 0.
454
475
  if (!sawTypeField) {
455
- 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
+ );
456
479
  process.exit(2);
457
480
  }
458
481
  }
459
482
 
460
- const turns = parseTranscript(jsonl);
483
+ // `integrity` counts the rows parseTranscript drops. The pre-flight above
484
+ // only catches an ENTIRELY unparseable file; a transcript that is 30%
485
+ // corrupt passed it and then printed "no §10-V hits across N turn(s)" with N
486
+ // silently short (audit R11-24). Same warning shape as the string-row skip
487
+ // below — the verdict still comes from the rows that parsed, but the reader
488
+ // is told how many did not.
489
+ const integrity = { totalLines: 0, badLines: 0 };
490
+ const turns = parseTranscript(jsonl, integrity);
491
+ if (integrity.badLines > 0) {
492
+ process.stderr.write(
493
+ `audit: warning: skipped ${integrity.badLines} of ${integrity.totalLines} non-empty line(s) ` +
494
+ `that did not parse as JSON — the turn count below covers only the rows that parsed.\n`
495
+ );
496
+ }
461
497
  // QA ISSUE-002 (option c): string-shape assistant rows are outside the
462
498
  // block-array input domain and never reach the scanner. Keep the verdict
463
499
  // based on scanned turns, but say so on stderr — a silent skip here is the
@@ -468,8 +504,8 @@ function auditCmd(rawArgs) {
468
504
  if (skippedStringRows > 0) {
469
505
  process.stderr.write(
470
506
  `audit: warning: skipped ${skippedStringRows} assistant row(s) with string-shape ` +
471
- `message.content — not the Claude Code block-array shape, so their text was NOT scanned. ` +
472
- `Non-CC transcript export?\n`
507
+ `message.content — not the Claude Code block-array shape, so their text was NOT scanned. ` +
508
+ `Non-CC transcript export?\n`
473
509
  );
474
510
  }
475
511
  const patterns = readPatterns();
@@ -491,7 +527,9 @@ function auditCmd(rawArgs) {
491
527
  const flaggedCount = annotated.reduce((n, t) => n + (t.hits.length > 0 ? 1 : 0), 0);
492
528
 
493
529
  if (json) {
494
- process.stdout.write(formatJSON({ scope: 'audit', transcript: transcriptPath, turns: annotated }) + '\n');
530
+ process.stdout.write(
531
+ formatJSON({ scope: 'audit', transcript: transcriptPath, turns: annotated, integrity }) + '\n'
532
+ );
495
533
  } else {
496
534
  const out = formatHumanReadable({ scope: 'audit', turns: annotated });
497
535
  if (flaggedCount === 0) process.stdout.write(out + '\n');
@@ -506,7 +544,8 @@ function main() {
506
544
  // meant `claudemd-cli lint --help` exited 2 with "unknown flag '--help'" — the
507
545
  // exact discoverability bug lib/argv.js#printHelpAndExit was written to fix,
508
546
  // which this CLI predates.
509
- if (argv.length === 0 || argv.includes('--help') || argv.includes('-h')) { // argv-lint:allow — help detection runs before subcommand routing; --help takes no value
547
+ // argv-lint:allow — help detection runs before subcommand routing; --help takes no value
548
+ if (argv.length === 0 || argv.includes('--help') || argv.includes('-h')) {
510
549
  process.stdout.write(USAGE + '\n');
511
550
  process.exit(argv.length === 0 ? 2 : 0);
512
551
  }
@@ -516,8 +555,10 @@ function main() {
516
555
  }
517
556
  const sub = argv[0];
518
557
  switch (sub) {
519
- case 'lint': return lintCmd(argv.slice(1));
520
- case 'audit': return auditCmd(argv.slice(1));
558
+ case 'lint':
559
+ return lintCmd(argv.slice(1));
560
+ case 'audit':
561
+ return auditCmd(argv.slice(1));
521
562
  default:
522
563
  process.stderr.write(`unknown subcommand: ${sub}\n${USAGE}\n`);
523
564
  process.exit(2);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudemd-cli",
3
- "version": "0.71.4",
3
+ "version": "0.73.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": {
@@ -24,7 +24,7 @@
24
24
  "scripts": {
25
25
  "test": "bash tests/run-all.sh",
26
26
  "test:scripts": "bash -c 'source tests/lib/env-hygiene.sh && claudemd_reset_test_env && node --test tests/scripts/*.test.js'",
27
- "test:hooks": "for t in tests/hooks/*.test.sh; do bash \"$t\" || exit 1; done",
27
+ "test:hooks": "bash -c 'source tests/lib/env-hygiene.sh && claudemd_reset_test_env && source tests/lib/run-suite.sh && for t in tests/hooks/*.test.sh; do run_suite \"$t\" 300 || exit 1; done'",
28
28
  "lint:argv": "node scripts/lint-argv.js",
29
29
  "version-check": "node scripts/version-cascade-check.js",
30
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",
@@ -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.
@@ -374,19 +383,33 @@ export function scan(text, { excludeRatio = false, patterns, sanitize = false }
374
383
  return hits;
375
384
  }
376
385
 
377
- // parseTranscript(jsonlText) → [{turnIndex, line, text}, ...]
386
+ // parseTranscript(jsonlText, integrity) → [{turnIndex, line, text}, ...]
378
387
  // Iterates jsonl, returns one entry per assistant text-content turn. Each
379
388
  // entry concatenates all .message.content[*].text blocks for that turn.
380
- // Corrupt rows (unparseable JSON, missing fields) silently skipped — matches
389
+ // Corrupt rows (unparseable JSON, missing fields) are skipped — matches
381
390
  // transcript-vocab-scan.sh's `try fromjson catch empty` design.
382
- export function parseTranscript(jsonlText) {
391
+ //
392
+ // `integrity` (out param, optional): `{ totalLines, badLines }` written back
393
+ // so the skip is countable rather than invisible. Skipping is still the right
394
+ // behavior — a half-corrupt transcript is worth scanning — but until 2026-09-03
395
+ // (audit R11-24) nothing downstream could tell "0 hits across 40 turns" from
396
+ // "0 hits across the 40 turns that happened to parse". readLogRows in
397
+ // rule-hits-parse.js has carried the same counter since a 33% corruption went
398
+ // unnoticed there; this is the same counter on the transcript side.
399
+ export function parseTranscript(jsonlText, integrity = null) {
383
400
  const lines = jsonlText.split('\n');
384
401
  const turns = [];
385
402
  let turnIndex = 0;
386
403
  for (let i = 0; i < lines.length; i++) {
387
404
  if (!lines[i].trim()) continue;
405
+ if (integrity) integrity.totalLines = (integrity.totalLines || 0) + 1;
388
406
  let row;
389
- try { row = JSON.parse(lines[i]); } catch { continue; }
407
+ try {
408
+ row = JSON.parse(lines[i]);
409
+ } catch {
410
+ if (integrity) integrity.badLines = (integrity.badLines || 0) + 1;
411
+ continue;
412
+ }
390
413
  if (row.type !== 'assistant') continue;
391
414
  const content = row.message?.content || [];
392
415
  const texts = [];
@@ -414,7 +437,11 @@ export function countStringContentAssistantRows(jsonlText) {
414
437
  for (const line of jsonlText.split('\n')) {
415
438
  if (!line.trim()) continue;
416
439
  let row;
417
- try { row = JSON.parse(line); } catch { continue; }
440
+ try {
441
+ row = JSON.parse(line);
442
+ } catch {
443
+ continue;
444
+ }
418
445
  if (row?.type !== 'assistant') continue;
419
446
  const content = row.message?.content;
420
447
  if (typeof content === 'string' && content.trim().length > 0) count++;