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.
- package/bin/claudemd-lint.js +67 -26
- package/package.json +2 -2
- package/scripts/lib/argv.js +56 -6
- package/scripts/lib/lint.js +72 -45
package/bin/claudemd-lint.js
CHANGED
|
@@ -111,9 +111,13 @@ function readCommitTemplate(sourcePath) {
|
|
|
111
111
|
|
|
112
112
|
for (const cwd of starts) {
|
|
113
113
|
try {
|
|
114
|
-
const git = (...a) =>
|
|
115
|
-
|
|
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');
|
|
158
|
-
const stdin = args.includes('--stdin');
|
|
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');
|
|
165
|
-
const denyCommitMsg = args.includes('--no-commit-msg');
|
|
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');
|
|
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(
|
|
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(
|
|
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(
|
|
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');
|
|
388
|
-
const includeRatio = args.includes('--include-ratio');
|
|
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(
|
|
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 {
|
|
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) {
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
472
|
-
|
|
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(
|
|
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
|
-
|
|
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':
|
|
520
|
-
|
|
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.
|
|
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
|
|
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",
|
package/scripts/lib/argv.js
CHANGED
|
@@ -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) {
|
|
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)) {
|
|
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)) {
|
|
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('--')) {
|
|
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(
|
|
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)) {
|
|
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
|
}
|
package/scripts/lib/lint.js
CHANGED
|
@@ -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'],
|
|
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 =
|
|
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])) {
|
|
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) {
|
|
128
|
-
|
|
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
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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 =
|
|
205
|
-
(c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') ||
|
|
206
|
-
|
|
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])) {
|
|
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 =
|
|
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)
|
|
389
|
+
// Corrupt rows (unparseable JSON, missing fields) are skipped — matches
|
|
381
390
|
// transcript-vocab-scan.sh's `try fromjson catch empty` design.
|
|
382
|
-
|
|
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 {
|
|
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 {
|
|
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++;
|