text-sanctifier 1.3.11 → 1.3.13
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 +11 -6
- package/bin/text-sanctifier.js +81 -37
- package/dist/text-sanctifier.min.js +1 -1
- package/dist/types/hiddenScan.d.ts +1 -0
- package/dist/types/hiddenText.d.ts +12 -0
- package/dist/types/version.generated.d.ts +1 -1
- package/package.json +1 -1
- package/src/hiddenScan.js +30 -0
- package/src/hiddenText.js +6 -0
- package/src/version.generated.js +1 -1
package/README.md
CHANGED
|
@@ -261,6 +261,7 @@ const { runs, benign, counts, total, truncated } = findHiddenRuns(text, { maxRun
|
|
|
261
261
|
|
|
262
262
|
- `kind` is `tag` (U+E0000–U+E007F), `variation_selector` (U+FE00–U+FE0F, U+E0100–U+E01EF, U+180B–U+180D, U+180F) `invisible_operator` (U+2061–U+2064) or `reserved_tag_plane` (U+E0080–U+E00FF, U+E01F0–U+E0FFF: unassigned, default ignorable, so they render as nothing and can carry data). Consecutive characters of one kind form one run.
|
|
263
263
|
- `offset` and `end` are UTF-16 indexes, `[offset, end)`; `count` is in code points. `line` and `column` are counted exactly as in `inspectTextDetailed` (1-based, columns in UTF-16 units, CRLF is one line break). Only tag runs have `decoded` and `decodedTruncated`.
|
|
264
|
+
- `sequence` marks a lone selector in a form `text-sanctifier/context` calls benign: `'ideographic'` for U+E0100–U+E01EF after an ideograph, `'mongolian'` for U+180B–U+180D or U+180F after a Mongolian letter. These stay in `runs` because they can carry data, up to a byte per ideograph, so a run with `sequence` is usually ordinary text, but many of them can be a message. U+FE0E and U+FE0F have no such room and go under `benign`.
|
|
264
265
|
- `maxRuns` caps the `runs` list and, separately, the `benign` list; `counts`, `total` and `benignTotal` count everything, and `truncated` is true when either list was cut. `maxDecodedLength` caps each `decoded` string in UTF-16 units and never cuts a `<U+XXXX>` marker. Both default to no cap. Only non-negative integers and `Infinity` are valid; invalid values (negative, fractional, a string, `NaN` or `null`) also mean no cap, so set them explicitly for untrusted input. With both set, memory stays flat on any input: on 16 M characters (32 MB) of hidden text the heap grows by under 1 MB, against hundreds of MB with no caps.
|
|
265
266
|
- `decodeTagText` turns U+E0020–U+E007E into the ASCII they shadow and writes any other character as `<U+XXXX>`.
|
|
266
267
|
- Zero-width, bidi and other invisible characters are not runs; use `inspectTextDetailed` for those.
|
|
@@ -317,7 +318,8 @@ src/auth.js:40:3 U+202E bidi_control
|
|
|
317
318
|
2 findings in 2 of 31 files.
|
|
318
319
|
```
|
|
319
320
|
|
|
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.
|
|
321
|
+
- `--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.
|
|
322
|
+
- `--exclude GLOB`: exclude matching paths; repeatable. See [Inputs](#inputs). Not available with `--ai-config`.
|
|
321
323
|
- `--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
324
|
- `--max-findings N`: findings listed per file (default 100; `all` for no cap). Counts always cover every occurrence.
|
|
323
325
|
- `--strip-bom`: also report a BOM at offset 0, which is skipped by default.
|
|
@@ -354,7 +356,7 @@ skills/review/SKILL.md:2:3 U+FE00 run of 3 variation selectors
|
|
|
354
356
|
- 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
357
|
- 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
358
|
- 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 `
|
|
359
|
+
- `--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
360
|
|
|
359
361
|
### pre-commit
|
|
360
362
|
|
|
@@ -364,7 +366,7 @@ The repository publishes a [pre-commit](https://pre-commit.com) hook manifest, s
|
|
|
364
366
|
# .pre-commit-config.yaml
|
|
365
367
|
repos:
|
|
366
368
|
- repo: https://github.com/iWhatty/text-sanctifier
|
|
367
|
-
rev: v1.3.
|
|
369
|
+
rev: v1.3.13 # a release tag
|
|
368
370
|
hooks:
|
|
369
371
|
- id: text-sanctifier # every staged text file
|
|
370
372
|
- id: text-sanctifier-ai-config # agent instruction files, with --ai-config
|
|
@@ -386,7 +388,8 @@ Rewrites files in place and reports what it replaced and removed.
|
|
|
386
388
|
- 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
389
|
- `--strip-bom`: also remove a BOM at offset 0. It is kept by default.
|
|
388
390
|
- `--dry-run`: write nothing; report what would change.
|
|
389
|
-
- `--
|
|
391
|
+
- `--exclude GLOB`: exclude matching paths; repeatable. See [Inputs](#inputs).
|
|
392
|
+
- `--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
393
|
|
|
391
394
|
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
395
|
|
|
@@ -402,12 +405,14 @@ git diff | npx text-sanctifier reveal
|
|
|
402
405
|
### Inputs
|
|
403
406
|
|
|
404
407
|
- 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
|
-
-
|
|
408
|
+
- 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.
|
|
409
|
+
- `--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.
|
|
410
|
+
- 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
411
|
- UTF-16 files with a BOM are decoded and checked. `fix` never rewrites them and lists them under `skipped`.
|
|
408
412
|
- 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
413
|
- `-` 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
414
|
- 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.
|
|
415
|
+
- 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
416
|
|
|
412
417
|
### Exit codes
|
|
413
418
|
|
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.13";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};
|
|
@@ -25,6 +25,7 @@ export function scanHiddenSpans(text: string, options?: HiddenScanOptions, onSpa
|
|
|
25
25
|
}) => void): HiddenTextScan;
|
|
26
26
|
export type HiddenRunKind = import("./hiddenText.js").HiddenRunKind;
|
|
27
27
|
export type HiddenRun = import("./hiddenText.js").HiddenRun;
|
|
28
|
+
export type HiddenRunSequence = import("./hiddenText.js").HiddenRunSequence;
|
|
28
29
|
export type HiddenRunCounts = import("./hiddenText.js").HiddenRunCounts;
|
|
29
30
|
export type HiddenTextScan = import("./hiddenText.js").HiddenTextScan;
|
|
30
31
|
export type HiddenScanOptions = import("./hiddenText.js").HiddenScanOptions;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** @typedef {'tag' | 'variation_selector' | 'invisible_operator' | 'reserved_tag_plane'} HiddenRunKind */
|
|
2
|
+
/** @typedef {'ideographic' | 'mongolian'} HiddenRunSequence */
|
|
2
3
|
/**
|
|
3
4
|
* @typedef {Object} HiddenRun
|
|
4
5
|
* @property {HiddenRunKind} kind
|
|
@@ -10,6 +11,9 @@
|
|
|
10
11
|
* @property {string} [decoded] - tag runs only: the ASCII the tags spell (see decodeTagText),
|
|
11
12
|
* at most `maxDecodedLength` UTF-16 units
|
|
12
13
|
* @property {boolean} [decodedTruncated] - tag runs only: true when `decoded` stops short of the run
|
|
14
|
+
* @property {HiddenRunSequence} [sequence] - a lone selector in a form text-sanctifier/context calls
|
|
15
|
+
* benign: U+E0100–U+E01EF after an ideograph, or U+180B–U+180D or U+180F after a Mongolian letter.
|
|
16
|
+
* Still a run, because these can carry data: up to a byte per ideograph.
|
|
13
17
|
*/
|
|
14
18
|
/**
|
|
15
19
|
* @typedef {Object} HiddenRunCounts
|
|
@@ -57,6 +61,7 @@ export function decodeTagText(text: string): string;
|
|
|
57
61
|
* Extended_Pictographic character, or between #, * or a digit and U+20E3 (a keycap),
|
|
58
62
|
* the same rule as text-sanctifier/context. After a letter it is a run: one selector
|
|
59
63
|
* per letter can carry a bit each.
|
|
64
|
+
* Lone ideographic and Mongolian selectors stay runs, marked with `sequence`.
|
|
60
65
|
*
|
|
61
66
|
* `line` and `column` are counted as in inspectTextDetailed. Every run and benign
|
|
62
67
|
* span is counted; only the first `maxRuns` of each are listed. With the caps set,
|
|
@@ -70,6 +75,7 @@ export function decodeTagText(text: string): string;
|
|
|
70
75
|
export function findHiddenRuns(text: string, options?: HiddenScanOptions): HiddenTextScan;
|
|
71
76
|
export { VERSION } from "./version.generated.js";
|
|
72
77
|
export type HiddenRunKind = "tag" | "variation_selector" | "invisible_operator" | "reserved_tag_plane";
|
|
78
|
+
export type HiddenRunSequence = "ideographic" | "mongolian";
|
|
73
79
|
export type HiddenRun = {
|
|
74
80
|
kind: HiddenRunKind;
|
|
75
81
|
/**
|
|
@@ -101,6 +107,12 @@ export type HiddenRun = {
|
|
|
101
107
|
* - tag runs only: true when `decoded` stops short of the run
|
|
102
108
|
*/
|
|
103
109
|
decodedTruncated?: boolean;
|
|
110
|
+
/**
|
|
111
|
+
* - a lone selector in a form text-sanctifier/context calls
|
|
112
|
+
* benign: U+E0100–U+E01EF after an ideograph, or U+180B–U+180D or U+180F after a Mongolian letter.
|
|
113
|
+
* Still a run, because these can carry data: up to a byte per ideograph.
|
|
114
|
+
*/
|
|
115
|
+
sequence?: HiddenRunSequence;
|
|
104
116
|
};
|
|
105
117
|
export type HiddenRunCounts = {
|
|
106
118
|
tag: number;
|
|
@@ -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.13";
|
package/package.json
CHANGED
package/src/hiddenScan.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* @typedef {import('./hiddenText.js').HiddenRunKind} HiddenRunKind
|
|
5
5
|
* @typedef {import('./hiddenText.js').HiddenRun} HiddenRun
|
|
6
|
+
* @typedef {import('./hiddenText.js').HiddenRunSequence} HiddenRunSequence
|
|
6
7
|
* @typedef {import('./hiddenText.js').HiddenRunCounts} HiddenRunCounts
|
|
7
8
|
* @typedef {import('./hiddenText.js').HiddenTextScan} HiddenTextScan
|
|
8
9
|
* @typedef {import('./hiddenText.js').HiddenScanOptions} HiddenScanOptions
|
|
@@ -76,6 +77,32 @@ function isPresentationBase(text, index) {
|
|
|
76
77
|
return PICTOGRAPHIC_AT.test(text);
|
|
77
78
|
}
|
|
78
79
|
|
|
80
|
+
const IDEOGRAPHIC_AT = /\p{Ideographic}/yu;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The legitimate sequence a lone selector at `index` could belong to, by the same rules
|
|
84
|
+
* as text-sanctifier/context. It stays a run: unlike U+FE0E and U+FE0F, these selectors
|
|
85
|
+
* have enough values to spell data, up to a byte per ideograph.
|
|
86
|
+
* @param {string} text
|
|
87
|
+
* @param {number} index
|
|
88
|
+
* @param {number} cp - the selector
|
|
89
|
+
* @returns {HiddenRunSequence | null}
|
|
90
|
+
*/
|
|
91
|
+
function sequenceOf(text, index, cp) {
|
|
92
|
+
if (cp >= 0xE0100 && cp <= 0xE01EF) {
|
|
93
|
+
if (index === 0) return null;
|
|
94
|
+
const low = text.charCodeAt(index - 1);
|
|
95
|
+
IDEOGRAPHIC_AT.lastIndex = low >= 0xDC00 && low <= 0xDFFF && index >= 2 &&
|
|
96
|
+
(text.charCodeAt(index - 2) & 0xFC00) === 0xD800 ? index - 2 : index - 1;
|
|
97
|
+
return IDEOGRAPHIC_AT.test(text) ? 'ideographic' : null;
|
|
98
|
+
}
|
|
99
|
+
if (cp > 0x180F) return null;
|
|
100
|
+
const c = text.charCodeAt(index - 1);
|
|
101
|
+
return (c >= 0x1820 && c <= 0x1878) || (c >= 0x1880 && c <= 0x1884) || (c >= 0x1887 && c <= 0x18A8) || c === 0x18AA
|
|
102
|
+
? 'mongolian'
|
|
103
|
+
: null;
|
|
104
|
+
}
|
|
105
|
+
|
|
79
106
|
/**
|
|
80
107
|
* A cap option: a non-negative integer or Infinity; anything else means no cap.
|
|
81
108
|
* @param {unknown} value
|
|
@@ -163,6 +190,9 @@ export function scanHiddenSpans(text, options = {}, onSpan) {
|
|
|
163
190
|
const { decoded, truncated } = decodeRange(text, offset, end, maxDecodedLength);
|
|
164
191
|
run.decoded = decoded;
|
|
165
192
|
run.decodedTruncated = truncated;
|
|
193
|
+
} else if (kind === 'variation_selector' && count === 1) {
|
|
194
|
+
const sequence = sequenceOf(text, offset, cp);
|
|
195
|
+
if (sequence) run.sequence = sequence;
|
|
166
196
|
}
|
|
167
197
|
runs.push(run);
|
|
168
198
|
}
|
package/src/hiddenText.js
CHANGED
|
@@ -15,6 +15,8 @@ export { VERSION } from './version.generated.js';
|
|
|
15
15
|
|
|
16
16
|
/** @typedef {'tag' | 'variation_selector' | 'invisible_operator' | 'reserved_tag_plane'} HiddenRunKind */
|
|
17
17
|
|
|
18
|
+
/** @typedef {'ideographic' | 'mongolian'} HiddenRunSequence */
|
|
19
|
+
|
|
18
20
|
/**
|
|
19
21
|
* @typedef {Object} HiddenRun
|
|
20
22
|
* @property {HiddenRunKind} kind
|
|
@@ -26,6 +28,9 @@ export { VERSION } from './version.generated.js';
|
|
|
26
28
|
* @property {string} [decoded] - tag runs only: the ASCII the tags spell (see decodeTagText),
|
|
27
29
|
* at most `maxDecodedLength` UTF-16 units
|
|
28
30
|
* @property {boolean} [decodedTruncated] - tag runs only: true when `decoded` stops short of the run
|
|
31
|
+
* @property {HiddenRunSequence} [sequence] - a lone selector in a form text-sanctifier/context calls
|
|
32
|
+
* benign: U+E0100–U+E01EF after an ideograph, or U+180B–U+180D or U+180F after a Mongolian letter.
|
|
33
|
+
* Still a run, because these can carry data: up to a byte per ideograph.
|
|
29
34
|
*/
|
|
30
35
|
|
|
31
36
|
/**
|
|
@@ -81,6 +86,7 @@ export function decodeTagText(text) {
|
|
|
81
86
|
* Extended_Pictographic character, or between #, * or a digit and U+20E3 (a keycap),
|
|
82
87
|
* the same rule as text-sanctifier/context. After a letter it is a run: one selector
|
|
83
88
|
* per letter can carry a bit each.
|
|
89
|
+
* Lone ideographic and Mongolian selectors stay runs, marked with `sequence`.
|
|
84
90
|
*
|
|
85
91
|
* `line` and `column` are counted as in inspectTextDetailed. Every run and benign
|
|
86
92
|
* span is counted; only the first `maxRuns` of each are listed. With the caps set,
|
package/src/version.generated.js
CHANGED