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 +10 -6
- package/bin/text-sanctifier.js +81 -37
- package/dist/text-sanctifier.min.js +1 -1
- package/dist/types/version.generated.d.ts +1 -1
- package/package.json +1 -1
- package/src/version.generated.js +1 -1
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 `
|
|
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.
|
|
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
|
-
- `--
|
|
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
|
-
-
|
|
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
|
|
package/bin/text-sanctifier.js
CHANGED
|
@@ -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 {(
|
|
375
|
+
* @param {(path: string|null, code: string, message: string) => void} addError
|
|
355
376
|
* @param {boolean} [aiConfig]
|
|
356
|
-
* @returns {
|
|
377
|
+
* @returns {Array<{ path: string, explicit: boolean }>}
|
|
357
378
|
*/
|
|
358
|
-
function collectInputs(args, cwd,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
427
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
739
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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
|
-
|
|
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.
|
|
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.
|
|
2
|
+
export const VERSION: "1.3.12";
|
package/package.json
CHANGED
package/src/version.generated.js
CHANGED