text-sanctifier 1.1.1 → 1.3.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 CHANGED
@@ -7,13 +7,15 @@
7
7
  [![stars](https://img.shields.io/github/stars/iWhatty/text-sanctifier?style=social)](https://github.com/iWhatty/text-sanctifier)
8
8
  [![types](https://img.shields.io/npm/types/text-sanctifier)](https://www.npmjs.com/package/text-sanctifier)
9
9
 
10
- Brutal text normalizer and invisible Unicode scrubber for modern web projects. ~2.17 KB gzipped.
10
+ Brutal text normalizer and invisible Unicode scrubber for modern web projects. ~2.40 KB gzipped.
11
11
 
12
12
  ## Features
13
13
 
14
14
  - Purges zero-width Unicode garbage
15
15
  - Purges bidi controls, including the isolates (U+2066–U+2069) and the Arabic letter mark (U+061C) used in Trojan Source attacks
16
+ - Purges Unicode tag characters (U+E0000–U+E007F), the "ASCII smuggling" channel for hiding instructions from humans in text an LLM reads, plus variation selectors, Hangul fillers, soft hyphens and invisible operators
16
17
  - Locates invisible and control characters by offset, line and column (`inspectTextDetailed`)
18
+ - `text-sanctifier check` / `fix` command line for CI and coding agents, with JSON output and exit codes
17
19
  - Normalizes line endings (CRLF, CR, LF) to LF
18
20
  - Collapses unwanted spaces and paragraphs
19
21
  - Nukes control characters (if enabled)
@@ -154,7 +156,8 @@ findings: [
154
156
  { offset: 0, line: 1, column: 1, codePoint: 0xFEFF, category: 'bom', atStart: true },
155
157
  { offset: 42, line: 3, column: 7, codePoint: 0x2067, category: 'bidi_control' },
156
158
  ]
157
- counts: { bidi_control: 1, zero_width: 0, nonbreaking_space: 0, bom: 1, nul: 0, c0_control: 0, c1_control: 0 }
159
+ counts: { bidi_control: 1, zero_width: 0, nonbreaking_space: 0, bom: 1, nul: 0, c0_control: 0, c1_control: 0,
160
+ invisible_format: 0, hangul_filler: 0, variation_selector: 0, tag: 0 }
158
161
  total: 2
159
162
  truncated: false
160
163
  */
@@ -169,15 +172,73 @@ truncated: false
169
172
  | `nul` | U+0000 |
170
173
  | `c0_control` | U+0001–U+001F except tab, LF and CR; U+007F |
171
174
  | `c1_control` | U+0080–U+009F |
175
+ | `invisible_format` | U+00AD (soft hyphen), U+034F, U+180E, U+2061–U+2064 (invisible operators) |
176
+ | `hangul_filler` | U+115F, U+1160, U+3164, U+FFA0 (letters that render blank) |
177
+ | `variation_selector` | U+FE00–U+FE0F, U+E0100–U+E01EF |
178
+ | `tag` | U+E0000–U+E007F (tag characters, used for "ASCII smuggling" of hidden instructions) |
172
179
 
173
- `offset` is a 0-based UTF-16 index; `line` and `column` are 1-based, with columns in UTF-16 code units and lines ending at LF, CR or CRLF. Every occurrence is counted; only the first `maxFindings` (default 100, `Infinity` for all) are listed, and `truncated` says when some were not. The scan is one pass with no regex and no copy of the input.
180
+ `codePoint` is the full code point, so a tag character reports `0xE0041`, not a surrogate. `offset` is a 0-based UTF-16 index (for a code point above U+FFFF, that of its high surrogate); `line` and `column` are 1-based, with columns in UTF-16 code units and lines ending at LF, CR or CRLF. Every occurrence is counted; only the first `maxFindings` (default 100, `Infinity` for all) are listed, and `truncated` says when some were not. The scan is one pass with no regex and no copy of the input.
174
181
 
175
- `zero_width` also flags U+200D (zero width joiner) inside emoji ZWJ sequences such as 👨‍👩‍👧, so a finding there is not necessarily hidden text. Filter by neighbouring code points if you need to tell them apart. (`purgeInvisibleChars` removes those joiners too, splitting the emoji; `keyboardOnlyEmoji` keeps the sequences whole.)
182
+ `zero_width` also flags U+200D (zero width joiner) inside emoji ZWJ sequences such as 👨‍👩‍👧, so a finding there is not necessarily hidden text. Filter by neighbouring code points if you need to tell them apart. (`purgeInvisibleChars` removes those joiners too, splitting the emoji; `keyboardOnlyEmoji` keeps the sequences whole.) The same holds for `variation_selector`, which flags VS16 (U+FE0F) in emoji such as ✌️ and the ideographic variation selectors in CJK names, and for `tag`, which flags the tag characters inside subdivision flags such as the England flag (U+1F3F4 followed by tags). `purgeInvisibleChars` strips them all; `keyboardOnlyEmoji` keeps VS16.
176
183
 
177
184
  The categories cover exactly what `nukeControls` and `purgeInvisibleChars` strip.
178
185
 
179
186
  ---
180
187
 
188
+ ## Command line
189
+
190
+ The package ships a `text-sanctifier` command for CI and coding agents, which cannot see invisible characters in their own tool output. It runs on Node 18+ and has no dependencies.
191
+
192
+ ```sh
193
+ npx text-sanctifier check src docs "**/*.md"
194
+ npx text-sanctifier fix --dry-run src
195
+ ```
196
+
197
+ ### `check [paths...]`
198
+
199
+ Reports every invisible or control character as `path:line:column U+XXXX category`, using the categories of `inspectTextDetailed`:
200
+
201
+ ```
202
+ docs/intro.md:12:7 U+200B zero_width
203
+ src/auth.js:40:3 U+202E bidi_control
204
+ 2 findings in 2 of 31 files.
205
+ ```
206
+
207
+ - `--json`: one JSON object on stdout, `{ files, total, filesScanned, filesWithFindings, skipped }`. Each entry in `files` is `{ path, ...inspectTextDetailed(text) }`; only files with findings are listed.
208
+ - `--max-findings N`: findings listed per file (default 100; `all` for no cap). Counts always cover every occurrence.
209
+ - `--strip-bom`: also report a BOM at offset 0, which is skipped by default.
210
+
211
+ ### `fix [paths...]`
212
+
213
+ Rewrites files in place and reports what it removed.
214
+
215
+ - `--purge-invisible`: `purgeInvisibleChars`
216
+ - `--nuke-controls`: `nukeControls`
217
+ - `--normalize-newlines`: `normalizeNewlines` (CRLF and CR to LF)
218
+ - With none of the three, `fix` uses `--purge-invisible --nuke-controls`, which removes exactly what `check` reports.
219
+ - `--strip-bom`: also remove a BOM at offset 0. It is kept by default.
220
+ - `--dry-run`: write nothing; report what would change.
221
+ - `--json`: `{ files, dryRun, filesScanned, filesChanged, skipped }`, where each file is `{ path, removed, removedTotal, newlinesNormalized }`.
222
+
223
+ `fix` deletes characters; it does not replace them. A non-breaking space between two words joins them, and emoji lose their zero width joiners and variation selectors. Run `check` or `fix --dry-run` first on prose.
224
+
225
+ ### Inputs
226
+
227
+ - Paths can be files, directories or globs (`*`, `**`, `?`, `[...]`, `{a,b}`). Quote globs so the shell passes them through.
228
+ - Inside a git work tree, directories and globs list files with `git ls-files`, so anything `.gitignore` excludes is skipped. Outside git, `.git` and `node_modules` are skipped. A file named explicitly is always read.
229
+ - Binary files (a NUL byte in the first 8000 bytes) and files that are not valid UTF-8 are skipped, and `fix` never writes them. In JSON they appear under `skipped`.
230
+ - `-`, or no paths with piped input, reads stdin. `fix` writes the cleaned text to stdout and the report to stderr.
231
+
232
+ ### Exit codes
233
+
234
+ | Code | `check` | `fix` |
235
+ | --- | --- | --- |
236
+ | 0 | nothing found | done (or nothing to do) |
237
+ | 1 | findings | `--dry-run` with changes pending |
238
+ | 2 | usage error, missing path or unreadable file | same |
239
+
240
+ ---
241
+
181
242
  ## Notes
182
243
 
183
244
  ### Runtime requirements
@@ -0,0 +1,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 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,12 +1,14 @@
1
- function n(a={}){const c=!!a.purgeInvisibleChars,d=!!a.purgeEmojis,f=!!a.nukeControls,g=!!a.keyboardOnlyFilter,l=!!a.normalizeNewlines,h=!!a.trimSpacesAroundNewlines,b=!!a.collapseNewLines,e=!!a.preserveParagraphs,k=!!a.collapseSpaces,m=!!a.finalTrim,p=Number.isFinite(a.maxLength)&&0<=a.maxLength?a.maxLength:Infinity,q="truncate"===a.g||"noop"===a.g||"throw"===a.g?a.g:"throw";return z=>r(z,c,d,f,g,l,h,b,e,k,m,p,q)}n.strict=a=>r(a,!0,!0,!0,!1,!0,!0,!0,!1,!0,!0);
1
+ function n(a={}){const c=!!a.purgeInvisibleChars,e=!!a.purgeEmojis,f=!!a.nukeControls,h=!!a.keyboardOnlyFilter,m=!!a.normalizeNewlines,l=!!a.trimSpacesAroundNewlines,b=!!a.collapseNewLines,d=!!a.preserveParagraphs,g=!!a.collapseSpaces,p=!!a.finalTrim,k=Number.isFinite(a.maxLength)&&0<=a.maxLength?a.maxLength:Infinity,q="truncate"===a.g||"noop"===a.g||"throw"===a.g?a.g:"throw";return z=>r(z,c,e,f,h,m,l,b,d,g,p,k,q)}n.strict=a=>r(a,!0,!0,!0,!1,!0,!0,!0,!1,!0,!0);
2
2
  n.loose=a=>r(a,!1,!1,!1,!1,!0,!0,!0,!0,!0,!0);n.keyboardOnlyEmoji=a=>r(a,!1,!1,!1,!0,!0,!0,!1,!1,!1,!0);n.keyboardOnly=a=>r(a,!0,!0,!0,!0,!0,!0,!0,!1,!0,!0);
3
- function r(a,c=!1,d=!1,f=!1,g=!1,l=!1,h=!1,b=!1,e=!1,k=!1,m=!1,p=Infinity,q="throw"){if("string"!==typeof a)throw new TypeError("sanctifyText expects a string input.");"truncate"!==q&&"noop"!==q&&"throw"!==q&&(q="throw");if(a.length>p)switch(q){case "truncate":a=a.slice(0,p);break;case "noop":return a;default:throw new RangeError(`sanctifyText input length ${a.length} exceeds maxLength ${p}.`);}c&&(a=a.replace(t,""));d&&(a=a.replace(u,""));f&&(a=a.replace(v,""));g&&(a=w(a,d));l&&(a=a.replace(x,"\n"));
4
- h&&(a=a.replace(y,"$1"));b&&(c=a,a=e?c.replace(A,"\n\n"):c.replace(B,"\n"));k&&(a=a.replace(C," "));return m?a.trim():a}var t=/[\u00A0\u2000-\u200D\u202F\u2060\u3000\uFEFF\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]+/g,D=/[^\x20-\x7E\r\n]+/gu;function w(a,c=!1){a=E(a);return c?a.replace(D,""):a.replace(D,d=>{u.lastIndex=0;if(u.test(d)){u.lastIndex=0;var f="";for(const g of d.matchAll(u))f+=g[0];d=f}else d="";return d})}
5
- var F=/[\u2018\u2019\u201A\u201B\u2032\u2035]/g,G=/[\u201C\u201D\u201E\u201F\u2033\u2036\u00AB\u00BB]/g,H=/[\u2012\u2013\u2014\u2015\u2212]/g,I=/\u2026/g,J=/[\u2022\u00B7]/g,K=/[\uFF01-\uFF5E]/g;function E(a){return a.replace(F,"'").replace(G,'"').replace(H,"-").replace(I,"...").replace(J,"*").replace(K,c=>String.fromCharCode(c.charCodeAt(0)-65248))}var u;try{u=RegExp("(?:\\p{Extended_Pictographic}(?:\\uFE0F|\\uFE0E)?(?:\\u200D(?:\\p{Extended_Pictographic}|\\w)+)*)","gu")}catch{u=/[\u{2700}-\u{27BF}\u{1F300}-\u{1FAFF}\u{1F3FB}-\u{1F3FF}\u200D\uFE0F\u{1F1E6}-\u{1F1FF}]/gu}
6
- var x=/\r\n|\r|\n/g,y=/[ \t]*(\n+)[ \t]*/g,B=/\n{2,}/g,A=/\n{3,}/g,C=/ {2,}/g,v=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F\u0080-\u009F\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]+/g;function L(a,c){a.lastIndex=0;return a.test(c)}
7
- function M(a){if("string"!==typeof a)throw new TypeError("inspectText expects a string input.");const c=[],d={hasControlChars:!1,hasInvisibleChars:!1,hasMixedNewlines:!1,newlineStyle:null,hasEmojis:!1,hasNonKeyboardChars:!1,summary:c};L(v,a)&&(d.hasControlChars=!0,c.push("Control characters detected."));L(t,a)&&(d.hasInvisibleChars=!0,c.push("Invisible Unicode characters detected."));L(u,a)&&(d.hasEmojis=!0,c.push("Emojis detected."));if("string"!==typeof a)throw new TypeError("getNewlineStats expects a string input.");
8
- var f=0,g=0;let l=0;for(let k=0,m=a.length;k<m;k++){const p=a.charCodeAt(k);13===p?10===a.charCodeAt(k+1)?(f++,k++):g++:10===p&&l++}f={i:f,h:g,j:l};g=[];0<f.i&&g.push("CRLF");0<f.h&&g.push("CR");0<f.j&&g.push("LF");const {l:h,types:b}={...f,types:g,l:1<g.length};d.hasMixedNewlines=h;d.newlineStyle=h?"Mixed":b[0]||null;d.newlineStyle&&c.push(h?"Mixed newline styles detected.":`Consistent newline style: ${d.newlineStyle}`);a:{a=E(a);if(L(D,a))for(e of a)if(!("\n"===e||"\r"===e||" "<=e&&"~">=e||L(u,
9
- e))){var e=!0;break a}e=!1}e&&(d.hasNonKeyboardChars=!0,c.push("Non-keyboard characters detected."));return d}
10
- function N(a,c={}){if("string"!==typeof a)throw new TypeError("inspectTextDetailed expects a string input.");c=c&&c.maxFindings;c=Infinity===c||Number.isInteger(c)&&0<=c?c:100;const d={bidi_control:0,zero_width:0,nonbreaking_space:0,bom:0,nul:0,c0_control:0,c1_control:0},f=[];let g=0,l=1,h=0;for(let e=0,k=a.length;e<k;e++){var b=a.charCodeAt(e);if(32<=b&&127>b)continue;if(10===b){l++;h=e+1;continue}if(13===b){10!==a.charCodeAt(e+1)&&(l++,h=e+1);continue}const m=128>b?0===b?"nul":32>b?9===b||10===
11
- b||13===b?null:"c0_control":127===b?"c0_control":null:159>=b?"c1_control":160===b?"nonbreaking_space":1564===b?"bidi_control":8192>b?null:8202>=b?"nonbreaking_space":8205>=b?"zero_width":8207>=b||8234<=b&&8238>=b?"bidi_control":8239===b?"nonbreaking_space":8288===b?"zero_width":8294<=b&&8297>=b?"bidi_control":12288===b?"nonbreaking_space":65279===b?"bom":null;null!==m&&(d[m]++,g++,f.length<c&&(b={offset:e,line:l,column:e-h+1,codePoint:b,category:m},"bom"===m&&(b.atStart=0===e),f.push(b)))}return{findings:f,
12
- counts:d,total:g,truncated:g>f.length}}function O(a){return{purgeInvisibleChars:a.hasInvisibleChars,purgeEmojis:a.hasEmojis,nukeControls:a.hasControlChars,keyboardOnlyFilter:a.hasNonKeyboardChars,normalizeNewlines:a.hasMixedNewlines||"CRLF"===a.newlineStyle||"CR"===a.newlineStyle}}export { n as summonSanctifier, M as inspectText, O as getRecommendedSanctifierOptions, N as inspectTextDetailed };
3
+ function r(a,c=!1,e=!1,f=!1,h=!1,m=!1,l=!1,b=!1,d=!1,g=!1,p=!1,k=Infinity,q="throw"){if("string"!==typeof a)throw new TypeError("sanctifyText expects a string input.");"truncate"!==q&&"noop"!==q&&"throw"!==q&&(q="throw");if(a.length>k)switch(q){case "truncate":a=a.slice(0,k);break;case "noop":return a;default:throw new RangeError(`sanctifyText input length ${a.length} exceeds maxLength ${k}.`);}c&&(a=a.replace(t,""));e&&(a=a.replace(u,""));f&&(a=a.replace(v,""));h&&(a=w(a,e));m&&(a=a.replace(x,"\n"));
4
+ l&&(a=a.replace(y,"$1"));b&&(c=a,a=d?c.replace(A,"\n\n"):c.replace(B,"\n"));g&&(a=a.replace(C," "));return p?a.trim():a}var t=/[\u00a0\u00ad\u034f\u061c\u115f\u1160\u180e\u2000-\u200f\u202a-\u202f\u2060-\u2064\u2066-\u2069\u3000\u3164\ufe00-\ufe0f\ufeff\uffa0\u{E0000}-\u{E007F}\u{E0100}-\u{E01EF}]+/gu,D=/[^\x20-\x7E\r\n]+/gu;
5
+ function w(a,c=!1){a=E(a);return c?a.replace(D,""):a.replace(D,e=>{u.lastIndex=0;if(u.test(e)){u.lastIndex=0;var f="";for(const h of e.matchAll(u))f+=h[0];e=f}else e="";return e})}var F=/[\u2018\u2019\u201A\u201B\u2032\u2035]/g,G=/[\u201C\u201D\u201E\u201F\u2033\u2036\u00AB\u00BB]/g,H=/[\u2012\u2013\u2014\u2015\u2212]/g,I=/\u2026/g,J=/[\u2022\u00B7]/g,K=/[\uFF01-\uFF5E]/g;
6
+ function E(a){return a.replace(F,"'").replace(G,'"').replace(H,"-").replace(I,"...").replace(J,"*").replace(K,c=>String.fromCharCode(c.charCodeAt(0)-65248))}var u;try{u=RegExp("(?:\\p{Extended_Pictographic}(?:\\uFE0F|\\uFE0E)?(?:\\u200D(?:\\p{Extended_Pictographic}|\\w)+)*)","gu")}catch{u=/[\u{2700}-\u{27BF}\u{1F300}-\u{1FAFF}\u{1F3FB}-\u{1F3FF}\u200D\uFE0F\u{1F1E6}-\u{1F1FF}]/gu}var x=/\r\n|\r|\n/g,y=/[ \t]*(\n+)[ \t]*/g,B=/\n{2,}/g,A=/\n{3,}/g,C=/ {2,}/g,v=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F\u0080-\u009F\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]+/g;
7
+ function L(a,c){a.lastIndex=0;return a.test(c)}
8
+ function M(a){if("string"!==typeof a)throw new TypeError("inspectText expects a string input.");const c=[],e={hasControlChars:!1,hasInvisibleChars:!1,hasMixedNewlines:!1,newlineStyle:null,hasEmojis:!1,hasNonKeyboardChars:!1,summary:c};L(v,a)&&(e.hasControlChars=!0,c.push("Control characters detected."));L(t,a)&&(e.hasInvisibleChars=!0,c.push("Invisible Unicode characters detected."));L(u,a)&&(e.hasEmojis=!0,c.push("Emojis detected."));if("string"!==typeof a)throw new TypeError("getNewlineStats expects a string input.");var f=
9
+ 0,h=0;let m=0;for(let g=0,p=a.length;g<p;g++){const k=a.charCodeAt(g);13===k?10===a.charCodeAt(g+1)?(f++,g++):h++:10===k&&m++}f={i:f,h,j:m};h=[];0<f.i&&h.push("CRLF");0<f.h&&h.push("CR");0<f.j&&h.push("LF");const {l,types:b}={...f,types:h,l:1<h.length};e.hasMixedNewlines=l;e.newlineStyle=l?"Mixed":b[0]||null;e.newlineStyle&&c.push(l?"Mixed newline styles detected.":`Consistent newline style: ${e.newlineStyle}`);a:{a=E(a);if(L(D,a))for(d of a)if(!("\n"===d||"\r"===d||" "<=d&&"~">=d||L(u,d))){var d=
10
+ !0;break a}d=!1}d&&(e.hasNonKeyboardChars=!0,c.push("Non-keyboard characters detected."));return e}
11
+ function N(a,c={}){if("string"!==typeof a)throw new TypeError("inspectTextDetailed expects a string input.");c=c&&c.maxFindings;c=Infinity===c||Number.isInteger(c)&&0<=c?c:100;const e={bidi_control:0,zero_width:0,nonbreaking_space:0,bom:0,nul:0,c0_control:0,c1_control:0,invisible_format:0,hangul_filler:0,variation_selector:0,tag:0},f=[];let h=0,m=1,l=0;for(let g=0,p=a.length;g<p;g++){var b=a.charCodeAt(g);if(32<=b&&127>b)continue;if(10===b){m++;l=g+1;continue}if(13===b){10!==a.charCodeAt(g+1)&&(m++,
12
+ l=g+1);continue}const k=g;var d=b;if(56128===b){d=a.charCodeAt(g+1);if(!(56320<=d&&57343>=d))continue;d=65536+(b-55296<<10)+(d-56320);b=917504<=d&&917631>=d?"tag":917760<=d&&917999>=d?"variation_selector":null;g++}else b=128>b?0===b?"nul":32>b?9===b||10===b||13===b?null:"c0_control":127===b?"c0_control":null:159>=b?"c1_control":160===b?"nonbreaking_space":173===b||847===b||6158===b?"invisible_format":1564===b?"bidi_control":4447===b||4448===b?"hangul_filler":8192>b?null:8202>=b?"nonbreaking_space":
13
+ 8205>=b?"zero_width":8207>=b||8234<=b&&8238>=b?"bidi_control":8239===b?"nonbreaking_space":8288===b?"zero_width":8289<=b&&8292>=b?"invisible_format":8294<=b&&8297>=b?"bidi_control":12288===b?"nonbreaking_space":12644===b||65440===b?"hangul_filler":65024<=b&&65039>=b?"variation_selector":65279===b?"bom":null;null!==b&&(e[b]++,h++,f.length<c&&(d={offset:k,line:m,column:k-l+1,codePoint:d,category:b},"bom"===b&&(d.atStart=0===k),f.push(d)))}return{findings:f,counts:e,total:h,truncated:h>f.length}}
14
+ function O(a){return{purgeInvisibleChars:a.hasInvisibleChars,purgeEmojis:a.hasEmojis,nukeControls:a.hasControlChars,keyboardOnlyFilter:a.hasNonKeyboardChars,normalizeNewlines:a.hasMixedNewlines||"CRLF"===a.newlineStyle||"CR"===a.newlineStyle}}export { n as summonSanctifier, M as inspectText, O as getRecommendedSanctifierOptions, N as inspectTextDetailed };