text-sanctifier 1.3.1 → 1.3.3

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.
@@ -1,519 +1,753 @@
1
- #!/usr/bin/env node
2
- // bin/text-sanctifier.js
3
- //
4
- // Command-line front end for coding agents and CI:
5
- // text-sanctifier check [paths...] report invisible and control characters
6
- // text-sanctifier fix [paths...] remove them in place
7
- //
8
- // Plain Node (>=18), no dependencies. Exit codes: 0 clean, 1 findings (check,
9
- // or fix --dry-run with changes pending), 2 usage or I/O error.
10
-
11
- import { readFileSync, writeFileSync, statSync, readdirSync, existsSync } from 'node:fs';
12
- import { resolve, join, sep } from 'node:path';
13
- import { spawnSync } from 'node:child_process';
14
- import { inspectTextDetailed, summonSanctifier } from '../src/index.js';
15
- import { getNewlineStats } from '../src/inspectText.js';
16
-
17
- const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
18
-
19
- const USAGE = `text-sanctifier ${VERSION}
20
-
21
- Usage:
22
- text-sanctifier check [options] [paths...]
23
- text-sanctifier fix [options] [paths...]
24
-
25
- Paths may be files, directories or globs (quote globs so the shell leaves them
26
- alone). "-", or no paths with piped input, reads stdin. Directories and globs
27
- skip files ignored by git (inside a git work tree) and skip binary files.
28
-
29
- check options:
30
- --json JSON report on stdout
31
- --max-findings N findings listed per file (default 100, "all" for no cap)
32
- --strip-bom also report a BOM at the start of a file
33
-
34
- fix options:
35
- --purge-invisible remove zero-width, non-breaking space, bidi and BOM characters
36
- --nuke-controls remove C0/C1 control characters and bidi controls
37
- --normalize-newlines convert CRLF and CR to LF
38
- --strip-bom also remove a BOM at the start of a file (kept by default)
39
- --dry-run report what would change; write nothing
40
- --json JSON report
41
- With no cleaning flag, fix uses --purge-invisible --nuke-controls.
42
- stdin is cleaned to stdout; the report then goes to stderr.
43
-
44
- Other:
45
- -h, --help show this help
46
- -v, --version show the version
47
-
48
- Exit codes: 0 clean, 1 findings (check) or changes pending (fix --dry-run),
49
- 2 usage or I/O error.
50
- `;
51
-
52
- const BOM = 0xFEFF;
53
- const STDIN = '-';
54
- const STDIN_LABEL = '<stdin>';
55
- const BINARY_SNIFF_BYTES = 8000;
56
- const WALK_SKIP_DIRS = new Set(['.git', 'node_modules']);
57
- const CASE_INSENSITIVE_FS = process.platform === 'win32' || process.platform === 'darwin';
58
-
59
- class UsageError extends Error {}
60
-
61
-
62
- // --- Arguments ---
63
-
64
- const BOOLEAN_FLAGS = {
65
- '--json': 'json',
66
- '--strip-bom': 'stripBom',
67
- '--purge-invisible': 'purgeInvisible',
68
- '--nuke-controls': 'nukeControls',
69
- '--normalize-newlines': 'normalizeNewlines',
70
- '--dry-run': 'dryRun',
71
- };
72
- const FIX_ONLY_FLAGS = new Set(['purgeInvisible', 'nukeControls', 'normalizeNewlines', 'dryRun']);
73
-
74
- /**
75
- * @param {string[]} argv
76
- */
77
- function parseArgs(argv) {
78
- const opts = {
79
- command: null,
80
- paths: [],
81
- json: false,
82
- stripBom: false,
83
- purgeInvisible: false,
84
- nukeControls: false,
85
- normalizeNewlines: false,
86
- dryRun: false,
87
- maxFindings: 100,
88
- help: false,
89
- version: false,
90
- };
91
- let endOfOptions = false;
92
-
93
- for (let i = 0; i < argv.length; i++) {
94
- const arg = argv[i];
95
-
96
- if (endOfOptions || arg === STDIN || !arg.startsWith('-')) {
97
- if (opts.command === null && !endOfOptions) opts.command = arg;
98
- else opts.paths.push(arg);
99
- continue;
100
- }
101
- if (arg === '--') { endOfOptions = true; continue; }
102
- if (arg === '-h' || arg === '--help') { opts.help = true; continue; }
103
- if (arg === '-v' || arg === '--version') { opts.version = true; continue; }
104
-
105
- const [name, inline] = arg.split(/=(.*)/s, 2);
106
- if (name === '--max-findings') {
107
- const value = inline !== undefined ? inline : argv[++i];
108
- opts.maxFindings = parseMaxFindings(value);
109
- continue;
110
- }
111
- if (name in BOOLEAN_FLAGS && inline === undefined) {
112
- opts[BOOLEAN_FLAGS[name]] = true;
113
- continue;
114
- }
115
- throw new UsageError(`Unknown option: ${arg}`);
116
- }
117
-
118
- if (opts.help || opts.version) return opts;
119
- if (opts.command !== 'check' && opts.command !== 'fix') {
120
- throw new UsageError(opts.command === null ? 'Missing command.' : `Unknown command: ${opts.command}`);
121
- }
122
- if (opts.command === 'check') {
123
- for (const [flag, key] of Object.entries(BOOLEAN_FLAGS)) {
124
- if (FIX_ONLY_FLAGS.has(key) && opts[key]) throw new UsageError(`${flag} only applies to fix.`);
125
- }
126
- }
127
- if (opts.command === 'fix' && !opts.purgeInvisible && !opts.nukeControls && !opts.normalizeNewlines) {
128
- opts.purgeInvisible = true;
129
- opts.nukeControls = true;
130
- }
131
- return opts;
132
- }
133
-
134
- /** @param {string|undefined} value */
135
- function parseMaxFindings(value) {
136
- if (value === 'all') return Infinity;
137
- const n = Number(value);
138
- if (value === undefined || value === '' || !Number.isInteger(n) || n < 0) {
139
- throw new UsageError(`--max-findings expects a non-negative integer or "all", got ${value === undefined ? 'nothing' : JSON.stringify(value)}.`);
140
- }
141
- return n;
142
- }
143
-
144
-
145
- // --- File collection ---
146
-
147
- const GLOB_CHARS = /[*?[{]/;
148
-
149
- /** @param {string} p */
150
- const toSlash = (p) => p.split(sep).join('/');
151
-
152
- /** @param {string} cwd */
153
- function isGitWorkTree(cwd) {
154
- const r = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd, encoding: 'utf8' });
155
- return !r.error && r.status === 0 && r.stdout.trim() === 'true';
156
- }
157
-
158
- /**
159
- * Lists tracked and untracked-but-not-ignored files under one pathspec.
160
- * Returns null when git cannot answer (for example a path outside the work tree).
161
- * @param {string} cwd
162
- * @param {string} pathspec
163
- * @returns {string[]|null}
164
- */
165
- function gitListFiles(cwd, pathspec) {
166
- const r = spawnSync(
167
- 'git',
168
- ['ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', pathspec],
169
- { cwd, encoding: 'utf8', maxBuffer: 1 << 28 }
170
- );
171
- if (r.error || r.status !== 0) return null;
172
- // --cached lists tracked files deleted from the work tree; drop them
173
- return r.stdout.split('\0').filter((f) => f && existsSync(resolve(cwd, f)));
174
- }
175
-
176
- /**
177
- * Recursive file walk used outside git. Skips .git and node_modules.
178
- * @param {string} cwd
179
- * @param {string} dir - relative or absolute, as the user typed it
180
- * @returns {string[]}
181
- */
182
- function walk(cwd, dir) {
183
- const out = [];
184
- const stack = [dir];
185
- while (stack.length) {
186
- const current = stack.pop();
187
- let entries;
188
- try {
189
- entries = readdirSync(resolve(cwd, current), { withFileTypes: true });
190
- } catch {
191
- continue;
192
- }
193
- entries.sort((a, b) => (a.name < b.name ? 1 : a.name > b.name ? -1 : 0));
194
- for (const e of entries) {
195
- const p = current === '.' ? e.name : join(current, e.name);
196
- if (e.isDirectory()) {
197
- if (!WALK_SKIP_DIRS.has(e.name)) stack.push(p);
198
- } else if (e.isFile()) {
199
- out.push(toSlash(p));
200
- }
201
- }
202
- }
203
- return out.sort();
204
- }
205
-
206
- /**
207
- * Glob to RegExp for the non-git fallback: `**`, `*`, `?`, `[...]` and `{a,b}`.
208
- * @param {string} glob
209
- */
210
- function globToRegExp(glob) {
211
- let re = '';
212
- for (let i = 0; i < glob.length; i++) {
213
- const c = glob[i];
214
- if (c === '*') {
215
- if (glob[i + 1] === '*') {
216
- const slashAfter = glob[i + 2] === '/';
217
- re += slashAfter ? '(?:.*/)?' : '.*';
218
- i += slashAfter ? 2 : 1;
219
- } else {
220
- re += '[^/]*';
221
- }
222
- } else if (c === '?') {
223
- re += '[^/]';
224
- } else if (c === '[') {
225
- const close = glob.indexOf(']', i + 1);
226
- if (close === -1) { re += '\\['; continue; }
227
- let body = glob.slice(i + 1, close).replace(/\\/g, '\\\\');
228
- if (body[0] === '!') body = '^' + body.slice(1);
229
- re += `[${body}]`;
230
- i = close;
231
- } else if (c === '{') {
232
- const close = glob.indexOf('}', i + 1);
233
- if (close === -1) { re += '\\{'; continue; }
234
- const alts = glob.slice(i + 1, close).split(',').map((a) => a.replace(/[.+^$()|\\]/g, '\\$&'));
235
- re += `(?:${alts.join('|')})`;
236
- i = close;
237
- } else {
238
- re += c.replace(/[.+^$()|\\\]}]/g, '\\$&');
239
- }
240
- }
241
- return new RegExp(`^${re}$`);
242
- }
243
-
244
- /**
245
- * @param {string} cwd
246
- * @param {string} glob
247
- */
248
- function walkGlob(cwd, glob) {
249
- const pattern = toSlash(glob).replace(/^\.\//, '');
250
- const segments = pattern.split('/');
251
- const firstGlob = segments.findIndex((s) => GLOB_CHARS.test(s));
252
- const base = segments.slice(0, firstGlob).join('/') || '.';
253
- const re = globToRegExp(pattern);
254
- return walk(cwd, base).filter((f) => re.test(f));
255
- }
256
-
257
- /**
258
- * Expands the path arguments into a de-duplicated input list.
259
- * @param {string[]} args
260
- * @param {string} cwd
261
- * @param {(msg: string) => void} warn
262
- * @returns {{ inputs: Array<{ path: string, explicit: boolean }>, errors: number }}
263
- */
264
- function collectInputs(args, cwd, warn) {
265
- const inputs = [];
266
- const seen = new Set();
267
- let errors = 0;
268
- let inGit;
269
-
270
- const add = (path, explicit) => {
271
- const abs = resolve(cwd, path);
272
- const key = path === STDIN ? STDIN : CASE_INSENSITIVE_FS ? abs.toLowerCase() : abs;
273
- if (seen.has(key)) return;
274
- seen.add(key);
275
- inputs.push({ path, explicit });
276
- };
277
-
278
- for (const arg of args) {
279
- if (arg === STDIN) { add(STDIN, true); continue; }
280
-
281
- let stat = null;
282
- try { stat = statSync(resolve(cwd, arg)); } catch { /* not a plain path */ }
283
-
284
- if (stat && stat.isFile()) { add(arg, true); continue; }
285
-
286
- const isGlob = !stat && GLOB_CHARS.test(arg);
287
- if (!stat && !isGlob) {
288
- warn(`${arg}: no such file or directory`);
289
- errors++;
290
- continue;
291
- }
292
-
293
- if (inGit === undefined) inGit = isGitWorkTree(cwd);
294
- const listed =
295
- (inGit && gitListFiles(cwd, isGlob ? `:(glob)${toSlash(arg)}` : toSlash(arg))) ||
296
- (isGlob ? walkGlob(cwd, arg) : walk(cwd, arg));
297
-
298
- if (listed.length === 0) warn(`${arg}: no files matched`);
299
- for (const f of listed) add(f, false);
300
- }
301
- return { inputs, errors };
302
- }
303
-
304
-
305
- // --- Reading ---
306
-
307
- const utf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
308
-
309
- /**
310
- * Decodes UTF-8, keeping a leading BOM. Returns null for binary or non-UTF-8 data.
311
- * @param {Uint8Array} bytes
312
- * @returns {{ text: string|null, reason?: string }}
313
- */
314
- function decode(bytes) {
315
- if (bytes.subarray(0, BINARY_SNIFF_BYTES).includes(0)) return { text: null, reason: 'binary' };
316
- try {
317
- return { text: utf8.decode(bytes) };
318
- } catch {
319
- return { text: null, reason: 'not UTF-8' };
320
- }
321
- }
322
-
323
- /** @returns {Promise<Buffer>} */
324
- function readStdin() {
325
- return new Promise((res, rej) => {
326
- const chunks = [];
327
- process.stdin.on('data', (c) => chunks.push(c));
328
- process.stdin.on('end', () => res(Buffer.concat(chunks)));
329
- process.stdin.on('error', rej);
330
- });
331
- }
332
-
333
-
334
- // --- check ---
335
-
336
- /**
337
- * inspectTextDetailed, minus a BOM at offset 0 unless stripBom.
338
- * @param {string} text
339
- * @param {number} maxFindings
340
- * @param {boolean} stripBom
341
- */
342
- function inspect(text, maxFindings, stripBom) {
343
- const leadingBom = !stripBom && text.charCodeAt(0) === BOM;
344
- const report = inspectTextDetailed(text, { maxFindings: leadingBom ? maxFindings + 1 : maxFindings });
345
- if (leadingBom) {
346
- report.findings.shift();
347
- report.counts.bom--;
348
- report.total--;
349
- report.truncated = report.total > report.findings.length;
350
- }
351
- return report;
352
- }
353
-
354
- /** @param {number} cp */
355
- const hex = (cp) => 'U+' + cp.toString(16).toUpperCase().padStart(4, '0');
356
-
357
- /**
358
- * @param {Record<string, number>} counts
359
- */
360
- const formatCounts = (counts) =>
361
- Object.entries(counts).filter(([, n]) => n > 0).map(([k, n]) => `${k} ${n}`).join(', ');
362
-
363
-
364
- // --- Main ---
365
-
366
- /**
367
- * @param {string[]} argv
368
- * @returns {Promise<number>} exit code
369
- */
370
- async function main(argv) {
371
- const out = (s) => process.stdout.write(s + '\n');
372
- const err = (s) => process.stderr.write(s + '\n');
373
-
374
- let opts;
375
- try {
376
- opts = parseArgs(argv);
377
- } catch (e) {
378
- if (!(e instanceof UsageError)) throw e;
379
- err(`text-sanctifier: ${e.message}\nRun "text-sanctifier --help" for usage.`);
380
- return 2;
381
- }
382
- if (opts.help) { out(USAGE); return 0; }
383
- if (opts.version) { out(VERSION); return 0; }
384
-
385
- const cwd = process.cwd();
386
- let paths = opts.paths;
387
- if (paths.length === 0) {
388
- if (process.stdin.isTTY) {
389
- err('text-sanctifier: no paths given and nothing piped on stdin.\nRun "text-sanctifier --help" for usage.');
390
- return 2;
391
- }
392
- paths = [STDIN];
393
- }
394
-
395
- const warn = (msg) => err(`text-sanctifier: ${msg}`);
396
- const { inputs, errors: pathErrors } = collectInputs(paths, cwd, warn);
397
- let errors = pathErrors;
398
- /** @type {Array<{ path: string, reason: string }>} */
399
- const skipped = [];
400
-
401
- /**
402
- * Reads every input in order, calling `visit(label, text, input)` for each decodable one.
403
- * @param {(label: string, text: string, input: { path: string, explicit: boolean }) => void} visit
404
- */
405
- const forEachText = async (visit) => {
406
- for (const input of inputs) {
407
- const label = input.path === STDIN ? STDIN_LABEL : input.path;
408
- let bytes;
409
- try {
410
- bytes = input.path === STDIN ? await readStdin() : readFileSync(resolve(cwd, input.path));
411
- } catch (e) {
412
- warn(`${label}: ${e.code || e.message}`);
413
- errors++;
414
- continue;
415
- }
416
- const { text, reason } = decode(bytes);
417
- if (text === null) {
418
- skipped.push({ path: label, reason });
419
- if (input.explicit) warn(`${label}: skipped (${reason})`);
420
- continue;
421
- }
422
- visit(label, text, input);
423
- }
424
- };
425
-
426
- if (opts.command === 'check') {
427
- const files = [];
428
- let scanned = 0;
429
- let total = 0;
430
-
431
- await forEachText((label, text) => {
432
- scanned++;
433
- const report = inspect(text, opts.maxFindings, opts.stripBom);
434
- total += report.total;
435
- if (report.total > 0) files.push({ path: label, ...report });
436
- });
437
-
438
- if (opts.json) {
439
- out(JSON.stringify({ files, total, filesScanned: scanned, filesWithFindings: files.length, skipped }, null, 2));
440
- } else {
441
- for (const f of files) {
442
- for (const x of f.findings) out(`${f.path}:${x.line}:${x.column} ${hex(x.codePoint)} ${x.category}`);
443
- if (f.truncated) out(`${f.path}: ${f.total - f.findings.length} more not listed (--max-findings)`);
444
- }
445
- out(total === 0
446
- ? `No invisible or control characters in ${scanned} file${scanned === 1 ? '' : 's'}.`
447
- : `${total} finding${total === 1 ? '' : 's'} in ${files.length} of ${scanned} file${scanned === 1 ? '' : 's'}.`);
448
- }
449
- return errors > 0 ? 2 : total > 0 ? 1 : 0;
450
- }
451
-
452
- // fix
453
- const clean = summonSanctifier({
454
- purgeInvisibleChars: opts.purgeInvisible,
455
- nukeControls: opts.nukeControls,
456
- normalizeNewlines: opts.normalizeNewlines,
457
- });
458
- const writesStdout = !opts.dryRun && inputs.some((i) => i.path === STDIN);
459
- const report = writesStdout ? err : out;
460
- const files = [];
461
- let scanned = 0;
462
-
463
- await forEachText((label, text, input) => {
464
- scanned++;
465
- // The BOM at offset 0 is set aside so purgeInvisibleChars cannot take it
466
- const leadingBom = text.charCodeAt(0) === BOM;
467
- let cleaned = clean(leadingBom ? text.slice(1) : text);
468
- if (leadingBom && !opts.stripBom) cleaned = String.fromCharCode(BOM) + cleaned;
469
-
470
- if (input.path === STDIN && !opts.dryRun) process.stdout.write(cleaned);
471
- if (cleaned === text) return;
472
-
473
- const before = inspectTextDetailed(text, { maxFindings: 0 }).counts;
474
- const after = inspectTextDetailed(cleaned, { maxFindings: 0 }).counts;
475
- const removed = {};
476
- let removedTotal = 0;
477
- for (const k of Object.keys(before)) {
478
- const n = before[k] - (after[k] || 0);
479
- if (n > 0) { removed[k] = n; removedTotal += n; }
480
- }
481
- const nl = getNewlineStats(text);
482
- const newlinesNormalized = opts.normalizeNewlines && nl.crlf + nl.cr > 0;
483
-
484
- if (input.path !== STDIN && !opts.dryRun) {
485
- try {
486
- writeFileSync(resolve(cwd, input.path), cleaned, 'utf8');
487
- } catch (e) {
488
- warn(`${label}: ${e.code || e.message}`);
489
- errors++;
490
- return;
491
- }
492
- }
493
- files.push({ path: label, removed, removedTotal, newlinesNormalized });
494
- });
495
-
496
- if (opts.json) {
497
- report(JSON.stringify({ files, dryRun: opts.dryRun, filesScanned: scanned, filesChanged: files.length, skipped }, null, 2));
498
- } else {
499
- const verb = opts.dryRun ? 'would fix' : 'fixed';
500
- for (const f of files) {
501
- const parts = [];
502
- if (f.removedTotal > 0) parts.push(`removed ${f.removedTotal} (${formatCounts(f.removed)})`);
503
- if (f.newlinesNormalized) parts.push('normalized newlines');
504
- report(`${verb} ${f.path}: ${parts.join('; ')}`);
505
- }
506
- report(files.length === 0
507
- ? `Nothing to fix in ${scanned} file${scanned === 1 ? '' : 's'}.`
508
- : `${opts.dryRun ? 'Would change' : 'Changed'} ${files.length} of ${scanned} file${scanned === 1 ? '' : 's'}.`);
509
- }
510
- return errors > 0 ? 2 : opts.dryRun && files.length > 0 ? 1 : 0;
511
- }
512
-
513
- main(process.argv.slice(2)).then(
514
- (code) => { process.exitCode = code; },
515
- (e) => {
516
- process.stderr.write(`text-sanctifier: ${e && e.stack ? e.stack : e}\n`);
517
- process.exitCode = 2;
518
- }
519
- );
1
+ #!/usr/bin/env node
2
+ // bin/text-sanctifier.js
3
+ //
4
+ // Command-line front end for coding agents and CI:
5
+ // text-sanctifier check [paths...] report invisible and control characters
6
+ // text-sanctifier check --ai-config report hidden text in agent instruction files
7
+ // text-sanctifier fix [paths...] remove them in place
8
+ //
9
+ // Plain Node (>=18), no dependencies. Exit codes: 0 clean, 1 findings (check,
10
+ // or fix --dry-run with changes pending), 2 usage or I/O error.
11
+
12
+ import { readFileSync, writeFileSync, statSync, readdirSync, existsSync } from 'node:fs';
13
+ import { resolve, join, sep, relative } from 'node:path';
14
+ import { spawnSync } from 'node:child_process';
15
+ import { pathToFileURL } from 'node:url';
16
+ import { inspectTextDetailed, summonSanctifier } from '../src/index.js';
17
+ import { getNewlineStats } from '../src/inspectText.js';
18
+ import { findHiddenRuns } from '../src/hiddenText.js';
19
+
20
+ const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
21
+
22
+ const USAGE = `text-sanctifier ${VERSION}
23
+
24
+ Usage:
25
+ text-sanctifier check [options] [paths...]
26
+ text-sanctifier fix [options] [paths...]
27
+
28
+ Paths may be files, directories or globs (quote globs so the shell leaves them
29
+ alone). "-", or no paths with piped input, reads stdin. Directories and globs
30
+ skip files ignored by git (inside a git work tree) and skip binary files.
31
+
32
+ check options:
33
+ --format F text (default), json or sarif (SARIF 2.1.0)
34
+ --json same as --format json
35
+ --max-findings N findings listed per file (default 100, "all" for no cap)
36
+ --strip-bom also report a BOM at the start of a file
37
+ --ai-config check agent instruction files (CLAUDE.md, AGENTS.md,
38
+ .cursor/rules, SKILL.md, MCP config, ...) for hidden text;
39
+ directories, or "." when no paths are given, are searched
40
+ for those files, and tag text is decoded
41
+
42
+ fix options:
43
+ --purge-invisible remove zero-width, non-breaking space, bidi and BOM characters
44
+ --nuke-controls remove C0/C1 control characters and bidi controls
45
+ --normalize-newlines convert CRLF and CR to LF
46
+ --strip-bom also remove a BOM at the start of a file (kept by default)
47
+ --dry-run report what would change; write nothing
48
+ --format F text (default) or json
49
+ --json same as --format json
50
+ With no cleaning flag, fix uses --purge-invisible --nuke-controls.
51
+ stdin is cleaned to stdout; the report then goes to stderr.
52
+
53
+ Other:
54
+ -h, --help show this help
55
+ -v, --version show the version
56
+
57
+ Exit codes: 0 clean, 1 findings (check) or changes pending (fix --dry-run),
58
+ 2 usage or I/O error.
59
+ `;
60
+
61
+ const BOM = 0xFEFF;
62
+ const STDIN = '-';
63
+ const STDIN_LABEL = '<stdin>';
64
+ const BINARY_SNIFF_BYTES = 8000;
65
+ const WALK_SKIP_DIRS = new Set(['.git', 'node_modules']);
66
+ const CASE_INSENSITIVE_FS = process.platform === 'win32' || process.platform === 'darwin';
67
+
68
+ class UsageError extends Error {}
69
+
70
+
71
+ // --- Arguments ---
72
+
73
+ const BOOLEAN_FLAGS = {
74
+ '--ai-config': 'aiConfig',
75
+ '--strip-bom': 'stripBom',
76
+ '--purge-invisible': 'purgeInvisible',
77
+ '--nuke-controls': 'nukeControls',
78
+ '--normalize-newlines': 'normalizeNewlines',
79
+ '--dry-run': 'dryRun',
80
+ };
81
+ const FIX_ONLY_FLAGS = new Set(['purgeInvisible', 'nukeControls', 'normalizeNewlines', 'dryRun']);
82
+ const CHECK_ONLY_FLAGS = new Set(['aiConfig']);
83
+ const FORMATS = new Set(['text', 'json', 'sarif']);
84
+
85
+ /**
86
+ * @param {string[]} argv
87
+ */
88
+ function parseArgs(argv) {
89
+ const opts = {
90
+ command: null,
91
+ paths: [],
92
+ format: null,
93
+ aiConfig: false,
94
+ stripBom: false,
95
+ purgeInvisible: false,
96
+ nukeControls: false,
97
+ normalizeNewlines: false,
98
+ dryRun: false,
99
+ maxFindings: 100,
100
+ help: false,
101
+ version: false,
102
+ };
103
+ let endOfOptions = false;
104
+
105
+ for (let i = 0; i < argv.length; i++) {
106
+ const arg = argv[i];
107
+
108
+ if (endOfOptions || arg === STDIN || !arg.startsWith('-')) {
109
+ if (opts.command === null && !endOfOptions) opts.command = arg;
110
+ else opts.paths.push(arg);
111
+ continue;
112
+ }
113
+ if (arg === '--') { endOfOptions = true; continue; }
114
+ if (arg === '-h' || arg === '--help') { opts.help = true; continue; }
115
+ if (arg === '-v' || arg === '--version') { opts.version = true; continue; }
116
+
117
+ const [name, inline] = arg.split(/=(.*)/s, 2);
118
+ if (name === '--max-findings') {
119
+ const value = inline !== undefined ? inline : argv[++i];
120
+ opts.maxFindings = parseMaxFindings(value);
121
+ continue;
122
+ }
123
+ if (name === '--format' || (name === '--json' && inline === undefined)) {
124
+ const value = name === '--json' ? 'json' : inline !== undefined ? inline : argv[++i];
125
+ if (!FORMATS.has(value)) {
126
+ throw new UsageError(`--format expects text, json or sarif, got ${value === undefined ? 'nothing' : JSON.stringify(value)}.`);
127
+ }
128
+ if (opts.format !== null && opts.format !== value) throw new UsageError(`Conflicting formats: ${opts.format} and ${value}.`);
129
+ opts.format = value;
130
+ continue;
131
+ }
132
+ if (name in BOOLEAN_FLAGS && inline === undefined) {
133
+ opts[BOOLEAN_FLAGS[name]] = true;
134
+ continue;
135
+ }
136
+ throw new UsageError(`Unknown option: ${arg}`);
137
+ }
138
+
139
+ if (opts.help || opts.version) return opts;
140
+ if (opts.command !== 'check' && opts.command !== 'fix') {
141
+ throw new UsageError(opts.command === null ? 'Missing command.' : `Unknown command: ${opts.command}`);
142
+ }
143
+ for (const [flag, key] of Object.entries(BOOLEAN_FLAGS)) {
144
+ if (opts.command === 'check' && FIX_ONLY_FLAGS.has(key) && opts[key]) throw new UsageError(`${flag} only applies to fix.`);
145
+ if (opts.command === 'fix' && CHECK_ONLY_FLAGS.has(key) && opts[key]) throw new UsageError(`${flag} only applies to check.`);
146
+ }
147
+ if (opts.format === null) opts.format = 'text';
148
+ if (opts.command === 'fix' && opts.format === 'sarif') throw new UsageError('--format sarif only applies to check.');
149
+ if (opts.command === 'fix' && !opts.purgeInvisible && !opts.nukeControls && !opts.normalizeNewlines) {
150
+ opts.purgeInvisible = true;
151
+ opts.nukeControls = true;
152
+ }
153
+ return opts;
154
+ }
155
+
156
+ /** @param {string|undefined} value */
157
+ function parseMaxFindings(value) {
158
+ if (value === 'all') return Infinity;
159
+ const n = Number(value);
160
+ if (value === undefined || value === '' || !Number.isInteger(n) || n < 0) {
161
+ throw new UsageError(`--max-findings expects a non-negative integer or "all", got ${value === undefined ? 'nothing' : JSON.stringify(value)}.`);
162
+ }
163
+ return n;
164
+ }
165
+
166
+
167
+ // --- File collection ---
168
+
169
+ const GLOB_CHARS = /[*?[{]/;
170
+
171
+ /** @param {string} p */
172
+ const toSlash = (p) => p.split(sep).join('/');
173
+
174
+ /** @param {string} cwd */
175
+ function isGitWorkTree(cwd) {
176
+ const r = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd, encoding: 'utf8' });
177
+ return !r.error && r.status === 0 && r.stdout.trim() === 'true';
178
+ }
179
+
180
+ /**
181
+ * Lists tracked and untracked-but-not-ignored files under one pathspec.
182
+ * Returns null when git cannot answer (for example a path outside the work tree).
183
+ * @param {string} cwd
184
+ * @param {string} pathspec
185
+ * @returns {string[]|null}
186
+ */
187
+ function gitListFiles(cwd, pathspec) {
188
+ const r = spawnSync(
189
+ 'git',
190
+ ['ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', pathspec],
191
+ { cwd, encoding: 'utf8', maxBuffer: 1 << 28 }
192
+ );
193
+ if (r.error || r.status !== 0) return null;
194
+ // --cached lists tracked files deleted from the work tree; drop them
195
+ return r.stdout.split('\0').filter((f) => f && existsSync(resolve(cwd, f)));
196
+ }
197
+
198
+ /**
199
+ * Recursive file walk used outside git. Skips .git and node_modules.
200
+ * @param {string} cwd
201
+ * @param {string} dir - relative or absolute, as the user typed it
202
+ * @returns {string[]}
203
+ */
204
+ function walk(cwd, dir) {
205
+ const out = [];
206
+ const stack = [dir];
207
+ while (stack.length) {
208
+ const current = stack.pop();
209
+ let entries;
210
+ try {
211
+ entries = readdirSync(resolve(cwd, current), { withFileTypes: true });
212
+ } catch {
213
+ continue;
214
+ }
215
+ entries.sort((a, b) => (a.name < b.name ? 1 : a.name > b.name ? -1 : 0));
216
+ for (const e of entries) {
217
+ const p = current === '.' ? e.name : join(current, e.name);
218
+ if (e.isDirectory()) {
219
+ if (!WALK_SKIP_DIRS.has(e.name)) stack.push(p);
220
+ } else if (e.isFile()) {
221
+ out.push(toSlash(p));
222
+ }
223
+ }
224
+ }
225
+ return out.sort();
226
+ }
227
+
228
+ /**
229
+ * Glob to RegExp for the non-git fallback: `**`, `*`, `?`, `[...]` and `{a,b}`.
230
+ * @param {string} glob
231
+ */
232
+ function globToRegExp(glob) {
233
+ let re = '';
234
+ for (let i = 0; i < glob.length; i++) {
235
+ const c = glob[i];
236
+ if (c === '*') {
237
+ if (glob[i + 1] === '*') {
238
+ const slashAfter = glob[i + 2] === '/';
239
+ re += slashAfter ? '(?:.*/)?' : '.*';
240
+ i += slashAfter ? 2 : 1;
241
+ } else {
242
+ re += '[^/]*';
243
+ }
244
+ } else if (c === '?') {
245
+ re += '[^/]';
246
+ } else if (c === '[') {
247
+ const close = glob.indexOf(']', i + 1);
248
+ if (close === -1) { re += '\\['; continue; }
249
+ let body = glob.slice(i + 1, close).replace(/\\/g, '\\\\');
250
+ if (body[0] === '!') body = '^' + body.slice(1);
251
+ re += `[${body}]`;
252
+ i = close;
253
+ } else if (c === '{') {
254
+ const close = glob.indexOf('}', i + 1);
255
+ if (close === -1) { re += '\\{'; continue; }
256
+ const alts = glob.slice(i + 1, close).split(',').map((a) => a.replace(/[.+^$()|\\]/g, '\\$&'));
257
+ re += `(?:${alts.join('|')})`;
258
+ i = close;
259
+ } else {
260
+ re += c.replace(/[.+^$()|\\\]}]/g, '\\$&');
261
+ }
262
+ }
263
+ return new RegExp(`^${re}$`);
264
+ }
265
+
266
+ /**
267
+ * @param {string} cwd
268
+ * @param {string} glob
269
+ */
270
+ function walkGlob(cwd, glob) {
271
+ const pattern = toSlash(glob).replace(/^\.\//, '');
272
+ const segments = pattern.split('/');
273
+ const firstGlob = segments.findIndex((s) => GLOB_CHARS.test(s));
274
+ const base = segments.slice(0, firstGlob).join('/') || '.';
275
+ const re = globToRegExp(pattern);
276
+ return walk(cwd, base).filter((f) => re.test(f));
277
+ }
278
+
279
+ /**
280
+ * Agent instruction and configuration files, relative to the directory searched.
281
+ * Matched case-insensitively. Keep .pre-commit-hooks.yaml (the text-sanctifier-ai-config
282
+ * hook's `files`) in step; tests/cli.test.js checks both against the same paths.
283
+ */
284
+ const AI_CONFIG_GLOBS = [
285
+ '**/CLAUDE.md', '**/CLAUDE.local.md', '**/AGENTS.md', '**/AGENT.md', '**/GEMINI.md', '**/SKILL.md',
286
+ '.claude/**',
287
+ '.cursorrules', '.cursor/rules/**', '.cursor/mcp.json',
288
+ '.windsurfrules', '.windsurf/rules/**',
289
+ '.clinerules', '.clinerules/**',
290
+ '.roorules', '.roo/rules/**',
291
+ '.github/copilot-instructions.md', '.github/instructions/**', '.github/prompts/**',
292
+ '.github/chatmodes/**', '.github/agents/**',
293
+ '.mcp.json', 'mcp.json', '.vscode/mcp.json',
294
+ ];
295
+
296
+ const AI_CONFIG_RES = AI_CONFIG_GLOBS.map((g) => new RegExp(globToRegExp(g).source, 'i'));
297
+
298
+ /** @param {string} rel - slash-separated path relative to the directory searched */
299
+ const isAiConfigPath = (rel) => AI_CONFIG_RES.some((re) => re.test(rel));
300
+
301
+ /**
302
+ * Expands the path arguments into a de-duplicated input list. With `aiConfig`,
303
+ * directories contribute only agent instruction files (AI_CONFIG_GLOBS).
304
+ * @param {string[]} args
305
+ * @param {string} cwd
306
+ * @param {(msg: string) => void} warn
307
+ * @param {boolean} [aiConfig]
308
+ * @returns {{ inputs: Array<{ path: string, explicit: boolean }>, errors: number }}
309
+ */
310
+ function collectInputs(args, cwd, warn, aiConfig = false) {
311
+ const inputs = [];
312
+ const seen = new Set();
313
+ let errors = 0;
314
+ let inGit;
315
+
316
+ const add = (path, explicit) => {
317
+ const abs = resolve(cwd, path);
318
+ const key = path === STDIN ? STDIN : CASE_INSENSITIVE_FS ? abs.toLowerCase() : abs;
319
+ if (seen.has(key)) return;
320
+ seen.add(key);
321
+ inputs.push({ path, explicit });
322
+ };
323
+
324
+ for (const arg of args) {
325
+ if (arg === STDIN) { add(STDIN, true); continue; }
326
+
327
+ let stat = null;
328
+ try { stat = statSync(resolve(cwd, arg)); } catch { /* not a plain path */ }
329
+
330
+ if (stat && stat.isFile()) { add(arg, true); continue; }
331
+
332
+ const isGlob = !stat && GLOB_CHARS.test(arg);
333
+ if (!stat && !isGlob) {
334
+ warn(`${arg}: no such file or directory`);
335
+ errors++;
336
+ continue;
337
+ }
338
+
339
+ if (inGit === undefined) inGit = isGitWorkTree(cwd);
340
+ let listed =
341
+ (inGit && gitListFiles(cwd, isGlob ? `:(glob)${toSlash(arg)}` : toSlash(arg))) ||
342
+ (isGlob ? walkGlob(cwd, arg) : walk(cwd, arg));
343
+
344
+ if (aiConfig && !isGlob) {
345
+ const base = resolve(cwd, arg);
346
+ listed = listed.filter((f) => isAiConfigPath(toSlash(relative(base, resolve(cwd, f)))));
347
+ } else if (listed.length === 0) {
348
+ warn(`${arg}: no files matched`);
349
+ }
350
+ for (const f of listed) add(f, false);
351
+ }
352
+ return { inputs, errors };
353
+ }
354
+
355
+
356
+ // --- Reading ---
357
+
358
+ const utf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
359
+
360
+ /**
361
+ * Decodes UTF-8, keeping a leading BOM. Returns null for binary or non-UTF-8 data.
362
+ * @param {Uint8Array} bytes
363
+ * @returns {{ text: string|null, reason?: string }}
364
+ */
365
+ function decode(bytes) {
366
+ if (bytes.subarray(0, BINARY_SNIFF_BYTES).includes(0)) return { text: null, reason: 'binary' };
367
+ try {
368
+ return { text: utf8.decode(bytes) };
369
+ } catch {
370
+ return { text: null, reason: 'not UTF-8' };
371
+ }
372
+ }
373
+
374
+ /** @returns {Promise<Buffer>} */
375
+ function readStdin() {
376
+ return new Promise((res, rej) => {
377
+ const chunks = [];
378
+ process.stdin.on('data', (c) => chunks.push(c));
379
+ process.stdin.on('end', () => res(Buffer.concat(chunks)));
380
+ process.stdin.on('error', rej);
381
+ });
382
+ }
383
+
384
+
385
+ // --- check ---
386
+
387
+ /**
388
+ * inspectTextDetailed, minus a BOM at offset 0 unless stripBom.
389
+ * @param {string} text
390
+ * @param {number} maxFindings
391
+ * @param {boolean} stripBom
392
+ */
393
+ function inspect(text, maxFindings, stripBom) {
394
+ const leadingBom = !stripBom && text.charCodeAt(0) === BOM;
395
+ const report = inspectTextDetailed(text, { maxFindings: leadingBom ? maxFindings + 1 : maxFindings });
396
+ if (leadingBom) {
397
+ report.findings.shift();
398
+ report.counts.bom--;
399
+ report.total--;
400
+ report.truncated = report.total > report.findings.length;
401
+ }
402
+ return report;
403
+ }
404
+
405
+ /** @param {number} cp */
406
+ const hex = (cp) => 'U+' + cp.toString(16).toUpperCase().padStart(4, '0');
407
+
408
+ /**
409
+ * @param {Record<string, number>} counts
410
+ */
411
+ const formatCounts = (counts) =>
412
+ Object.entries(counts).filter(([, n]) => n > 0).map(([k, n]) => `${k} ${n}`).join(', ');
413
+
414
+ /**
415
+ * @param {number} n
416
+ * @param {string} word
417
+ */
418
+ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
419
+
420
+
421
+ // --- check --ai-config ---
422
+
423
+ const MAX_DECODED = 1000;
424
+ const RUN_NOUNS = { tag: 'tag character', variation_selector: 'variation selector', invisible_operator: 'invisible operator' };
425
+
426
+ /**
427
+ * inspect() for agent instruction files: drops findings that belong to legitimate
428
+ * sequences (subdivision flags, a lone emoji presentation selector) and adds the runs
429
+ * of tag characters, variation selectors and invisible operators, with tag text decoded.
430
+ * @param {string} text
431
+ * @param {number} maxFindings
432
+ * @param {boolean} stripBom
433
+ */
434
+ function inspectAiConfig(text, maxFindings, stripBom) {
435
+ const full = inspect(text, Infinity, stripBom);
436
+ const { runs, benign } = findHiddenRuns(text);
437
+
438
+ const kept = [];
439
+ let b = 0;
440
+ for (const f of full.findings) {
441
+ while (b < benign.length && benign[b][1] <= f.offset) b++;
442
+ if (b < benign.length && benign[b][0] <= f.offset) { full.counts[f.category]--; continue; }
443
+ kept.push(f);
444
+ }
445
+
446
+ // Every run character is a finding, so each run starts at a kept finding
447
+ const hiddenRuns = [];
448
+ const loose = [];
449
+ let r = 0;
450
+ for (const f of kept) {
451
+ while (r < runs.length && runs[r].end <= f.offset) r++;
452
+ const run = runs[r];
453
+ if (!run || f.offset < run.offset) { loose.push(f); continue; }
454
+ if (f.offset !== run.offset) continue;
455
+ const entry = { kind: run.kind, offset: run.offset, line: f.line, column: f.column, codePoint: f.codePoint, count: run.count };
456
+ if (run.decoded !== undefined) {
457
+ entry.decoded = run.decoded.slice(0, MAX_DECODED);
458
+ entry.decodedTruncated = run.decoded.length > MAX_DECODED;
459
+ }
460
+ hiddenRuns.push(entry);
461
+ }
462
+
463
+ const findings = kept.slice(0, maxFindings);
464
+ return {
465
+ report: {
466
+ ...full,
467
+ findings,
468
+ total: kept.length,
469
+ truncated: kept.length > findings.length,
470
+ hiddenRuns: hiddenRuns.slice(0, maxFindings),
471
+ hiddenRunCount: hiddenRuns.length,
472
+ },
473
+ hiddenRuns,
474
+ loose,
475
+ };
476
+ }
477
+
478
+ /** @param {{ kind: string, count: number, decoded?: string, decodedTruncated?: boolean }} run */
479
+ function describeRun(run) {
480
+ const what = plural(run.count, RUN_NOUNS[run.kind]);
481
+ if (run.decoded === undefined) return `run of ${what}`;
482
+ return `run of ${what} spelling ${JSON.stringify(run.decoded)}${run.decodedTruncated ? ' (cut short)' : ''}`;
483
+ }
484
+
485
+
486
+ // --- SARIF ---
487
+
488
+ // Level and description per category, for SARIF rules. Every category needs an entry;
489
+ // tests/cli.test.js checks the rules against inspectTextDetailed's counts.
490
+ const CATEGORY_RULES = {
491
+ bidi_control: ['error', 'Bidirectional control character, which can make text display in a different order than it is read (Trojan Source)'],
492
+ zero_width: ['warning', 'Zero-width character'],
493
+ nonbreaking_space: ['note', 'Non-breaking or typographic space that looks like an ordinary space'],
494
+ bom: ['warning', 'Byte order mark (U+FEFF) that is not at the start of the file'],
495
+ nul: ['warning', 'NUL character'],
496
+ c0_control: ['warning', 'C0 control character other than tab, LF and CR'],
497
+ c1_control: ['warning', 'C1 control character'],
498
+ invisible_format: ['warning', 'Invisible format character, such as a soft hyphen or an invisible operator'],
499
+ hangul_filler: ['warning', 'Hangul filler, a letter that renders blank'],
500
+ variation_selector: ['warning', 'Variation selector, which can carry hidden data'],
501
+ tag: ['error', 'Tag character, which can hide ASCII text that people cannot see but language models read'],
502
+ };
503
+
504
+ const SARIF_SCHEMA = 'https://json.schemastore.org/sarif-2.1.0.json';
505
+ const INFORMATION_URI = 'https://github.com/iWhatty/text-sanctifier';
506
+ const ABSOLUTE_PATH = /^(?:[a-zA-Z]:)?[\\/]/;
507
+
508
+ /** @param {string} label */
509
+ function sarifArtifact(label) {
510
+ if (label === STDIN_LABEL) return { uri: 'stdin', description: { text: 'standard input' } };
511
+ if (ABSOLUTE_PATH.test(label)) return { uri: pathToFileURL(label).href };
512
+ return { uri: encodeURI(toSlash(label)).replace(/[?#]/g, encodeURIComponent), uriBaseId: '%SRCROOT%' };
513
+ }
514
+
515
+ /**
516
+ * Builds a SARIF 2.1.0 log with one result per listed finding; the rule is the category.
517
+ * @param {Array<{ path: string, findings: Array<{ offset: number, line: number, column: number, codePoint: number, category: string }>, hiddenRuns?: Array<{ offset: number, kind: string, count: number, decoded?: string }>, truncated: boolean }>} files
518
+ * @param {{ cwd: string, total: number, filesScanned: number, skipped: Array<{ path: string, reason: string }>, succeeded: boolean }} info
519
+ */
520
+ function sarifLog(files, { cwd, total, filesScanned, skipped, succeeded }) {
521
+ const ruleIds = Object.keys(inspectTextDetailed('', { maxFindings: 0 }).counts);
522
+ const rules = ruleIds.map((id) => ({
523
+ id,
524
+ shortDescription: { text: CATEGORY_RULES[id][1] },
525
+ defaultConfiguration: { level: CATEGORY_RULES[id][0] },
526
+ }));
527
+
528
+ const results = [];
529
+ for (const f of files) {
530
+ const runAt = new Map((f.hiddenRuns || []).map((run) => [run.offset, run]));
531
+ const artifactLocation = sarifArtifact(f.path);
532
+ for (const x of f.findings) {
533
+ const run = runAt.get(x.offset);
534
+ results.push({
535
+ ruleId: x.category,
536
+ ruleIndex: ruleIds.indexOf(x.category),
537
+ level: CATEGORY_RULES[x.category][0],
538
+ message: { text: `${hex(x.codePoint)} ${x.category}${run ? `, start of a ${describeRun(run)}` : ''}` },
539
+ locations: [{
540
+ physicalLocation: {
541
+ artifactLocation,
542
+ region: { startLine: x.line, startColumn: x.column, endColumn: x.column + (x.codePoint > 0xFFFF ? 2 : 1) },
543
+ },
544
+ }],
545
+ });
546
+ }
547
+ }
548
+
549
+ return {
550
+ $schema: SARIF_SCHEMA,
551
+ version: '2.1.0',
552
+ runs: [{
553
+ tool: { driver: { name: 'text-sanctifier', version: VERSION, semanticVersion: VERSION, informationUri: INFORMATION_URI, rules } },
554
+ originalUriBaseIds: { '%SRCROOT%': { uri: pathToFileURL(cwd.endsWith(sep) ? cwd : cwd + sep).href } },
555
+ columnKind: 'utf16CodeUnits',
556
+ invocations: [{ executionSuccessful: succeeded }],
557
+ results,
558
+ properties: {
559
+ total,
560
+ filesScanned,
561
+ filesWithFindings: files.length,
562
+ truncated: files.filter((f) => f.truncated).map((f) => f.path),
563
+ skipped,
564
+ },
565
+ }],
566
+ };
567
+ }
568
+
569
+
570
+ // --- Main ---
571
+
572
+ /**
573
+ * @param {string[]} argv
574
+ * @returns {Promise<number>} exit code
575
+ */
576
+ async function main(argv) {
577
+ const out = (s) => process.stdout.write(s + '\n');
578
+ const err = (s) => process.stderr.write(s + '\n');
579
+
580
+ let opts;
581
+ try {
582
+ opts = parseArgs(argv);
583
+ } catch (e) {
584
+ if (!(e instanceof UsageError)) throw e;
585
+ err(`text-sanctifier: ${e.message}\nRun "text-sanctifier --help" for usage.`);
586
+ return 2;
587
+ }
588
+ if (opts.help) { out(USAGE); return 0; }
589
+ if (opts.version) { out(VERSION); return 0; }
590
+
591
+ const cwd = process.cwd();
592
+ let paths = opts.paths;
593
+ if (paths.length === 0 && opts.aiConfig) {
594
+ paths = ['.'];
595
+ } else if (paths.length === 0) {
596
+ if (process.stdin.isTTY) {
597
+ err('text-sanctifier: no paths given and nothing piped on stdin.\nRun "text-sanctifier --help" for usage.');
598
+ return 2;
599
+ }
600
+ paths = [STDIN];
601
+ }
602
+
603
+ const warn = (msg) => err(`text-sanctifier: ${msg}`);
604
+ const { inputs, errors: pathErrors } = collectInputs(paths, cwd, warn, opts.aiConfig);
605
+ let errors = pathErrors;
606
+ /** @type {Array<{ path: string, reason: string }>} */
607
+ const skipped = [];
608
+
609
+ /**
610
+ * Reads every input in order, calling `visit(label, text, input)` for each decodable one.
611
+ * @param {(label: string, text: string, input: { path: string, explicit: boolean }) => void} visit
612
+ */
613
+ const forEachText = async (visit) => {
614
+ for (const input of inputs) {
615
+ const label = input.path === STDIN ? STDIN_LABEL : input.path;
616
+ let bytes;
617
+ try {
618
+ bytes = input.path === STDIN ? await readStdin() : readFileSync(resolve(cwd, input.path));
619
+ } catch (e) {
620
+ warn(`${label}: ${e.code || e.message}`);
621
+ errors++;
622
+ continue;
623
+ }
624
+ const { text, reason } = decode(bytes);
625
+ if (text === null) {
626
+ skipped.push({ path: label, reason });
627
+ if (input.explicit) warn(`${label}: skipped (${reason})`);
628
+ continue;
629
+ }
630
+ visit(label, text, input);
631
+ }
632
+ };
633
+
634
+ if (opts.command === 'check') {
635
+ const files = [];
636
+ let scanned = 0;
637
+ let total = 0;
638
+
639
+ let hiddenRunCount = 0;
640
+ /** @type {string[]} */
641
+ const lines = [];
642
+ const findingLine = (label, x) => `${label}:${x.line}:${x.column} ${hex(x.codePoint)} ${x.category}`;
643
+
644
+ await forEachText((label, text) => {
645
+ scanned++;
646
+ if (!opts.aiConfig) {
647
+ const report = inspect(text, opts.maxFindings, opts.stripBom);
648
+ total += report.total;
649
+ if (report.total === 0) return;
650
+ files.push({ path: label, ...report });
651
+ for (const x of report.findings) lines.push(findingLine(label, x));
652
+ if (report.truncated) lines.push(`${label}: ${report.total - report.findings.length} more not listed (--max-findings)`);
653
+ return;
654
+ }
655
+
656
+ const { report, hiddenRuns, loose } = inspectAiConfig(text, opts.maxFindings, opts.stripBom);
657
+ total += report.total;
658
+ hiddenRunCount += report.hiddenRunCount;
659
+ if (report.total === 0) return;
660
+ files.push({ path: label, ...report });
661
+ // Text output lists each run once, in place of its characters
662
+ const entries = [
663
+ ...hiddenRuns.map((run) => ({ offset: run.offset, text: `${label}:${run.line}:${run.column} ${hex(run.codePoint)} ${describeRun(run)}` })),
664
+ ...loose.map((x) => ({ offset: x.offset, text: findingLine(label, x) })),
665
+ ].sort((a, b) => a.offset - b.offset);
666
+ for (const e of entries.slice(0, opts.maxFindings)) lines.push(e.text);
667
+ if (entries.length > opts.maxFindings) lines.push(`${label}: ${entries.length - opts.maxFindings} more not listed (--max-findings)`);
668
+ });
669
+
670
+ const kind = opts.aiConfig ? 'AI configuration file' : 'file';
671
+ if (opts.format === 'json') {
672
+ const summary = { files, total, filesScanned: scanned, filesWithFindings: files.length, skipped };
673
+ if (opts.aiConfig) summary.hiddenRunCount = hiddenRunCount;
674
+ out(JSON.stringify(summary, null, 2));
675
+ } else if (opts.format === 'sarif') {
676
+ out(JSON.stringify(sarifLog(files, { cwd, total, filesScanned: scanned, skipped, succeeded: errors === 0 }), null, 2));
677
+ } else {
678
+ for (const line of lines) out(line);
679
+ if (opts.aiConfig && scanned === 0 && skipped.length === 0) out('No AI configuration files found.');
680
+ else if (total === 0) out(`No ${opts.aiConfig ? 'hidden text, ' : ''}invisible or control characters in ${plural(scanned, kind)}.`);
681
+ else out(`${plural(total, 'finding')}${opts.aiConfig ? ` (${plural(hiddenRunCount, 'hidden run')})` : ''} in ${files.length} of ${plural(scanned, kind)}.`);
682
+ }
683
+ return errors > 0 ? 2 : total > 0 ? 1 : 0;
684
+ }
685
+
686
+ // fix
687
+ const clean = summonSanctifier({
688
+ purgeInvisibleChars: opts.purgeInvisible,
689
+ nukeControls: opts.nukeControls,
690
+ normalizeNewlines: opts.normalizeNewlines,
691
+ });
692
+ const writesStdout = !opts.dryRun && inputs.some((i) => i.path === STDIN);
693
+ const report = writesStdout ? err : out;
694
+ const files = [];
695
+ let scanned = 0;
696
+
697
+ await forEachText((label, text, input) => {
698
+ scanned++;
699
+ // The BOM at offset 0 is set aside so purgeInvisibleChars cannot take it
700
+ const leadingBom = text.charCodeAt(0) === BOM;
701
+ let cleaned = clean(leadingBom ? text.slice(1) : text);
702
+ if (leadingBom && !opts.stripBom) cleaned = String.fromCharCode(BOM) + cleaned;
703
+
704
+ if (input.path === STDIN && !opts.dryRun) process.stdout.write(cleaned);
705
+ if (cleaned === text) return;
706
+
707
+ const before = inspectTextDetailed(text, { maxFindings: 0 }).counts;
708
+ const after = inspectTextDetailed(cleaned, { maxFindings: 0 }).counts;
709
+ const removed = {};
710
+ let removedTotal = 0;
711
+ for (const k of Object.keys(before)) {
712
+ const n = before[k] - (after[k] || 0);
713
+ if (n > 0) { removed[k] = n; removedTotal += n; }
714
+ }
715
+ const nl = getNewlineStats(text);
716
+ const newlinesNormalized = opts.normalizeNewlines && nl.crlf + nl.cr > 0;
717
+
718
+ if (input.path !== STDIN && !opts.dryRun) {
719
+ try {
720
+ writeFileSync(resolve(cwd, input.path), cleaned, 'utf8');
721
+ } catch (e) {
722
+ warn(`${label}: ${e.code || e.message}`);
723
+ errors++;
724
+ return;
725
+ }
726
+ }
727
+ files.push({ path: label, removed, removedTotal, newlinesNormalized });
728
+ });
729
+
730
+ if (opts.format === 'json') {
731
+ report(JSON.stringify({ files, dryRun: opts.dryRun, filesScanned: scanned, filesChanged: files.length, skipped }, null, 2));
732
+ } else {
733
+ const verb = opts.dryRun ? 'would fix' : 'fixed';
734
+ for (const f of files) {
735
+ const parts = [];
736
+ if (f.removedTotal > 0) parts.push(`removed ${f.removedTotal} (${formatCounts(f.removed)})`);
737
+ if (f.newlinesNormalized) parts.push('normalized newlines');
738
+ report(`${verb} ${f.path}: ${parts.join('; ')}`);
739
+ }
740
+ report(files.length === 0
741
+ ? `Nothing to fix in ${scanned} file${scanned === 1 ? '' : 's'}.`
742
+ : `${opts.dryRun ? 'Would change' : 'Changed'} ${files.length} of ${scanned} file${scanned === 1 ? '' : 's'}.`);
743
+ }
744
+ return errors > 0 ? 2 : opts.dryRun && files.length > 0 ? 1 : 0;
745
+ }
746
+
747
+ main(process.argv.slice(2)).then(
748
+ (code) => { process.exitCode = code; },
749
+ (e) => {
750
+ process.stderr.write(`text-sanctifier: ${e && e.stack ? e.stack : e}\n`);
751
+ process.exitCode = 2;
752
+ }
753
+ );