claudemd-cli 0.68.2 → 0.69.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 +23 -4
- package/bin/claudemd-lint.js +64 -32
- package/package.json +1 -1
- package/scripts/lib/argv.js +126 -0
- package/scripts/lib/lint.js +65 -26
- package/CHANGELOG.md +0 -4965
package/README.md
CHANGED
|
@@ -111,7 +111,7 @@ Per-hook timeout (3-5s in `hooks.json`); timeout = treated as exit 0 (pass) per
|
|
|
111
111
|
| `/claudemd-refresh` | v0.48.0 — one-shot plugin refresh (marketplace update → uninstall → install via the `claude` CLI). Restart Claude Code afterwards; spec + manifest sync is automatic. Fired by the SessionStart upgrade banner. |
|
|
112
112
|
| `/claudemd-audit [N]` | Aggregate rule-hits over last N days (default 30). Top banned-vocab patterns, per-hook deny counts. Slash form takes a bare number (`/claudemd-audit 90`); direct script invocation takes `--days=N` (= form only). |
|
|
113
113
|
| `/claudemd-toggle <hook-name>` | Enable/disable a specific hook by toggling `DISABLE_*_HOOK` in `settings.json` env. |
|
|
114
|
-
| `/claudemd-doctor [--prune-backups=N]` | Health checks; optionally prune `~/.claude/backup-*`
|
|
114
|
+
| `/claudemd-doctor [--prune-backups=N]` | Health checks; optionally prune each backup namespace (`~/.claude/backup-*`, `spec-backup-*`, `handhook-backup-*`) to its N newest. v0.7.1+ also flags rule sections whose bypass:deny ratio > 50% (R-N6 §0.1 demotion candidates). |
|
|
115
115
|
| `/claudemd-rules [N]` | v0.8.0+ — audit `spec/hard-rules.json` manifest over last N days (default 30 — lowered from 90d in v0.13.1 after the 90d gate was structurally unreachable under typical log retention). Surfaces `demoteCandidates` (hook-enforced rules with 0 hits) and `staleReviews` (rules whose `last_demote_review` is null/old). |
|
|
116
116
|
| `/claudemd-sparkline [--days=A,B,C]` | v0.8.4+ R-N9 — per-`spec_section` cumulative counts of signal events across 3 windows (default 30/60/90d). Trend arrow compares per-period rate; `(newly active)` / `(silenced)` annotations flag activation/deactivation transitions. Markdown block suitable for CHANGELOG header pre-release. |
|
|
117
117
|
| `/claudemd-clean-residue [--apply]` | Dry-run-by-default cleanup of stale `claudemd-sync-*` sentinels and historical `claudemd-(mockgh\|work).*` test sandboxes. |
|
|
@@ -215,6 +215,19 @@ export DISABLE_BOOTSTRAP_FAIL_BANNER=1 # v0.50.0+ — only the SessionStart
|
|
|
215
215
|
# sentinel + bootstrap.log trail keep being
|
|
216
216
|
# written so the state stays diagnosable.
|
|
217
217
|
|
|
218
|
+
export DISABLE_SPEC_DRIFT_BANNER=1 # only the SessionStart banner reporting
|
|
219
|
+
# installed-spec vs plugin-spec drift; the
|
|
220
|
+
# version check itself still runs, so
|
|
221
|
+
# /claudemd-doctor keeps reporting the drift.
|
|
222
|
+
|
|
223
|
+
export DISABLE_RULE_HITS_LOG=1 # stop every hook from appending to
|
|
224
|
+
# ~/.claude/logs/claudemd.jsonl. Enforcement
|
|
225
|
+
# is unaffected (a deny still denies); what
|
|
226
|
+
# stops is the telemetry /claudemd-audit,
|
|
227
|
+
# /claudemd-rules and the §13.1 demote loop
|
|
228
|
+
# read. Set it while hand-probing a hook so
|
|
229
|
+
# the probe does not land in the real corpus.
|
|
230
|
+
|
|
218
231
|
export DISABLE_BATCH_CADENCE_ADVISORY=1 # v0.19.2+ — only the §13.2 batch-review
|
|
219
232
|
# cadence advisory inside session-end-check;
|
|
220
233
|
# mid-SPINE warn-on-unvalidated-mutation
|
|
@@ -353,12 +366,12 @@ claudemd/
|
|
|
353
366
|
├── .claude-plugin/
|
|
354
367
|
│ ├── plugin.json # minimal manifest (name, version, author, license, keywords)
|
|
355
368
|
│ └── marketplace.json # marketplace catalog entry
|
|
356
|
-
├── hooks/ # 15 shell hooks + hooks/lib/ (hook-common, rule-hits, platform)
|
|
369
|
+
├── hooks/ # 15 shell hooks + hooks/lib/ (hook-common, rule-hits, platform, memory-tags)
|
|
357
370
|
│ └── hooks.json # authoritative hook registration (v0.1.5+); CC expands ${CLAUDE_PLUGIN_ROOT} here
|
|
358
371
|
├── commands/ # 16 slash-command markdown files
|
|
359
372
|
├── bin/ # standalone CLI entrypoint (claudemd-lint.js → `npx claudemd-cli` on npmjs.org)
|
|
360
373
|
├── scripts/ # 18 Node.js scripts + scripts/lib/ (single-source registry, lint, etc.)
|
|
361
|
-
├── spec/ # shipped v6.25.
|
|
374
|
+
├── spec/ # shipped v6.25.2 CLAUDE*.md trio + OPERATOR.md + hard-rules.json manifest
|
|
362
375
|
├── tests/ # hook shell tests + Node.js tests + integration + fixtures
|
|
363
376
|
├── docs/ # ADDING-NEW-HOOK.md + RULE-HITS-SCHEMA.md + superpowers/
|
|
364
377
|
└── .github/workflows/ # ci.yml (ubuntu+macOS × node 20/22/24) + npm-publish.yml (tag-triggered)
|
|
@@ -368,12 +381,18 @@ claudemd/
|
|
|
368
381
|
|
|
369
382
|
## Extending
|
|
370
383
|
|
|
371
|
-
- **Add a new hook** — see [`docs/ADDING-NEW-HOOK.md`](docs/ADDING-NEW-HOOK.md) for the
|
|
384
|
+
- **Add a new hook** — see [`docs/ADDING-NEW-HOOK.md`](docs/ADDING-NEW-HOOK.md) for the full checklist (hook script + test + plugin registration + telemetry gates + docs + the content gates a new hook trips + version bump).
|
|
372
385
|
- **Rule-hits log schema** — [`docs/RULE-HITS-SCHEMA.md`](docs/RULE-HITS-SCHEMA.md) for the JSONL row format used by `/claudemd-audit`.
|
|
373
386
|
- **Design rationale + decisions log** — [`docs/superpowers/specs/2026-04-21-claudemd-plugin-design.md`](docs/superpowers/specs/2026-04-21-claudemd-plugin-design.md).
|
|
374
387
|
|
|
375
388
|
---
|
|
376
389
|
|
|
390
|
+
## Changelog
|
|
391
|
+
|
|
392
|
+
Every release is recorded in [`CHANGELOG.md`](https://github.com/sdsrss/claudemd/blob/main/CHANGELOG.md) on GitHub. It is not shipped in the npm tarball — at 727 KB it was 89% of a package whose runtime is 95 KB unpacked (35 KB compressed), and `npx claudemd-cli` re-downloads that tarball on every cold run.
|
|
393
|
+
|
|
394
|
+
---
|
|
395
|
+
|
|
377
396
|
## License
|
|
378
397
|
|
|
379
398
|
MIT — see [LICENSE](LICENSE).
|
package/bin/claudemd-lint.js
CHANGED
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
// node bin/claudemd-lint.js audit transcript.jsonl
|
|
16
16
|
|
|
17
17
|
import fs from 'node:fs';
|
|
18
|
+
import os from 'node:os';
|
|
18
19
|
import path from 'node:path';
|
|
20
|
+
import { spawnSync } from 'node:child_process';
|
|
19
21
|
import { fileURLToPath } from 'node:url';
|
|
20
22
|
import {
|
|
21
23
|
scan,
|
|
@@ -27,6 +29,11 @@ import {
|
|
|
27
29
|
stripGitCommitComments,
|
|
28
30
|
looksLikeGitMessageFile,
|
|
29
31
|
} from '../scripts/lib/lint.js';
|
|
32
|
+
// Shared argv authority. It used to be a private function here, which is how
|
|
33
|
+
// scripts/lint-argv.js came to authenticate a CLI's argv contract by FUNCTION
|
|
34
|
+
// NAME — any file declaring a local `validateAndExpandFlags` satisfied the gate
|
|
35
|
+
// (audit-2026-08-22 条目 14). The gate now requires this import.
|
|
36
|
+
import { validateAndExpandFlags } from '../scripts/lib/argv.js';
|
|
30
37
|
|
|
31
38
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
32
39
|
const REPO_ROOT = path.resolve(HERE, '..');
|
|
@@ -81,40 +88,63 @@ function readPackageVersion() {
|
|
|
81
88
|
}
|
|
82
89
|
}
|
|
83
90
|
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
91
|
+
// Resolve the repo's `commit.template` and return its lines, so cleanup can
|
|
92
|
+
// drop template text by exact match instead of by shape.
|
|
93
|
+
//
|
|
94
|
+
// Why this exists (0.68.3 pre-tag review, HIGH-1): `commit.template` +
|
|
95
|
+
// `commit.status=false` hands a commit-msg hook a buffer that is entirely
|
|
96
|
+
// git-authored comment lines — no `#\t` status prefix, no cut line — and git
|
|
97
|
+
// discards all of them. Both shape signals miss it, so lint scanned a checklist
|
|
98
|
+
// the author never committed and denied a clean commit. A §10-V checklist in a
|
|
99
|
+
// commit template is exactly what this project's own users write.
|
|
100
|
+
//
|
|
101
|
+
// Best-effort by construction: no git, no repo, unset config, unreadable file
|
|
102
|
+
// → null, and the caller falls back to the shape signals unchanged.
|
|
103
|
+
function readCommitTemplate(sourcePath) {
|
|
104
|
+
// A commit-msg hook runs with cwd at the work tree root, so that is the first
|
|
105
|
+
// place to ask. The message file's own directory is the fallback — and it is
|
|
106
|
+
// usually `.git/`, where `rev-parse --show-toplevel` refuses to answer
|
|
107
|
+
// ("this operation must be run in a work tree"), so the work tree is derived
|
|
108
|
+
// from `--absolute-git-dir` instead of assumed.
|
|
109
|
+
const starts = [process.cwd()];
|
|
110
|
+
if (sourcePath) starts.push(path.dirname(path.resolve(sourcePath)));
|
|
111
|
+
|
|
112
|
+
for (const cwd of starts) {
|
|
113
|
+
try {
|
|
114
|
+
const git = (...a) => spawnSync('git', a, {
|
|
115
|
+
cwd, encoding: 'utf8', timeout: 5000, windowsHide: true,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const cfg = git('config', '--get', 'commit.template');
|
|
119
|
+
if (cfg.status !== 0 || !cfg.stdout || !cfg.stdout.trim()) continue;
|
|
120
|
+
let tmpl = cfg.stdout.trim();
|
|
121
|
+
|
|
122
|
+
// git expands a leading `~/`; it does not expand shell variables.
|
|
123
|
+
if (tmpl === '~' || tmpl.startsWith('~/')) {
|
|
124
|
+
const home = os.homedir();
|
|
125
|
+
if (home) tmpl = path.join(home, tmpl.slice(1));
|
|
109
126
|
}
|
|
110
|
-
|
|
111
|
-
|
|
127
|
+
if (!path.isAbsolute(tmpl)) tmpl = path.resolve(workTreeOf(git, cwd), tmpl);
|
|
128
|
+
|
|
129
|
+
return fs.readFileSync(tmpl, 'utf8').split('\n');
|
|
130
|
+
} catch {
|
|
131
|
+
// fall through to the next candidate
|
|
112
132
|
}
|
|
113
|
-
if (bools.has(a) || values.has(a)) { out.push(a); continue; }
|
|
114
|
-
process.stderr.write(`${sub}: unknown flag '${a}'.\n`);
|
|
115
|
-
process.exit(2);
|
|
116
133
|
}
|
|
117
|
-
return
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// The work tree a relative `commit.template` resolves against.
|
|
138
|
+
function workTreeOf(git, cwd) {
|
|
139
|
+
const top = git('rev-parse', '--show-toplevel');
|
|
140
|
+
if (top.status === 0 && top.stdout.trim()) return top.stdout.trim();
|
|
141
|
+
// Called from inside the git dir: the work tree is its parent. `.git` for a
|
|
142
|
+
// normal repo, `.git/worktrees/<name>` for a linked one — hence common-dir.
|
|
143
|
+
const common = git('rev-parse', '--git-common-dir');
|
|
144
|
+
if (common.status === 0 && common.stdout.trim()) {
|
|
145
|
+
return path.dirname(path.resolve(cwd, common.stdout.trim()));
|
|
146
|
+
}
|
|
147
|
+
return cwd;
|
|
118
148
|
}
|
|
119
149
|
|
|
120
150
|
function lintCmd(rawArgs) {
|
|
@@ -305,7 +335,9 @@ function lintCmd(rawArgs) {
|
|
|
305
335
|
// sitting in a `#` line git will discard is not in the commit message either.
|
|
306
336
|
const commitMsgCleanup = !denyCommitMsg && (forceCommitMsg || looksLikeGitMessageFile(sourcePath));
|
|
307
337
|
if (commitMsgCleanup) {
|
|
308
|
-
text = stripGitCommitComments(text, commentChar
|
|
338
|
+
text = stripGitCommitComments(text, commentChar, {
|
|
339
|
+
templateLines: readCommitTemplate(sourcePath),
|
|
340
|
+
});
|
|
309
341
|
}
|
|
310
342
|
|
|
311
343
|
// Per-commit escape hatch — mirrors hooks/banned-vocab-check.sh:36. Without
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claudemd-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.69.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": {
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Strict argv parser for slash-command CLI scripts (clean-residue / audit /
|
|
2
|
+
// sparkline). Three contracts the previous inline parsers silently violated:
|
|
3
|
+
// 1. `--key=value` is the ONLY accepted shape for value flags. The space form
|
|
4
|
+
// `--key value` falls back to default + ignores the value, exiting 0 — same
|
|
5
|
+
// footgun family as the v0.9.14 `claudemd-cli lint <path>` silent-success.
|
|
6
|
+
// 2. Unknown flags reject loudly. Pre-fix, every script's `args.find()` lookup
|
|
7
|
+
// silently dropped anything it didn't recognize, so a typo produced
|
|
8
|
+
// indistinguishable-from-success output.
|
|
9
|
+
// 3. `--key=value` for boolean flags rejects (`--apply=yes` shouldn't parse
|
|
10
|
+
// as `--apply` true).
|
|
11
|
+
// Caller catches `ArgvError` and exits 2 (distinct from numeric-validation
|
|
12
|
+
// exit 1) so wrappers can tell parsing-shape errors from validation errors.
|
|
13
|
+
|
|
14
|
+
export class ArgvError extends Error {
|
|
15
|
+
constructor(message) { super(message); this.name = 'ArgvError'; }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Discoverability helper: when `--help` or `-h` is the first non-empty arg
|
|
19
|
+
// (or anywhere in argv for scripts with no flags), print usage to stdout and
|
|
20
|
+
// exit 0. Caller invokes BEFORE parseStrict so unknown-arg rejection doesn't
|
|
21
|
+
// shadow the universal first-probe of every Unix CLI. Pre-fix, every
|
|
22
|
+
// parseStrict-using script (audit / sparkline / hard-rules-audit /
|
|
23
|
+
// clean-residue / doctor) responded `Unknown argument: '--help'.` exit 2 —
|
|
24
|
+
// classic discoverability bug for new users.
|
|
25
|
+
export function printHelpAndExit(argv, usage) {
|
|
26
|
+
if (argv.some(a => a === '--help' || a === '-h')) {
|
|
27
|
+
process.stdout.write(usage.endsWith('\n') ? usage : usage + '\n');
|
|
28
|
+
process.exit(0);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Strict positive-integer validator for numeric flags (`--days` / `--age-days`
|
|
33
|
+
// / `--prune-backups` / `--sample`). Returns the integer when `raw` (after
|
|
34
|
+
// trimming surrounding whitespace) is a plain base-10 positive integer with no
|
|
35
|
+
// leading zero, else null. `Number()` alone is too permissive — it coerces
|
|
36
|
+
// '0x1e' → 30, '1e2' → 100, ' 30 ' → 30 — all of which pass `Number.isInteger`
|
|
37
|
+
// despite help text promising a "positive integer", a silent contract
|
|
38
|
+
// divergence (inverse of the `parseInt` truncation footgun). Mirrors the
|
|
39
|
+
// `/^[1-9][0-9]*$/` guard already used for CLAUDEMD_BATCH_THRESHOLD in status.js.
|
|
40
|
+
export function parsePositiveInt(raw) {
|
|
41
|
+
if (raw == null) return null;
|
|
42
|
+
const s = String(raw).trim();
|
|
43
|
+
// Plain base-10 notation only — rejects hex ('0x1e'), exponential ('1e2'),
|
|
44
|
+
// signs, and interior junk that `Number()` would coerce. A trailing-zero
|
|
45
|
+
// decimal ('30.0', '30.00') is allowed through the shape gate so the
|
|
46
|
+
// integer-valued-float check below can accept it (existing contract: callers
|
|
47
|
+
// / scripts may pass '30.0'); a true fraction ('1.5') passes the shape gate
|
|
48
|
+
// but fails Number.isInteger and is rejected.
|
|
49
|
+
if (!/^[0-9]+(\.[0-9]+)?$/.test(s)) return null;
|
|
50
|
+
const n = Number(s);
|
|
51
|
+
if (!Number.isInteger(n) || n < 1) return null;
|
|
52
|
+
return n;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function parseStrict(argv, { bools = [], values = [] } = {}) {
|
|
56
|
+
const out = { bools: new Set(), values: {} };
|
|
57
|
+
const knownBool = new Set(bools);
|
|
58
|
+
const knownValue = new Set(values);
|
|
59
|
+
for (const a of argv) {
|
|
60
|
+
if (knownBool.has(a)) { out.bools.add(a); continue; }
|
|
61
|
+
if (a.startsWith('--') && a.includes('=')) {
|
|
62
|
+
const eq = a.indexOf('=');
|
|
63
|
+
const k = a.slice(0, eq);
|
|
64
|
+
const v = a.slice(eq + 1);
|
|
65
|
+
if (knownValue.has(k)) { out.values[k] = v; continue; }
|
|
66
|
+
if (knownBool.has(k)) {
|
|
67
|
+
throw new ArgvError(`Boolean flag '${k}' does not take a value (got '${a}').`);
|
|
68
|
+
}
|
|
69
|
+
throw new ArgvError(`Unknown flag: '${k}'.`);
|
|
70
|
+
}
|
|
71
|
+
if (knownValue.has(a)) {
|
|
72
|
+
throw new ArgvError(`'${a}' requires '=value' form (got '${a}' bare). Use '${a}=N'.`);
|
|
73
|
+
}
|
|
74
|
+
throw new ArgvError(`Unknown argument: '${a}'.`);
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Space-form argv validator for the published `claudemd-cli` binary.
|
|
80
|
+
//
|
|
81
|
+
// Lived in bin/claudemd-lint.js until audit-2026-08-22 条目 14: a second argv
|
|
82
|
+
// authority beside parseStrict, and scripts/lint-argv.js authenticated it BY
|
|
83
|
+
// FUNCTION NAME, so any file that declared a local function called
|
|
84
|
+
// `validateAndExpandFlags` satisfied the gate without validating anything. The
|
|
85
|
+
// gate had been widened to accommodate the duplicate instead of the duplicate
|
|
86
|
+
// being converged. It is not merged into parseStrict because the two contracts
|
|
87
|
+
// genuinely differ and the difference is published: parseStrict rejects the
|
|
88
|
+
// `--key value` space form and positional arguments, both of which
|
|
89
|
+
// `claudemd-cli lint <path> --comment-char ';'` documents and users' pre-commit
|
|
90
|
+
// hooks depend on.
|
|
91
|
+
//
|
|
92
|
+
// Strict-validate flag-shaped args + normalize `--key=value` → `--key value`
|
|
93
|
+
// pairs so the existing space-form parsing below works on either shape.
|
|
94
|
+
// Catches the same antipattern the slash-command CLIs hit in v0.9.16/0.9.17:
|
|
95
|
+
// `args.includes('--json')` returns false for `--json=yes`, so the flag was
|
|
96
|
+
// silently dropped; `args.indexOf('--file')` returns -1 for `--file=PATH`,
|
|
97
|
+
// so the value was silently ignored; an unknown `--jzon` typo was silently
|
|
98
|
+
// stripped from positional and never surfaced. Each path now exits 2.
|
|
99
|
+
export function validateAndExpandFlags(args, knownBools, knownValues, sub) {
|
|
100
|
+
const out = [];
|
|
101
|
+
const bools = new Set(knownBools);
|
|
102
|
+
const values = new Set(knownValues);
|
|
103
|
+
for (const a of args) {
|
|
104
|
+
if (!a.startsWith('--')) { out.push(a); continue; }
|
|
105
|
+
if (a.includes('=')) {
|
|
106
|
+
const eq = a.indexOf('=');
|
|
107
|
+
const k = a.slice(0, eq);
|
|
108
|
+
const v = a.slice(eq + 1);
|
|
109
|
+
if (bools.has(k)) {
|
|
110
|
+
process.stderr.write(`${sub}: '${k}' is a boolean flag and does not take a value (got '${a}'). Drop the '=...' suffix.\n`);
|
|
111
|
+
process.exit(2);
|
|
112
|
+
}
|
|
113
|
+
if (values.has(k)) {
|
|
114
|
+
out.push(k);
|
|
115
|
+
out.push(v);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
process.stderr.write(`${sub}: unknown flag '${k}' (got '${a}').\n`);
|
|
119
|
+
process.exit(2);
|
|
120
|
+
}
|
|
121
|
+
if (bools.has(a) || values.has(a)) { out.push(a); continue; }
|
|
122
|
+
process.stderr.write(`${sub}: unknown flag '${a}'.\n`);
|
|
123
|
+
process.exit(2);
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
package/scripts/lib/lint.js
CHANGED
|
@@ -85,10 +85,14 @@ function posixClassesToJs(regex) {
|
|
|
85
85
|
// not read as a value claim. `\b` treats '-', '/', '.' as word boundaries, so
|
|
86
86
|
// `\bcomprehensive\b` fires INSIDE `comprehensive-parser.js` or a branch name
|
|
87
87
|
// `docs/comprehensive-audit`. Mirrors hooks/banned-vocab-check.sh's v0.23.19
|
|
88
|
-
// Path 2 sanitizer
|
|
89
|
-
//
|
|
90
|
-
// commit messages
|
|
91
|
-
//
|
|
88
|
+
// Path 2 sanitizer stage for stage: fenced blocks → inline backtick spans →
|
|
89
|
+
// slashed-path runs → bare dotted-file tokens. The last clause was JS-only when
|
|
90
|
+
// it was written — commit messages, the CLI's primary input, commonly name bare
|
|
91
|
+
// files (`refactor comprehensive-parser.js`) with no backticks and no leading
|
|
92
|
+
// path — but the bash side gained it on 2026-08-16 and
|
|
93
|
+
// tests/scripts/sanitize-stage-parity.test.js now extracts both programs and
|
|
94
|
+
// requires the clause lists to match, so it is no longer an extension of
|
|
95
|
+
// anything (audit-2026-08-22 条目 22). Token classes are ASCII-only so 中文
|
|
92
96
|
// prose and bare-word claims (the real violations) stay intact and still match.
|
|
93
97
|
export function stripIdentifiers(text) {
|
|
94
98
|
if (!text) return text;
|
|
@@ -162,8 +166,10 @@ export function stripIdentifiers(text) {
|
|
|
162
166
|
// The bash engines need no equivalent: POSIX sed does not backtrack and
|
|
163
167
|
// the hook caps its input at `tail -c 4096`; the Node path caps nothing.
|
|
164
168
|
.replace(/(?<![A-Za-z0-9._@~-])[A-Za-z0-9._@~-]*\/[A-Za-z0-9._/@~-]*/g, ' ')
|
|
165
|
-
// 4. Bare dotted-file tokens (foo.js, comprehensive-parser.ts)
|
|
166
|
-
//
|
|
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
|
|
167
173
|
// (a) excludes decimals / versions ("3.5x", "v6.14") whose ".5x"/".14"
|
|
168
174
|
// could otherwise swallow a baseline-less ratio claim → false negative,
|
|
169
175
|
// and (b) excludes sentence-boundary typos ("comprehensive.Next", capital
|
|
@@ -275,7 +281,7 @@ export function looksLikeGitMessageFile(filePath) {
|
|
|
275
281
|
// editor shapes stored none. Stripping unconditionally therefore muted a
|
|
276
282
|
// real violation in `git commit -F release-notes.md` or
|
|
277
283
|
// `-m "$(cat notes.md)"` whenever the body carried a markdown heading.
|
|
278
|
-
export function stripGitCommitComments(text, commentChar = '#') {
|
|
284
|
+
export function stripGitCommitComments(text, commentChar = '#', { templateLines } = {}) {
|
|
279
285
|
if (!text) return text;
|
|
280
286
|
const c = (typeof commentChar === 'string' && commentChar.length === 1) ? commentChar : '#';
|
|
281
287
|
const esc = c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
@@ -287,31 +293,64 @@ export function stripGitCommitComments(text, commentChar = '#') {
|
|
|
287
293
|
const lines = cutAt === -1 ? all : all.slice(0, cutAt);
|
|
288
294
|
const sawCutLine = cutAt !== -1;
|
|
289
295
|
|
|
290
|
-
// 2.
|
|
291
|
-
|
|
292
|
-
|
|
296
|
+
// 2. Drop lines git copied verbatim out of `commit.template`. This is an
|
|
297
|
+
// exact match against the template file's own comment lines, not a shape
|
|
298
|
+
// heuristic: git knows they are template lines because it copied them, and
|
|
299
|
+
// it discards them under the editor path's cleanup=strip. Author-typed
|
|
300
|
+
// comment lines are absent from the template and stay in scope (P1-3).
|
|
301
|
+
const fromTemplate = templateComments(templateLines, c);
|
|
302
|
+
const body = fromTemplate.size
|
|
303
|
+
? lines.filter(l => !(l.startsWith(c) && fromTemplate.has(l)))
|
|
304
|
+
: lines;
|
|
305
|
+
|
|
306
|
+
// 3. Strip the remaining comment lines only when git wrote a status block or
|
|
307
|
+
// a cut line here.
|
|
308
|
+
if (!sawCutLine && !hasGitTemplate(body, c)) return body.join('\n');
|
|
309
|
+
return body.filter(l => !l.startsWith(c)).join('\n');
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// The comment lines of a resolved `commit.template`, as an exact-match set.
|
|
313
|
+
// Non-comment template lines are deliberately excluded: git KEEPS those in the
|
|
314
|
+
// stored message, so they are the author's text and must stay scannable.
|
|
315
|
+
function templateComments(templateLines, c) {
|
|
316
|
+
const set = new Set();
|
|
317
|
+
if (!templateLines) return set;
|
|
318
|
+
const arr = Array.isArray(templateLines) ? templateLines : String(templateLines).split('\n');
|
|
319
|
+
for (const l of arr) if (typeof l === 'string' && l.startsWith(c)) set.add(l);
|
|
320
|
+
return set;
|
|
293
321
|
}
|
|
294
322
|
|
|
295
|
-
// Locale-proof template detection.
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
//
|
|
323
|
+
// Locale-proof template detection. The signal is structural — git localizes the
|
|
324
|
+
// LABELS ("Changes to be committed", "Please enter the commit message…") but
|
|
325
|
+
// never the `<commentChar>`+TAB status prefix that introduces each file it
|
|
326
|
+
// lists. (The cut line is the other signal, handled by the caller.)
|
|
299
327
|
//
|
|
300
|
-
// Deliberately conservative: when
|
|
328
|
+
// Deliberately conservative: when it does not fire we scan MORE text, so a
|
|
301
329
|
// misdetection costs a false positive (visible, bypassable) rather than a
|
|
302
|
-
// silent miss.
|
|
303
|
-
//
|
|
330
|
+
// silent miss.
|
|
331
|
+
//
|
|
332
|
+
// A second signal used to live here — "≥3 contiguous comment lines ending at
|
|
333
|
+
// EOF" — meant to catch git's intro paragraph. Removed (audit-2026-08-22 P1-3)
|
|
334
|
+
// because of what it reached in the other direction: a `git commit -F notes.md`
|
|
335
|
+
// body (cleanup=whitespace — git KEEPS those lines) ending in three `#` lines
|
|
336
|
+
// had them stripped before the scan, muting any §10-V violation inside. Same
|
|
337
|
+
// violation at 2 trailing `#` lines denied, at 3 it exited 0 — a silent miss
|
|
338
|
+
// that grew MORE likely the longer the commented block got, on the shipped
|
|
339
|
+
// pre-commit/CI entry point.
|
|
340
|
+
//
|
|
341
|
+
// That removal was shipped with the claim that no git shape needs the signal.
|
|
342
|
+
// The 0.68.3 pre-tag review refuted it: the six-shape table measured
|
|
343
|
+
// `commit.status=false` with no `commit.template` configured. Configure one and
|
|
344
|
+
// git hands the hook a buffer of pure template comment lines — no `#\t`, no cut
|
|
345
|
+
// line — and discards every one of them. The replacement is not a third shape
|
|
346
|
+
// heuristic but the template's actual content, matched line-for-line; see
|
|
347
|
+
// `templateComments` and the `templateLines` option on the caller above.
|
|
348
|
+
// The `--allow-empty`-on-a-clean-tree shape (git status prose, no `#\t`,
|
|
349
|
+
// no cut line) is still scanned and is NOT covered here — stating it rather
|
|
350
|
+
// than implying the set is closed, which is the error this comment shipped.
|
|
304
351
|
function hasGitTemplate(lines, c) {
|
|
305
352
|
// git's status file list: `#\tmodified: path`
|
|
306
|
-
|
|
307
|
-
// The intro paragraph: ≥3 contiguous comment lines ending at EOF (trailing
|
|
308
|
-
// blanks ignored). A hand-written `-m` message carries one such line, not a
|
|
309
|
-
// run of three terminating the file.
|
|
310
|
-
let i = lines.length - 1;
|
|
311
|
-
while (i >= 0 && lines[i].trim() === '') i--;
|
|
312
|
-
let run = 0;
|
|
313
|
-
while (i >= 0 && lines[i].startsWith(c)) { run++; i--; }
|
|
314
|
-
return run >= 3;
|
|
353
|
+
return lines.some(l => l.startsWith(c + '\t'));
|
|
315
354
|
}
|
|
316
355
|
|
|
317
356
|
export function scan(text, { excludeRatio = false, patterns, sanitize = false } = {}) {
|