text-sanctifier 1.3.11 → 1.3.12

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
@@ -317,7 +317,8 @@ src/auth.js:40:3 U+202E bidi_control
317
317
  2 findings in 2 of 31 files.
318
318
  ```
319
319
 
320
- - `--json` (or `--format json`): one JSON object on stdout, `{ files, total, filesScanned, filesWithFindings, skipped, notUtf8 }`. Each entry in `files` is `{ path, ...inspectTextDetailed(text) }`; only files with findings are listed.
320
+ - `--json` (or `--format json`): one JSON object on stdout, `{ files, total, filesScanned, filesWithFindings, skipped, excluded, notUtf8, errors }`. Each entry in `files` is `{ path, ...inspectTextDetailed(text) }`; only files with findings are listed. Usage errors also produce JSON on stdout.
321
+ - `--exclude GLOB`: exclude matching paths; repeatable. See [Inputs](#inputs). Not available with `--ai-config`.
321
322
  - `--format sarif`: a [SARIF 2.1.0](https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html) log for code-scanning viewers and editors. See [SARIF output](#sarif-output).
322
323
  - `--max-findings N`: findings listed per file (default 100; `all` for no cap). Counts always cover every occurrence.
323
324
  - `--strip-bom`: also report a BOM at offset 0, which is skipped by default.
@@ -354,7 +355,7 @@ skills/review/SKILL.md:2:3 U+FE00 run of 3 variation selectors
354
355
  - Each location has the file (relative to `%SRCROOT%`, the current directory), `startLine`, `startColumn` and `endColumn`. Columns count UTF-16 code units, as in `inspectTextDetailed` (`columnKind: "utf16CodeUnits"`).
355
356
  - Each result has a `partialFingerprints` hash based on its rule, relative path, line content and occurrence on that line. Adding unrelated lines above does not change it.
356
357
  - With `--ai-config`, the message of the first result in a run describes the run and its decoded text.
357
- - `--max-findings` caps results per file as usual. `runs[0].properties` holds `total`, `filesScanned`, `filesWithFindings`, `truncated` (files with findings left out) and `skipped`.
358
+ - `--max-findings` caps results per file as usual. `runs[0].properties` holds `total`, `filesScanned`, `filesWithFindings`, `truncated` (files with findings left out), `skipped` and `excluded`.
358
359
 
359
360
  ### pre-commit
360
361
 
@@ -364,7 +365,7 @@ The repository publishes a [pre-commit](https://pre-commit.com) hook manifest, s
364
365
  # .pre-commit-config.yaml
365
366
  repos:
366
367
  - repo: https://github.com/iWhatty/text-sanctifier
367
- rev: v1.3.11 # a release tag
368
+ rev: v1.3.12 # a release tag
368
369
  hooks:
369
370
  - id: text-sanctifier # every staged text file
370
371
  - id: text-sanctifier-ai-config # agent instruction files, with --ai-config
@@ -386,7 +387,8 @@ Rewrites files in place and reports what it replaced and removed.
386
387
  - With none of these, `fix` uses `--normalize-spaces --purge-invisible --nuke-controls`: look-alike spaces become ordinary spaces, and everything else `check` reports is removed.
387
388
  - `--strip-bom`: also remove a BOM at offset 0. It is kept by default.
388
389
  - `--dry-run`: write nothing; report what would change.
389
- - `--json` (or `--format json`): `{ files, dryRun, filesScanned, filesChanged, skipped }`, where each file is `{ path, spacesReplaced, removed, removedTotal, newlinesNormalized }`.
390
+ - `--exclude GLOB`: exclude matching paths; repeatable. See [Inputs](#inputs).
391
+ - `--json` (or `--format json`): `{ files, dryRun, filesScanned, filesChanged, skipped, excluded, errors }`, where each file is `{ path, spacesReplaced, removed, removedTotal, newlinesNormalized }`. Usage errors also produce JSON on stdout.
390
392
 
391
393
  Apart from look-alike spaces, `fix` deletes characters. By default, emoji lose their zero width joiners and variation selectors, and so does Mongolian text. Use `--keep-benign` to preserve recognized legitimate sequences, or run `check` or `fix --dry-run` first on prose. To make hidden characters visible without changing a file, use `reveal`.
392
394
 
@@ -402,12 +404,14 @@ git diff | npx text-sanctifier reveal
402
404
  ### Inputs
403
405
 
404
406
  - Paths can be files, directories or globs (`*`, `**`, `?`, `[...]`, `{a,b}`). Quote globs so the shell passes them through.
405
- - 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.
406
- - Binary files are skipped, counted in the summary and listed under `skipped` in JSON. A file counts as binary when it is not valid UTF-8 and its first 8000 bytes hold a NUL and more than 5% control bytes, so a stray NUL or bad byte does not hide a text file: the NUL is reported as `nul`. Stdin and AI configuration files are never treated as binary. `fix` is more careful, because it rewrites files: in a directory or glob it also skips any file with a NUL in those bytes, and it cleans such a file only if you name it.
407
+ - 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, unless `--exclude` matches it.
408
+ - `--exclude GLOB` matches paths relative to the current directory, or any parent directory. Leading `./` and trailing `/` are ignored. `*` does not cross `/`: `*.tar` matches only the top level, while `**/*.tar` matches any depth. Excluded paths are listed in JSON and SARIF and counted in text summaries. Stdin is never excluded. There is no config file, ignore file or inline comment form; scanned text cannot silence the scanner.
409
+ - Binary files are skipped, counted in the summary and listed under `skipped` in JSON. A file counts as binary when its first 8000 bytes hold a NUL and more than 5% control bytes, and it is either invalid UTF-8 or entirely ASCII with at least 8 control bytes. Any non-ASCII byte in valid UTF-8 keeps the file checked, even beyond the sample. A stray NUL or bad byte does not hide a text file. Stdin and AI configuration files are never treated as binary. `fix` is more careful, because it rewrites files: in a directory or glob it also skips any file with a NUL in those bytes, and it cleans such a file only if you name it.
407
410
  - UTF-16 files with a BOM are decoded and checked. `fix` never rewrites them and lists them under `skipped`.
408
411
  - Files that are not valid UTF-8 are still checked, with each invalid byte read as U+FFFD, so one bad byte cannot hide the rest of the file. `check` notes them on stderr and lists them under `notUtf8` in JSON; `fix` never writes them and lists them under `skipped`.
409
412
  - `-` reads stdin. With no paths, `check` reads piped input, and scans the current directory when stdin is a terminal, the null device or empty. `check --ai-config` always searches the current directory with no paths. `fix` and `reveal` read piped input. `fix` writes the cleaned text to stdout and the report to stderr.
410
413
  - A path or glob that matches no files, or a run that checks nothing, exits 2 instead of reporting a clean result. So does `fix` when it refuses stdin or a file you named. Files it skips in a directory or glob don't fail the run.
414
+ - JSON `errors` entries have `path` (or null), `code` and `message`; read and write failures may also include a system `cause`. Codes are `usage`, `not_found`, `no_match`, `read_failed`, `decode_failed`, `write_failed`, `refused` and `nothing_checked`. Exit 2 means `errors` is nonempty.
411
415
 
412
416
  ### Exit codes
413
417
 
@@ -22,6 +22,10 @@ import { scanHiddenSpans } from '../src/hiddenScan.js';
22
22
  import { inspectTextInContext } from '../src/context.js';
23
23
  import { classifyCodeUnit, classifyAstral } from '../src/categories.generated.js';
24
24
 
25
+ for (const stream of [process.stdout, process.stderr]) {
26
+ stream.on('error', (e) => { if (e.code !== 'EPIPE') throw e; });
27
+ }
28
+
25
29
  const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
26
30
 
27
31
  const USAGE = `text-sanctifier ${VERSION}
@@ -42,6 +46,7 @@ matches no files, or a run that checks nothing, exits 2.
42
46
  check options:
43
47
  --format F text (default), json or sarif (SARIF 2.1.0)
44
48
  --json same as --format json
49
+ --exclude GLOB exclude matching paths (repeatable)
45
50
  --max-findings N findings listed per file (default 100, "all" for no cap)
46
51
  --strip-bom also report a BOM at the start of a file
47
52
  --ai-config check agent instruction files (CLAUDE.md, AGENTS.md,
@@ -58,6 +63,7 @@ fix options:
58
63
  --normalize-newlines convert CRLF and CR to LF
59
64
  --strip-bom also remove a BOM at the start of a file (kept by default)
60
65
  --dry-run report what would change; write nothing
66
+ --exclude GLOB exclude matching paths (repeatable)
61
67
  --format F text (default) or json
62
68
  --json same as --format json
63
69
  With no cleaning flag, fix uses --normalize-spaces --purge-invisible --nuke-controls.
@@ -82,6 +88,7 @@ const STDIN = '-';
82
88
  const STDIN_LABEL = '<stdin>';
83
89
  const BINARY_SNIFF_BYTES = 8000;
84
90
  const BINARY_CONTROL_SHARE = 0.05;
91
+ const BINARY_MIN_ASCII_CONTROLS = 8;
85
92
  const WALK_SKIP_DIRS = new Set(['.git', 'node_modules']);
86
93
  const CASE_INSENSITIVE_FS = process.platform === 'win32' || process.platform === 'darwin';
87
94
 
@@ -111,6 +118,7 @@ function parseArgs(argv) {
111
118
  const opts = {
112
119
  command: null,
113
120
  paths: [],
121
+ excludes: [],
114
122
  format: null,
115
123
  aiConfig: false,
116
124
  stripBom: false,
@@ -139,6 +147,14 @@ function parseArgs(argv) {
139
147
  if (arg === '-v' || arg === '--version') { opts.version = true; continue; }
140
148
 
141
149
  const [name, inline] = arg.split(/=(.*)/s, 2);
150
+ if (name === '--exclude') {
151
+ const value = inline !== undefined ? inline : argv[++i];
152
+ if (!value || value.startsWith('-')) throw new UsageError('--exclude expects a non-empty glob.');
153
+ try { excludeRegExp(value); }
154
+ catch (e) { if (e instanceof SyntaxError) throw new UsageError(`Invalid --exclude pattern: ${value}`); throw e; }
155
+ opts.excludes.push(value);
156
+ continue;
157
+ }
142
158
  if (name === '--max-findings') {
143
159
  const value = inline !== undefined ? inline : argv[++i];
144
160
  opts.maxFindings = parseMaxFindings(value);
@@ -165,6 +181,7 @@ function parseArgs(argv) {
165
181
  throw new UsageError(opts.command === null ? 'Missing command.' : `Unknown command: ${opts.command}`);
166
182
  }
167
183
  if (opts.command === 'reveal') {
184
+ if (opts.excludes.length) throw new UsageError('--exclude does not apply to reveal.');
168
185
  for (const [flag, key] of Object.entries(BOOLEAN_FLAGS)) {
169
186
  if (opts[key]) throw new UsageError(`${flag} does not apply to reveal.`);
170
187
  }
@@ -176,6 +193,7 @@ function parseArgs(argv) {
176
193
  if (opts.command === 'check' && FIX_ONLY_FLAGS.has(key) && opts[key]) throw new UsageError(`${flag} only applies to fix.`);
177
194
  if (opts.command === 'fix' && CHECK_ONLY_FLAGS.has(key) && opts[key]) throw new UsageError(`${flag} only applies to check.`);
178
195
  }
196
+ if (opts.aiConfig && opts.excludes.length) throw new UsageError('--exclude does not apply to --ai-config.');
179
197
  if (opts.format === null) opts.format = 'text';
180
198
  if (opts.command === 'fix' && opts.format === 'sarif') throw new UsageError('--format sarif only applies to check.');
181
199
  if (opts.command === 'fix' && !opts.normalizeSpaces && !opts.purgeInvisible && !opts.nukeControls && !opts.normalizeNewlines) {
@@ -277,7 +295,7 @@ function walk(cwd, dir) {
277
295
  * Glob to RegExp for the non-git fallback: `**`, `*`, `?`, `[...]` and `{a,b}`.
278
296
  * @param {string} glob
279
297
  */
