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 +1 -1
- package/bin/claudemd-lint.js +68 -26
- package/package.json +20 -3
- package/scripts/lib/argv.js +56 -6
- package/scripts/lib/lint.js +59 -42
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/ #
|
|
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/
|
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
|
}
|
|
@@ -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
|
-
|
|
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');
|
|
382
|
-
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
|
|
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(
|
|
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
|
-
|
|
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 {
|
|
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) {
|
|
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(
|
|
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(
|
|
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
|
-
|
|
456
|
-
|
|
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
|
-
|
|
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':
|
|
504
|
-
|
|
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.
|
|
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
|
}
|
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.
|
|
@@ -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 {
|
|
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 {
|
|
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++;
|