280
- function globToRegExp(glob) {
298
+ function globToRegExp(glob, flags = '') {
281
299
  let re = '';
282
300
  for (let i = 0; i < glob.length; i++) {
283
301
  const c = glob[i];
@@ -308,9 +326,12 @@ function globToRegExp(glob) {
308
326
  re += c.replace(/[.+^$()|\\\]}]/g, '\\$&');
309
327
  }
310
328
  }
311
- return new RegExp(`^${re}$`);
329
+ return new RegExp(`^${re}$`, flags);
312
330
  }
313
331
 
332
+ /** @param {string} glob - an --exclude pattern */
333
+ const excludeRegExp = (glob) => globToRegExp(toSlash(glob).replace(/^\.\//, '').replace(/\/$/, ''), CASE_INSENSITIVE_FS ? 'i' : '');
334
+
314
335
  /**
315
336
  * @param {string} cwd
316
337
  * @param {string} glob
@@ -351,14 +372,13 @@ const isAiConfigPath = (rel) => AI_CONFIG_RES.some((re) => re.test(rel));
351
372
  * directories contribute only agent instruction files (AI_CONFIG_GLOBS).
352
373
  * @param {string[]} args
353
374
  * @param {string} cwd
354
- * @param {(msg: string) => void} warn
375
+ * @param {(path: string|null, code: string, message: string) => void} addError
355
376
  * @param {boolean} [aiConfig]
356
- * @returns {{ inputs: Array<{ path: string, explicit: boolean }>, errors: number }}
377
+ * @returns {Array<{ path: string, explicit: boolean }>}
357
378
  */
358
- function collectInputs(args, cwd, warn, aiConfig = false) {
379
+ function collectInputs(args, cwd, addError, aiConfig = false) {
359
380
  const inputs = [];
360
381
  const seen = new Set();
361
- let errors = 0;
362
382
  let inGit;
363
383
 
364
384
  const add = (path, explicit) => {
@@ -379,8 +399,7 @@ function collectInputs(args, cwd, warn, aiConfig = false) {
379
399
 
380
400
  const isGlob = !stat && GLOB_CHARS.test(arg);
381
401
  if (!stat && !isGlob) {
382
- warn(`${arg}: no such file or directory`);
383
- errors++;
402
+ addError(arg, 'not_found', `${arg}: no such file or directory`);
384
403
  continue;
385
404
  }
386
405
 
@@ -393,12 +412,11 @@ function collectInputs(args, cwd, warn, aiConfig = false) {
393
412
  const base = resolve(cwd, arg);
394
413
  listed = listed.filter((f) => isAiConfigPath(toSlash(relative(base, resolve(cwd, f)))));
395
414
  } else if (listed.length === 0) {
396
- warn(`${arg}: no files matched`);
397
- errors++;
415
+ addError(arg, 'no_match', `${arg}: no files matched`);
398
416
  }
399
417
  for (const f of listed) add(f, false);
400
418
  }
401
- return { inputs, errors };
419
+ return inputs;
402
420
  }
403
421
 
404
422
 
@@ -412,19 +430,23 @@ const utf16 = new TextDecoder('utf-16le', { ignoreBOM: true });
412
430
  * A NUL plus a share of control bytes that text does not have, in the first
413
431
  * BINARY_SNIFF_BYTES. One stray NUL is not enough, or it would hide a file from `check`.
414
432
  * @param {Uint8Array} bytes
433
+ * @param {number} [minControls] - fewest control bytes that count
415
434
  */
416
- function looksBinary(bytes) {
435
+ function looksBinary(bytes, minControls = 0) {
417
436
  const sample = bytes.subarray(0, BINARY_SNIFF_BYTES);
418
437
  if (!sample.includes(0)) return false;
419
438
  let controls = 0;
420
439
  for (const b of sample) if (b < 9 || (b > 13 && b < 32)) controls++;
421
- return controls / sample.length > BINARY_CONTROL_SHARE;
440
+ return controls >= minControls && controls / sample.length > BINARY_CONTROL_SHARE;
422
441
  }
423
442
 
424
443
  /**
425
444
  * Decodes UTF-8, or UTF-16 with a BOM (setting `wide`), keeping a leading BOM. With
426
- * `sniff`, data that is not UTF-8 and looks binary is not decoded. Other bytes that are
427
- * not UTF-8 become U+FFFD and set `lossy`, so one bad byte cannot hide the rest of a file.
445
+ * `sniff`, data that looks binary is not decoded if it is not UTF-8, or if it is entirely
446
+ * ASCII with at least BINARY_MIN_ASCII_CONTROLS control bytes, so a short file with a stray
447
+ * NUL is still read. Invisible Unicode needs non-ASCII bytes in UTF-8, so ASCII-only data has
448
+ * only controls to hide. Other bytes that are not UTF-8 become U+FFFD and set `lossy`, so
449
+ * one bad byte cannot hide the rest of a file.
428
450
  * @param {Buffer} bytes
429
451
  * @param {boolean} sniff
430
452
  * @returns {{ text: string|null, reason?: string, lossy?: boolean, wide?: boolean }}
@@ -435,7 +457,9 @@ function decode(bytes, sniff) {
435
457
  return { text: utf16.decode(Buffer.from(bytes.subarray(0, bytes.length & ~1)).swap16()), wide: true };
436
458
  }
437
459
  try {
438
- return { text: utf8.decode(bytes) };
460
+ const text = utf8.decode(bytes);
461
+ if (sniff && looksBinary(bytes, BINARY_MIN_ASCII_CONTROLS) && bytes.every((b) => b < 0x80)) return { text: null, reason: 'binary' };
462
+ return { text };
439
463
  } catch (e) {
440
464
  if (!(e instanceof TypeError)) throw e; // too large for a string, not bad UTF-8
441
465
  if (sniff && looksBinary(bytes)) return { text: null, reason: 'binary' };
@@ -635,9 +659,9 @@ function sarifArtifact(label) {
635
659
  /**
636
660
  * Builds a SARIF 2.1.0 log with one result per listed finding; the rule is the category.
637
661
  * @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
638
- * @param {{ cwd: string, total: number, filesScanned: number, skipped: Array<{ path: string, reason: string }>, succeeded: boolean }} info
662
+ * @param {{ cwd: string, total: number, filesScanned: number, skipped: Array<{ path: string, reason: string }>, excluded: string[], succeeded: boolean }} info
639
663
  */
640
- function sarifLog(files, { cwd, total, filesScanned, skipped, succeeded }) {
664
+ function sarifLog(files, { cwd, total, filesScanned, skipped, excluded, succeeded }) {
641
665
  const ruleIds = Object.keys(inspectTextDetailed('', { maxFindings: 0 }).counts);
642
666
  const rules = ruleIds.map((id) => ({
643
667
  id,
@@ -682,6 +706,7 @@ function sarifLog(files, { cwd, total, filesScanned, skipped, succeeded }) {
682
706
  filesWithFindings: files.length,
683
707
  truncated: files.filter((f) => f.truncated).map((f) => f.path),
684
708
  skipped,
709
+ excluded,
685
710
  },
686
711
  }],
687
712
  };
@@ -704,6 +729,9 @@ async function main(argv) {
704
729
  } catch (e) {
705
730
  if (!(e instanceof UsageError)) throw e;
706
731
  err(`text-sanctifier: ${e.message}\nRun "text-sanctifier --help" for usage.`);
732
+ if (argv.some((arg, i) => arg === '--json' || arg === '--format=json' || (arg === '--format' && argv[i + 1] === 'json'))) {
733
+ out(JSON.stringify({ errors: [{ path: null, code: 'usage', message: e.message }] }));
734
+ }
707
735
  return 2;
708
736
  }
709
737
  if (opts.help) { out(USAGE); return 0; }
@@ -711,6 +739,13 @@ async function main(argv) {
711
739
 
712
740
  const cwd = process.cwd();
713
741
  const warn = (msg) => err(`text-sanctifier: ${msg}`);
742
+ const errors = [];
743
+ const addError = (path, code, message, cause, emit = true) => {
744
+ const entry = { path, code, message };
745
+ if (cause) entry.cause = cause;
746
+ errors.push(entry);
747
+ if (emit) warn(message);
748
+ };
714
749
  let paths = opts.paths;
715
750
  /** @type {Buffer | undefined} */
716
751
  let stdinBytes;
@@ -722,21 +757,33 @@ async function main(argv) {
722
757
  try {
723
758
  stdinBytes = await readStdin();
724
759
  } catch (e) {
725
- warn(`${STDIN_LABEL}: ${e.code || e.message}`);
760
+ addError(STDIN_LABEL, 'read_failed', `${STDIN_LABEL}: ${e.code || e.message}`, e.code);
761
+ if (opts.format === 'json') out(JSON.stringify({ errors }));
726
762
  return 2;
727
763
  }
728
764
  if (stdinBytes.length > 0) paths = [STDIN];
729
765
  }
730
766
  } else if (paths.length === 0) {
731
767
  if (process.stdin.isTTY) {
768
+ addError(null, 'usage', 'no paths given and nothing piped on stdin.', undefined, false);
732
769
  err('text-sanctifier: no paths given and nothing piped on stdin.\nRun "text-sanctifier --help" for usage.');
770
+ if (opts.format === 'json') out(JSON.stringify({ errors }));
733
771
  return 2;
734
772
  }
735
773
  paths = [STDIN];
736
774
  }
737
775
 
738
- const { inputs, errors: pathErrors } = collectInputs(paths, cwd, warn, opts.aiConfig);
739
- let errors = pathErrors;
776
+ const collected = collectInputs(paths, cwd, addError, opts.aiConfig);
777
+ const excluded = [];
778
+ const excludePatterns = opts.excludes.map(excludeRegExp);
779
+ const inputs = collected.filter((input) => {
780
+ if (input.path === STDIN) return true;
781
+ const path = toSlash(relative(cwd, resolve(cwd, input.path)));
782
+ const parts = path.split('/');
783
+ const matches = parts.some((_, i) => excludePatterns.some((re) => re.test(parts.slice(0, i + 1).join('/'))));
784
+ if (matches) excluded.push(input.path);
785
+ return !matches;
786
+ });
740
787
  /** @type {Array<{ path: string, reason: string }>} */
741
788
  const skipped = [];
742
789
  /** @type {string[]} */
@@ -754,8 +801,7 @@ async function main(argv) {
754
801
  try {
755
802
  bytes = input.path === STDIN ? (stdinBytes ?? await readStdin()) : readFileSync(resolve(cwd, input.path));
756
803
  } catch (e) {
757
- warn(`${label}: ${e.code || e.message}`);
758
- errors++;
804
+ addError(label, 'read_failed', `${label}: ${e.code || e.message}`, e.code);
759
805
  continue;
760
806
  }
761
807
  // Files that look binary are skipped. Stdin and AI configuration files are text
@@ -764,8 +810,7 @@ async function main(argv) {
764
810
  try {
765
811
  decoded = decode(bytes, input.path !== STDIN && !opts.aiConfig);
766
812
  } catch (e) {
767
- warn(`${label}: ${e.message}`);
768
- errors++;
813
+ addError(label, 'decode_failed', `${label}: ${e.message}`);
769
814
  continue;
770
815
  }
771
816
  const { text, lossy, wide } = decoded;
@@ -782,7 +827,7 @@ async function main(argv) {
782
827
  skipped.push({ path: label, reason });
783
828
  if (input.explicit || reason !== 'binary') warn(`${label}: skipped (${reason})`);
784
829
  // Asked to clean this input and did not: that must not look like success
785
- if (opts.command === 'fix' && input.explicit) errors++;
830
+ if (opts.command === 'fix' && input.explicit) addError(label, 'refused', `${label}: skipped (${reason})`, undefined, false);
786
831
  continue;
787
832
  }
788
833
  if (lossy) {
@@ -794,10 +839,10 @@ async function main(argv) {
794
839
  }
795
840
  // A clean result must mean something was read. Finding no AI configuration files is
796
841
  // an answer; --ai-config reports that itself.
797
- if (visited === 0 && errors === 0 && !opts.aiConfig) errors++;
842
+ if (visited === 0 && errors.length === 0 && !opts.aiConfig) addError(null, 'nothing_checked', 'Nothing checked.', undefined, false);
798
843
  };
799
844
 
800
- const skippedNote = () => (skipped.length > 0 ? `; ${plural(skipped.length, 'file')} skipped` : '');
845
+ const skippedNote = () => (skipped.length > 0 ? `; ${plural(skipped.length, 'file')} skipped` : '') + (excluded.length > 0 ? `; ${plural(excluded.length, 'file')} excluded` : '');
801
846
 
802
847
  if (opts.command === 'check') {
803
848
  const files = [];
@@ -839,14 +884,14 @@ async function main(argv) {
839
884
 
840
885
  const kind = opts.aiConfig ? 'AI configuration file' : 'file';
841
886
  if (opts.format === 'json') {
842
- const summary = { files, total, filesScanned: scanned, filesWithFindings: files.length, skipped, notUtf8 };
887
+ const summary = { files, total, filesScanned: scanned, filesWithFindings: files.length, skipped, excluded, notUtf8, errors };
843
888
  if (opts.aiConfig) summary.hiddenRunCount = hiddenRunCount;
844
889
  out(JSON.stringify(summary, null, 2));
845
890
  } else if (opts.format === 'sarif') {
846
- out(JSON.stringify(sarifLog(files, { cwd, total, filesScanned: scanned, skipped, succeeded: errors === 0 }), null, 2));
891
+ out(JSON.stringify(sarifLog(files, { cwd, total, filesScanned: scanned, skipped, excluded, succeeded: errors.length === 0 }), null, 2));
847
892
  } else {
848
893
  for (const line of lines) out(line);
849
- if (opts.aiConfig && scanned === 0 && skipped.length === 0) {
894
+ if (opts.aiConfig && scanned === 0 && skipped.length === 0 && errors.length === 0) {
850
895
  out('No AI configuration files found.');
851
896
  if (opts.paths.length === 0 && !process.stdin.isTTY) out('To check piped text, pass "-": text-sanctifier check --ai-config -');
852
897
  }
@@ -854,7 +899,7 @@ async function main(argv) {
854
899
  else if (total === 0) out(`No ${opts.aiConfig ? 'hidden text, ' : ''}invisible or control characters in ${plural(scanned, kind)}${skippedNote()}.`);
855
900
  else out(`${plural(total, 'finding')}${opts.aiConfig ? ` (${plural(hiddenRunCount, 'hidden run')})` : ''} in ${files.length} of ${plural(scanned, kind)}${skippedNote()}.`);
856
901
  }
857
- return errors > 0 ? 2 : total > 0 ? 1 : 0;
902
+ return errors.length > 0 ? 2 : total > 0 ? 1 : 0;
858
903
  }
859
904
 
860
905
  if (opts.command === 'reveal') {
@@ -867,7 +912,7 @@ async function main(argv) {
867
912
  }
868
913
  process.stdout.write(revealInvisible(text));
869
914
  });
870
- return errors > 0 ? 2 : 0;
915
+ return errors.length > 0 ? 2 : 0;
871
916
  }
872
917
 
873
918
  // fix
@@ -925,8 +970,7 @@ async function main(argv) {
925
970
  try {
926
971
  writeFileSync(resolve(cwd, input.path), cleaned, 'utf8');
927
972
  } catch (e) {
928
- warn(`${label}: ${e.code || e.message}`);
929
- errors++;
973
+ addError(label, 'write_failed', `${label}: ${e.code || e.message}`, e.code);
930
974
  return;
931
975
  }
932
976
  }
@@ -934,7 +978,7 @@ async function main(argv) {
934
978
  });
935
979
 
936
980
  if (opts.format === 'json') {
937
- report(JSON.stringify({ files, dryRun: opts.dryRun, filesScanned: scanned, filesChanged: files.length, skipped }, null, 2));
981
+ report(JSON.stringify({ files, dryRun: opts.dryRun, filesScanned: scanned, filesChanged: files.length, skipped, excluded, errors }, null, 2));
938
982
  } else {
939
983
  const verb = opts.dryRun ? 'would fix' : 'fixed';
940
984
  for (const f of files) {
@@ -950,7 +994,7 @@ async function main(argv) {
950
994
  ? `Nothing to fix in ${plural(scanned, 'file')}${skippedNote()}.`
951
995
  : `${opts.dryRun ? 'Would change' : 'Changed'} ${files.length} of ${plural(scanned, 'file')}${skippedNote()}.`);
952
996
  }
953
- return errors > 0 ? 2 : opts.dryRun && files.length > 0 ? 1 : 0;
997
+ return errors.length > 0 ? 2 : opts.dryRun && files.length > 0 ? 1 : 0;
954
998
  }
955
999
 
956
1000
  main(process.argv.slice(2)).then(
@@ -3,4 +3,4 @@ var y=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F\u061C\u200E\u200F\u2
3
3
  `){return e.replace(re,r)}var te=/[ \t]*(\n+)[ \t]*|([ \t]+)/g;function ie(e){return e.replace(te,"$1$2")}var oe=/\n{2,}/g,ue=/\n{3,}/g;function se(e,r){return r?e.replace(ue,`
4
4
 
5
5
  `):e.replace(oe,`
6
- `)}var le=/ {2,}/g;function ae(e){return e.replace(le," ")}function fe(e){return e.replace(y,"")}function ce(e,r={}){if(typeof e!="string")throw new TypeError("revealInvisible expects a string input.");let n=r&&typeof r.format=="function"?r.format:i=>"<U+"+i.toString(16).toUpperCase().padStart(4,"0")+">",u="",o=0;for(let i of A(e,{maxFindings:1/0}).findings)u+=e.slice(o,i.offset)+n(i.codePoint,i.category),o=i.offset+(i.codePoint>65535?2:1);return u+e.slice(o)}var Ee="1.3.11";export{Ee as VERSION,R as getNewlineStats,H as getRecommendedSanctifierOptions,j as inspectText,A as inspectTextDetailed,ce as revealInvisible,L as sanctifyKeyboardOnly,T as sanctifyKeyboardOnlyEmoji,N as sanctifyLoose,D as sanctifyPasted,w as sanctifyStrict,J as summonSanctifier};
6
+ `)}var le=/ {2,}/g;function ae(e){return e.replace(le," ")}function fe(e){return e.replace(y,"")}function ce(e,r={}){if(typeof e!="string")throw new TypeError("revealInvisible expects a string input.");let n=r&&typeof r.format=="function"?r.format:i=>"<U+"+i.toString(16).toUpperCase().padStart(4,"0")+">",u="",o=0;for(let i of A(e,{maxFindings:1/0}).findings)u+=e.slice(o,i.offset)+n(i.codePoint,i.category),o=i.offset+(i.codePoint>65535?2:1);return u+e.slice(o)}var Ee="1.3.12";export{Ee as VERSION,R as getNewlineStats,H as getRecommendedSanctifierOptions,j as inspectText,A as inspectTextDetailed,ce as revealInvisible,L as sanctifyKeyboardOnly,T as sanctifyKeyboardOnlyEmoji,N as sanctifyLoose,D as sanctifyPasted,w as sanctifyStrict,J as summonSanctifier};
@@ -1,2 +1,2 @@
1
1
  /** The installed version of text-sanctifier, as in its package.json. */
2
- export const VERSION: "1.3.11";
2
+ export const VERSION: "1.3.12";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "text-sanctifier",
3
- "version": "1.3.11",
3
+ "version": "1.3.12",
4
4
  "type": "module",
5
5
  "description": "Find and remove invisible Unicode in web input, model-bound text and repositories.",
6
6
  "engines": {
@@ -2,4 +2,4 @@
2
2
  // Generated from package.json by scripts/gen-version.js (`npm run gen`); do not edit.
3
3
 
4
4
  /** The installed version of text-sanctifier, as in its package.json. */
5
- export const VERSION = '1.3.11';
5
+ export const VERSION = '1.3.12';