text-sanctifier 1.3.10 → 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 +21 -12
- package/bin/text-sanctifier.js +193 -61
- package/dist/text-sanctifier.min.js +5 -5
- package/dist/types/inspectText.d.ts +4 -1
- package/dist/types/sanctifyText.d.ts +1 -1
- package/dist/types/version.generated.d.ts +1 -1
- package/package.json +7 -5
- package/src/inspectText.js +7 -3
- package/src/sanctifyText.js +17 -11
- package/src/version.generated.js +1 -1
- package/src/index.d.ts +0 -22
package/README.md
CHANGED
|
@@ -106,20 +106,22 @@ Used by `summonSanctifier(options)`. All boolean options default to `false`. Pro
|
|
|
106
106
|
| `collapseSpaces` | Collapse runs of ordinary spaces to one; keep tabs and newlines. |
|
|
107
107
|
| `finalTrim` | Apply JavaScript `.trim()` to the result. |
|
|
108
108
|
| `maxLength` | Input cap in UTF-16 code units, checked before processing. Default `Infinity`; negative, non-finite or non-number values mean no cap. |
|
|
109
|
-
| `onMaxLength` | Default `'throw'`: throw `RangeError` above the cap. `'truncate'`: slice input to the cap before processing
|
|
109
|
+
| `onMaxLength` | Default `'throw'`: throw `RangeError` above the cap. `'truncate'`: slice input to at most the cap before processing, never through a surrogate pair. `'noop'`: return original input unchanged. Invalid values use `'throw'`. |
|
|
110
110
|
|
|
111
111
|
```js
|
|
112
112
|
import { summonSanctifier } from 'text-sanctifier';
|
|
113
113
|
const clean = summonSanctifier({ purgeInvisibleChars: true, maxLength: 10000, onMaxLength: 'throw' });
|
|
114
114
|
```
|
|
115
115
|
|
|
116
|
+
Every sanitizer replaces unpaired surrogates with U+FFFD before anything else, so removing a character between two halves cannot join them into a new one.
|
|
117
|
+
|
|
116
118
|
### Errors
|
|
117
119
|
|
|
118
120
|
All text-processing exports throw `TypeError` on non-string input, including the preset functions, the sanitizer returned by `summonSanctifier`, `findHiddenRuns` and `decodeTagText`. Inputs are not coerced to strings. Sanitizers also throw `RangeError` when input exceeds `maxLength` with `onMaxLength: 'throw'`.
|
|
119
121
|
|
|
120
122
|
### Presets as standalone imports
|
|
121
123
|
|
|
122
|
-
Each preset is also its own export: `sanctifyStrict`, `sanctifyLoose`, `sanctifyKeyboardOnly`, `sanctifyKeyboardOnlyEmoji` and `sanctifyPasted`. They are the same functions as `summonSanctifier.strict` and the rest. Import one directly and a bundler leaves out `summonSanctifier` and the other presets (about <!-- gen:size:sanctifyPasted -->1.1 KiB<!-- /gen --> gzipped instead of <!-- gen:size:summonSanctifier.pasted -->1.
|
|
124
|
+
Each preset is also its own export: `sanctifyStrict`, `sanctifyLoose`, `sanctifyKeyboardOnly`, `sanctifyKeyboardOnlyEmoji` and `sanctifyPasted`. They are the same functions as `summonSanctifier.strict` and the rest. Import one directly and a bundler leaves out `summonSanctifier` and the other presets (about <!-- gen:size:sanctifyPasted -->1.1 KiB<!-- /gen --> gzipped instead of <!-- gen:size:summonSanctifier.pasted -->1.5 KiB<!-- /gen -->).
|
|
123
125
|
|
|
124
126
|
```js
|
|
125
127
|
import { sanctifyPasted } from 'text-sanctifier';
|
|
@@ -187,7 +189,7 @@ const report = inspectText(input);
|
|
|
187
189
|
*/
|
|
188
190
|
```
|
|
189
191
|
|
|
190
|
-
Use `inspectText` to preflight text content before rendering, storing, or linting. It's a diagnostic tool to help inform sanitization needs. Pass the report to `getRecommendedSanctifierOptions(report)` to auto-generate config flags for `summonSanctifier()`.
|
|
192
|
+
Use `inspectText` to preflight text content before rendering, storing, or linting. It's a diagnostic tool to help inform sanitization needs. Pass the report to `getRecommendedSanctifierOptions(report)` to auto-generate config flags for `summonSanctifier()`. It turns on what removes trash without losing meaning; `purgeEmojis` and `keyboardOnlyFilter` delete content, so you opt in to those yourself.
|
|
191
193
|
|
|
192
194
|
### `inspectTextDetailed(text: string, options?: { maxFindings?: number }): DetailedTrashReport`
|
|
193
195
|
|
|
@@ -299,6 +301,7 @@ The package ships a `text-sanctifier` command for CI and coding agents, which ca
|
|
|
299
301
|
```sh
|
|
300
302
|
npx text-sanctifier --help
|
|
301
303
|
npx text-sanctifier --version
|
|
304
|
+
npx text-sanctifier check
|
|
302
305
|
npx text-sanctifier check src docs "**/*.md"
|
|
303
306
|
npx text-sanctifier fix --dry-run src
|
|
304
307
|
npx text-sanctifier reveal notes.md
|
|
@@ -314,7 +317,8 @@ src/auth.js:40:3 U+202E bidi_control
|
|
|
314
317
|
2 findings in 2 of 31 files.
|
|
315
318
|
```
|
|
316
319
|
|
|
317
|
-
- `--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`.
|
|
318
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).
|
|
319
323
|
- `--max-findings N`: findings listed per file (default 100; `all` for no cap). Counts always cover every occurrence.
|
|
320
324
|
- `--strip-bom`: also report a BOM at offset 0, which is skipped by default.
|
|
@@ -351,7 +355,7 @@ skills/review/SKILL.md:2:3 U+FE00 run of 3 variation selectors
|
|
|
351
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"`).
|
|
352
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.
|
|
353
357
|
- With `--ai-config`, the message of the first result in a run describes the run and its decoded text.
|
|
354
|
-
- `--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`.
|
|
355
359
|
|
|
356
360
|
### pre-commit
|
|
357
361
|
|
|
@@ -361,13 +365,13 @@ The repository publishes a [pre-commit](https://pre-commit.com) hook manifest, s
|
|
|
361
365
|
# .pre-commit-config.yaml
|
|
362
366
|
repos:
|
|
363
367
|
- repo: https://github.com/iWhatty/text-sanctifier
|
|
364
|
-
rev: v1.3.
|
|
368
|
+
rev: v1.3.12 # a release tag
|
|
365
369
|
hooks:
|
|
366
370
|
- id: text-sanctifier # every staged text file
|
|
367
371
|
- id: text-sanctifier-ai-config # agent instruction files, with --ai-config
|
|
368
372
|
```
|
|
369
373
|
|
|
370
|
-
- `text-sanctifier` runs `text-sanctifier check` on staged text files. Use `exclude:` for files that legitimately contain these characters, or `args: [--strip-bom]` and the other `check` options.
|
|
374
|
+
- `text-sanctifier` runs `text-sanctifier check` on staged text files, as pre-commit defines them: it can drop a file with control bytes near its start before the hook sees it, so scan the repository with `check` as well if that matters to you. Use `exclude:` for files that legitimately contain these characters, or `args: [--strip-bom]` and the other `check` options.
|
|
371
375
|
- `text-sanctifier-ai-config` runs `text-sanctifier check --ai-config` on staged files that match the `--ai-config` file list.
|
|
372
376
|
- A hook fails the commit when it finds something (exit code 1). Neither hook changes files; run `text-sanctifier fix` yourself.
|
|
373
377
|
|
|
@@ -383,7 +387,8 @@ Rewrites files in place and reports what it replaced and removed.
|
|
|
383
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.
|
|
384
388
|
- `--strip-bom`: also remove a BOM at offset 0. It is kept by default.
|
|
385
389
|
- `--dry-run`: write nothing; report what would change.
|
|
386
|
-
- `--
|
|
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.
|
|
387
392
|
|
|
388
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`.
|
|
389
394
|
|
|
@@ -399,10 +404,14 @@ git diff | npx text-sanctifier reveal
|
|
|
399
404
|
### Inputs
|
|
400
405
|
|
|
401
406
|
- Paths can be files, directories or globs (`*`, `**`, `?`, `[...]`, `{a,b}`). Quote globs so the shell passes them through.
|
|
402
|
-
- 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.
|
|
403
|
-
-
|
|
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.
|
|
410
|
+
- UTF-16 files with a BOM are decoded and checked. `fix` never rewrites them and lists them under `skipped`.
|
|
404
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`.
|
|
405
|
-
-
|
|
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.
|
|
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.
|
|
406
415
|
|
|
407
416
|
### Exit codes
|
|
408
417
|
|
|
@@ -410,7 +419,7 @@ git diff | npx text-sanctifier reveal
|
|
|
410
419
|
| --- | --- | --- | --- |
|
|
411
420
|
| 0 | nothing found | done (or nothing to do) | done |
|
|
412
421
|
| 1 | findings | `--dry-run` with changes pending | not used |
|
|
413
|
-
| 2 | usage error, missing path or
|
|
422
|
+
| 2 | usage error, missing or unreadable input, a path matching no files, or nothing checked | same, or an input you named that it refused to rewrite | same as `check` |
|
|
414
423
|
|
|
415
424
|
---
|
|
416
425
|
|
package/bin/text-sanctifier.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// Plain Node (>=18), no dependencies. Exit codes: 0 clean, 1 findings (check,
|
|
11
11
|
// or fix --dry-run with changes pending), 2 usage or I/O error.
|
|
12
12
|
|
|
13
|
-
import { readFileSync, writeFileSync, statSync,
|
|
13
|
+
import { readFileSync, writeFileSync, statSync, fstatSync, readdirSync } from 'node:fs';
|
|
14
14
|
import { resolve, join, sep, relative } from 'node:path';
|
|
15
15
|
import { spawnSync } from 'node:child_process';
|
|
16
16
|
import { pathToFileURL } from 'node:url';
|
|
@@ -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}
|
|
@@ -32,12 +36,17 @@ Usage:
|
|
|
32
36
|
text-sanctifier reveal [paths...]
|
|
33
37
|
|
|
34
38
|
Paths may be files, directories or globs (quote globs so the shell leaves them
|
|
35
|
-
alone). "-"
|
|
36
|
-
|
|
39
|
+
alone). "-" reads stdin. With no paths, check reads piped input, or scans the current
|
|
40
|
+
directory when stdin is a terminal, the null device or empty. Fix and reveal read
|
|
41
|
+
piped input. Directories and globs skip files ignored by git (inside
|
|
42
|
+
a git work tree). Binary files are skipped; files that are text apart from a stray
|
|
43
|
+
NUL or bad byte are not, and UTF-16 files with a BOM are decoded. A path or glob that
|
|
44
|
+
matches no files, or a run that checks nothing, exits 2.
|
|
37
45
|
|
|
38
46
|
check options:
|
|
39
47
|
--format F text (default), json or sarif (SARIF 2.1.0)
|
|
40
48
|
--json same as --format json
|
|
49
|
+
--exclude GLOB exclude matching paths (repeatable)
|
|
41
50
|
--max-findings N findings listed per file (default 100, "all" for no cap)
|
|
42
51
|
--strip-bom also report a BOM at the start of a file
|
|
43
52
|
--ai-config check agent instruction files (CLAUDE.md, AGENTS.md,
|
|
@@ -54,6 +63,7 @@ fix options:
|
|
|
54
63
|
--normalize-newlines convert CRLF and CR to LF
|
|
55
64
|
--strip-bom also remove a BOM at the start of a file (kept by default)
|
|
56
65
|
--dry-run report what would change; write nothing
|
|
66
|
+
--exclude GLOB exclude matching paths (repeatable)
|
|
57
67
|
--format F text (default) or json
|
|
58
68
|
--json same as --format json
|
|
59
69
|
With no cleaning flag, fix uses --normalize-spaces --purge-invisible --nuke-controls.
|
|
@@ -77,6 +87,8 @@ const BOM = 0xFEFF;
|
|
|
77
87
|
const STDIN = '-';
|
|
78
88
|
const STDIN_LABEL = '<stdin>';
|
|
79
89
|
const BINARY_SNIFF_BYTES = 8000;
|
|
90
|
+
const BINARY_CONTROL_SHARE = 0.05;
|
|
91
|
+
const BINARY_MIN_ASCII_CONTROLS = 8;
|
|
80
92
|
const WALK_SKIP_DIRS = new Set(['.git', 'node_modules']);
|
|
81
93
|
const CASE_INSENSITIVE_FS = process.platform === 'win32' || process.platform === 'darwin';
|
|
82
94
|
|
|
@@ -106,6 +118,7 @@ function parseArgs(argv) {
|
|
|
106
118
|
const opts = {
|
|
107
119
|
command: null,
|
|
108
120
|
paths: [],
|
|
121
|
+
excludes: [],
|
|
109
122
|
format: null,
|
|
110
123
|
aiConfig: false,
|
|
111
124
|
stripBom: false,
|
|
@@ -134,6 +147,14 @@ function parseArgs(argv) {
|
|
|
134
147
|
if (arg === '-v' || arg === '--version') { opts.version = true; continue; }
|
|
135
148
|
|
|
136
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
|
+
}
|
|
137
158
|
if (name === '--max-findings') {
|
|
138
159
|
const value = inline !== undefined ? inline : argv[++i];
|
|
139
160
|
opts.maxFindings = parseMaxFindings(value);
|
|
@@ -160,6 +181,7 @@ function parseArgs(argv) {
|
|
|
160
181
|
throw new UsageError(opts.command === null ? 'Missing command.' : `Unknown command: ${opts.command}`);
|
|
161
182
|
}
|
|
162
183
|
if (opts.command === 'reveal') {
|
|
184
|
+
if (opts.excludes.length) throw new UsageError('--exclude does not apply to reveal.');
|
|
163
185
|
for (const [flag, key] of Object.entries(BOOLEAN_FLAGS)) {
|
|
164
186
|
if (opts[key]) throw new UsageError(`${flag} does not apply to reveal.`);
|
|
165
187
|
}
|
|
@@ -171,6 +193,7 @@ function parseArgs(argv) {
|
|
|
171
193
|
if (opts.command === 'check' && FIX_ONLY_FLAGS.has(key) && opts[key]) throw new UsageError(`${flag} only applies to fix.`);
|
|
172
194
|
if (opts.command === 'fix' && CHECK_ONLY_FLAGS.has(key) && opts[key]) throw new UsageError(`${flag} only applies to check.`);
|
|
173
195
|
}
|
|
196
|
+
if (opts.aiConfig && opts.excludes.length) throw new UsageError('--exclude does not apply to --ai-config.');
|
|
174
197
|
if (opts.format === null) opts.format = 'text';
|
|
175
198
|
if (opts.command === 'fix' && opts.format === 'sarif') throw new UsageError('--format sarif only applies to check.');
|
|
176
199
|
if (opts.command === 'fix' && !opts.normalizeSpaces && !opts.purgeInvisible && !opts.nukeControls && !opts.normalizeNewlines) {
|
|
@@ -206,21 +229,36 @@ function isGitWorkTree(cwd) {
|
|
|
206
229
|
}
|
|
207
230
|
|
|
208
231
|
/**
|
|
209
|
-
* Lists tracked and untracked-but-not-ignored files under
|
|
232
|
+
* Lists tracked and untracked-but-not-ignored files under the given pathspecs.
|
|
210
233
|
* Returns null when git cannot answer (for example a path outside the work tree).
|
|
211
234
|
* @param {string} cwd
|
|
212
|
-
* @param {string}
|
|
235
|
+
* @param {string[]} pathspecs
|
|
213
236
|
* @returns {string[]|null}
|
|
214
237
|
*/
|
|
215
|
-
function gitListFiles(cwd,
|
|
238
|
+
function gitListFiles(cwd, pathspecs) {
|
|
216
239
|
const r = spawnSync(
|
|
217
240
|
'git',
|
|
218
|
-
['ls-files', '-z', '--cached', '--others', '--exclude-standard', '--',
|
|
241
|
+
['ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', ...pathspecs],
|
|
219
242
|
{ cwd, encoding: 'utf8', maxBuffer: 1 << 28 }
|
|
220
243
|
);
|
|
221
244
|
if (r.error || r.status !== 0) return null;
|
|
222
|
-
// --cached lists tracked files deleted from the work tree
|
|
223
|
-
|
|
245
|
+
// --cached also lists tracked files deleted from the work tree, submodules and
|
|
246
|
+
// symlinks to directories; keep only what can be read as a file
|
|
247
|
+
return r.stdout.split('\0').filter((f) => {
|
|
248
|
+
if (!f) return false;
|
|
249
|
+
try { return statSync(resolve(cwd, f)).isFile(); } catch { return false; }
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Expands `{a,b}` into one glob per alternative: git pathspecs have no brace syntax.
|
|
255
|
+
* @param {string} glob
|
|
256
|
+
* @returns {string[]}
|
|
257
|
+
*/
|
|
258
|
+
function expandBraces(glob) {
|
|
259
|
+
const m = /\{([^{}]*)\}/.exec(glob);
|
|
260
|
+
if (!m) return [glob];
|
|
261
|
+
return m[1].split(',').flatMap((alt) => expandBraces(glob.slice(0, m.index) + alt + glob.slice(m.index + m[0].length)));
|
|
224
262
|
}
|
|
225
263
|
|
|
226
264
|
/**
|
|
@@ -257,7 +295,7 @@ function walk(cwd, dir) {
|
|
|
257
295
|
* Glob to RegExp for the non-git fallback: `**`, `*`, `?`, `[...]` and `{a,b}`.
|
|
258
296
|
* @param {string} glob
|
|
259
297
|
*/
|
|
260
|
-
function globToRegExp(glob) {
|
|
298
|
+
function globToRegExp(glob, flags = '') {
|
|
261
299
|
let re = '';
|
|
262
300
|
for (let i = 0; i < glob.length; i++) {
|
|
263
301
|
const c = glob[i];
|
|
@@ -288,9 +326,12 @@ function globToRegExp(glob) {
|
|
|
288
326
|
re += c.replace(/[.+^$()|\\\]}]/g, '\\$&');
|
|
289
327
|
}
|
|
290
328
|
}
|
|
291
|
-
return new RegExp(`^${re}
|
|
329
|
+
return new RegExp(`^${re}$`, flags);
|
|
292
330
|
}
|
|
293
331
|
|
|
332
|
+
/** @param {string} glob - an --exclude pattern */
|
|
333
|
+
const excludeRegExp = (glob) => globToRegExp(toSlash(glob).replace(/^\.\//, '').replace(/\/$/, ''), CASE_INSENSITIVE_FS ? 'i' : '');
|
|
334
|
+
|
|
294
335
|
/**
|
|
295
336
|
* @param {string} cwd
|
|
296
337
|
* @param {string} glob
|
|
@@ -331,14 +372,13 @@ const isAiConfigPath = (rel) => AI_CONFIG_RES.some((re) => re.test(rel));
|
|
|
331
372
|
* directories contribute only agent instruction files (AI_CONFIG_GLOBS).
|
|
332
373
|
* @param {string[]} args
|
|
333
374
|
* @param {string} cwd
|
|
334
|
-
* @param {(
|
|
375
|
+
* @param {(path: string|null, code: string, message: string) => void} addError
|
|
335
376
|
* @param {boolean} [aiConfig]
|
|
336
|
-
* @returns {
|
|
377
|
+
* @returns {Array<{ path: string, explicit: boolean }>}
|
|
337
378
|
*/
|
|
338
|
-
function collectInputs(args, cwd,
|
|
379
|
+
function collectInputs(args, cwd, addError, aiConfig = false) {
|
|
339
380
|
const inputs = [];
|
|
340
381
|
const seen = new Set();
|
|
341
|
-
let errors = 0;
|
|
342
382
|
let inGit;
|
|
343
383
|
|
|
344
384
|
const add = (path, explicit) => {
|
|
@@ -359,25 +399,24 @@ function collectInputs(args, cwd, warn, aiConfig = false) {
|
|
|
359
399
|
|
|
360
400
|
const isGlob = !stat && GLOB_CHARS.test(arg);
|
|
361
401
|
if (!stat && !isGlob) {
|
|
362
|
-
|
|
363
|
-
errors++;
|
|
402
|
+
addError(arg, 'not_found', `${arg}: no such file or directory`);
|
|
364
403
|
continue;
|
|
365
404
|
}
|
|
366
405
|
|
|
367
406
|
if (inGit === undefined) inGit = isGitWorkTree(cwd);
|
|
368
407
|
let listed =
|
|
369
|
-
(inGit && gitListFiles(cwd, isGlob ? `:(glob)${
|
|
408
|
+
(inGit && gitListFiles(cwd, isGlob ? expandBraces(toSlash(arg)).map((g) => `:(glob)${g}`) : [toSlash(arg)])) ||
|
|
370
409
|
(isGlob ? walkGlob(cwd, arg) : walk(cwd, arg));
|
|
371
410
|
|
|
372
411
|
if (aiConfig && !isGlob) {
|
|
373
412
|
const base = resolve(cwd, arg);
|
|
374
413
|
listed = listed.filter((f) => isAiConfigPath(toSlash(relative(base, resolve(cwd, f)))));
|
|
375
414
|
} else if (listed.length === 0) {
|
|
376
|
-
|
|
415
|
+
addError(arg, 'no_match', `${arg}: no files matched`);
|
|
377
416
|
}
|
|
378
417
|
for (const f of listed) add(f, false);
|
|
379
418
|
}
|
|
380
|
-
return
|
|
419
|
+
return inputs;
|
|
381
420
|
}
|
|
382
421
|
|
|
383
422
|
|
|
@@ -385,24 +424,63 @@ function collectInputs(args, cwd, warn, aiConfig = false) {
|
|
|
385
424
|
|
|
386
425
|
const utf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
|
387
426
|
const utf8Lenient = new TextDecoder('utf-8', { ignoreBOM: true });
|
|
427
|
+
const utf16 = new TextDecoder('utf-16le', { ignoreBOM: true });
|
|
388
428
|
|
|
389
429
|
/**
|
|
390
|
-
*
|
|
391
|
-
* BINARY_SNIFF_BYTES
|
|
392
|
-
* and set `lossy`, so one bad byte cannot hide the rest of a file from `check`.
|
|
430
|
+
* A NUL plus a share of control bytes that text does not have, in the first
|
|
431
|
+
* BINARY_SNIFF_BYTES. One stray NUL is not enough, or it would hide a file from `check`.
|
|
393
432
|
* @param {Uint8Array} bytes
|
|
433
|
+
* @param {number} [minControls] - fewest control bytes that count
|
|
434
|
+
*/
|
|
435
|
+
function looksBinary(bytes, minControls = 0) {
|
|
436
|
+
const sample = bytes.subarray(0, BINARY_SNIFF_BYTES);
|
|
437
|
+
if (!sample.includes(0)) return false;
|
|
438
|
+
let controls = 0;
|
|
439
|
+
for (const b of sample) if (b < 9 || (b > 13 && b < 32)) controls++;
|
|
440
|
+
return controls >= minControls && controls / sample.length > BINARY_CONTROL_SHARE;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Decodes UTF-8, or UTF-16 with a BOM (setting `wide`), keeping a leading BOM. With
|
|
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.
|
|
450
|
+
* @param {Buffer} bytes
|
|
394
451
|
* @param {boolean} sniff
|
|
395
|
-
* @returns {{ text: string|null, reason?: string, lossy?: boolean }}
|
|
452
|
+
* @returns {{ text: string|null, reason?: string, lossy?: boolean, wide?: boolean }}
|
|
396
453
|
*/
|
|
397
454
|
function decode(bytes, sniff) {
|
|
398
|
-
if (
|
|
455
|
+
if (bytes[0] === 0xFF && bytes[1] === 0xFE) return { text: utf16.decode(bytes), wide: true };
|
|
456
|
+
if (bytes[0] === 0xFE && bytes[1] === 0xFF) {
|
|
457
|
+
return { text: utf16.decode(Buffer.from(bytes.subarray(0, bytes.length & ~1)).swap16()), wide: true };
|
|
458
|
+
}
|
|
399
459
|
try {
|
|
400
|
-
|
|
401
|
-
|
|
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 };
|
|
463
|
+
} catch (e) {
|
|
464
|
+
if (!(e instanceof TypeError)) throw e; // too large for a string, not bad UTF-8
|
|
465
|
+
if (sniff && looksBinary(bytes)) return { text: null, reason: 'binary' };
|
|
402
466
|
return { text: utf8Lenient.decode(bytes), lossy: true };
|
|
403
467
|
}
|
|
404
468
|
}
|
|
405
469
|
|
|
470
|
+
/**
|
|
471
|
+
* True when stdin may hold input for check with no paths: anything but a terminal,
|
|
472
|
+
* the null device or an empty file. Pipes must be read to detect empty input;
|
|
473
|
+
* Windows pipes do not report isFIFO().
|
|
474
|
+
*/
|
|
475
|
+
function stdinHasInput() {
|
|
476
|
+
try {
|
|
477
|
+
const stat = fstatSync(0);
|
|
478
|
+
return !stat.isCharacterDevice() && !(stat.isFile() && stat.size === 0);
|
|
479
|
+
} catch {
|
|
480
|
+
return false;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
406
484
|
/** @returns {Promise<Buffer>} */
|
|
407
485
|
function readStdin() {
|
|
408
486
|
return new Promise((res, rej) => {
|
|
@@ -581,9 +659,9 @@ function sarifArtifact(label) {
|
|
|
581
659
|
/**
|
|
582
660
|
* Builds a SARIF 2.1.0 log with one result per listed finding; the rule is the category.
|
|
583
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
|
|
584
|
-
* @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
|
|
585
663
|
*/
|
|
586
|
-
function sarifLog(files, { cwd, total, filesScanned, skipped, succeeded }) {
|
|
664
|
+
function sarifLog(files, { cwd, total, filesScanned, skipped, excluded, succeeded }) {
|
|
587
665
|
const ruleIds = Object.keys(inspectTextDetailed('', { maxFindings: 0 }).counts);
|
|
588
666
|
const rules = ruleIds.map((id) => ({
|
|
589
667
|
id,
|
|
@@ -628,6 +706,7 @@ function sarifLog(files, { cwd, total, filesScanned, skipped, succeeded }) {
|
|
|
628
706
|
filesWithFindings: files.length,
|
|
629
707
|
truncated: files.filter((f) => f.truncated).map((f) => f.path),
|
|
630
708
|
skipped,
|
|
709
|
+
excluded,
|
|
631
710
|
},
|
|
632
711
|
}],
|
|
633
712
|
};
|
|
@@ -650,28 +729,61 @@ async function main(argv) {
|
|
|
650
729
|
} catch (e) {
|
|
651
730
|
if (!(e instanceof UsageError)) throw e;
|
|
652
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
|
+
}
|
|
653
735
|
return 2;
|
|
654
736
|
}
|
|
655
737
|
if (opts.help) { out(USAGE); return 0; }
|
|
656
738
|
if (opts.version) { out(VERSION); return 0; }
|
|
657
739
|
|
|
658
740
|
const cwd = process.cwd();
|
|
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
|
+
};
|
|
659
749
|
let paths = opts.paths;
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
750
|
+
/** @type {Buffer | undefined} */
|
|
751
|
+
let stdinBytes;
|
|
752
|
+
// check with no paths reads nonempty stdin once, otherwise scans the current
|
|
753
|
+
// directory. AI configuration discovery always defaults to the directory.
|
|
754
|
+
if (paths.length === 0 && opts.command === 'check') {
|
|
663
755
|
paths = ['.'];
|
|
756
|
+
if (!opts.aiConfig && stdinHasInput()) {
|
|
757
|
+
try {
|
|
758
|
+
stdinBytes = await readStdin();
|
|
759
|
+
} catch (e) {
|
|
760
|
+
addError(STDIN_LABEL, 'read_failed', `${STDIN_LABEL}: ${e.code || e.message}`, e.code);
|
|
761
|
+
if (opts.format === 'json') out(JSON.stringify({ errors }));
|
|
762
|
+
return 2;
|
|
763
|
+
}
|
|
764
|
+
if (stdinBytes.length > 0) paths = [STDIN];
|
|
765
|
+
}
|
|
664
766
|
} else if (paths.length === 0) {
|
|
665
767
|
if (process.stdin.isTTY) {
|
|
768
|
+
addError(null, 'usage', 'no paths given and nothing piped on stdin.', undefined, false);
|
|
666
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 }));
|
|
667
771
|
return 2;
|
|
668
772
|
}
|
|
669
773
|
paths = [STDIN];
|
|
670
774
|
}
|
|
671
775
|
|
|
672
|
-
const
|
|
673
|
-
const
|
|
674
|
-
|
|
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
|
+
});
|
|
675
787
|
/** @type {Array<{ path: string, reason: string }>} */
|
|
676
788
|
const skipped = [];
|
|
677
789
|
/** @type {string[]} */
|
|
@@ -682,38 +794,56 @@ async function main(argv) {
|
|
|
682
794
|
* @param {(label: string, text: string, input: { path: string, explicit: boolean }) => void} visit
|
|
683
795
|
*/
|
|
684
796
|
const forEachText = async (visit) => {
|
|
797
|
+
let visited = 0;
|
|
685
798
|
for (const input of inputs) {
|
|
686
799
|
const label = input.path === STDIN ? STDIN_LABEL : input.path;
|
|
687
800
|
let bytes;
|
|
688
801
|
try {
|
|
689
|
-
bytes = input.path === STDIN ? await readStdin() : readFileSync(resolve(cwd, input.path));
|
|
802
|
+
bytes = input.path === STDIN ? (stdinBytes ?? await readStdin()) : readFileSync(resolve(cwd, input.path));
|
|
690
803
|
} catch (e) {
|
|
691
|
-
|
|
692
|
-
errors++;
|
|
804
|
+
addError(label, 'read_failed', `${label}: ${e.code || e.message}`, e.code);
|
|
693
805
|
continue;
|
|
694
806
|
}
|
|
695
807
|
// Files that look binary are skipped. Stdin and AI configuration files are text
|
|
696
808
|
// by intent, so a NUL in one is a finding, not a reason to skip it.
|
|
697
|
-
|
|
698
|
-
|
|
809
|
+
let decoded;
|
|
810
|
+
try {
|
|
811
|
+
decoded = decode(bytes, input.path !== STDIN && !opts.aiConfig);
|
|
812
|
+
} catch (e) {
|
|
813
|
+
addError(label, 'decode_failed', `${label}: ${e.message}`);
|
|
814
|
+
continue;
|
|
815
|
+
}
|
|
816
|
+
const { text, lossy, wide } = decoded;
|
|
817
|
+
let { reason } = decoded;
|
|
818
|
+
// fix writes UTF-8 in place, so it never rewrites what it could not decode or
|
|
819
|
+
// what was not UTF-8. A directory scan also leaves any file with a NUL alone;
|
|
820
|
+
// a file you name is cleaned if it is otherwise UTF-8.
|
|
821
|
+
if (opts.command === 'fix' && !reason) {
|
|
822
|
+
if (lossy) reason = 'not UTF-8';
|
|
823
|
+
else if (wide) reason = 'UTF-16';
|
|
824
|
+
else if (!input.explicit && bytes.subarray(0, BINARY_SNIFF_BYTES).includes(0)) reason = 'binary';
|
|
825
|
+
}
|
|
826
|
+
if (reason) {
|
|
699
827
|
skipped.push({ path: label, reason });
|
|
700
|
-
if (input.explicit) warn(`${label}: skipped (${reason})`);
|
|
828
|
+
if (input.explicit || reason !== 'binary') warn(`${label}: skipped (${reason})`);
|
|
829
|
+
// Asked to clean this input and did not: that must not look like success
|
|
830
|
+
if (opts.command === 'fix' && input.explicit) addError(label, 'refused', `${label}: skipped (${reason})`, undefined, false);
|
|
701
831
|
continue;
|
|
702
832
|
}
|
|
703
833
|
if (lossy) {
|
|
704
|
-
// fix never rewrites bytes it could not decode
|
|
705
|
-
if (opts.command === 'fix') {
|
|
706
|
-
skipped.push({ path: label, reason: 'not UTF-8' });
|
|
707
|
-
warn(`${label}: skipped (not UTF-8)`);
|
|
708
|
-
continue;
|
|
709
|
-
}
|
|
710
834
|
notUtf8.push(label);
|
|
711
835
|
warn(`${label}: not valid UTF-8; checked with invalid bytes read as U+FFFD`);
|
|
712
836
|
}
|
|
837
|
+
visited++;
|
|
713
838
|
visit(label, text, input);
|
|
714
839
|
}
|
|
840
|
+
// A clean result must mean something was read. Finding no AI configuration files is
|
|
841
|
+
// an answer; --ai-config reports that itself.
|
|
842
|
+
if (visited === 0 && errors.length === 0 && !opts.aiConfig) addError(null, 'nothing_checked', 'Nothing checked.', undefined, false);
|
|
715
843
|
};
|
|
716
844
|
|
|
845
|
+
const skippedNote = () => (skipped.length > 0 ? `; ${plural(skipped.length, 'file')} skipped` : '') + (excluded.length > 0 ? `; ${plural(excluded.length, 'file')} excluded` : '');
|
|
846
|
+
|
|
717
847
|
if (opts.command === 'check') {
|
|
718
848
|
const files = [];
|
|
719
849
|
let scanned = 0;
|
|
@@ -754,21 +884,22 @@ async function main(argv) {
|
|
|
754
884
|
|
|
755
885
|
const kind = opts.aiConfig ? 'AI configuration file' : 'file';
|
|
756
886
|
if (opts.format === 'json') {
|
|
757
|
-
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 };
|
|
758
888
|
if (opts.aiConfig) summary.hiddenRunCount = hiddenRunCount;
|
|
759
889
|
out(JSON.stringify(summary, null, 2));
|
|
760
890
|
} else if (opts.format === 'sarif') {
|
|
761
|
-
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));
|
|
762
892
|
} else {
|
|
763
893
|
for (const line of lines) out(line);
|
|
764
|
-
if (opts.aiConfig && scanned === 0 && skipped.length === 0) {
|
|
894
|
+
if (opts.aiConfig && scanned === 0 && skipped.length === 0 && errors.length === 0) {
|
|
765
895
|
out('No AI configuration files found.');
|
|
766
896
|
if (opts.paths.length === 0 && !process.stdin.isTTY) out('To check piped text, pass "-": text-sanctifier check --ai-config -');
|
|
767
897
|
}
|
|
768
|
-
else if (
|
|
769
|
-
else
|
|
898
|
+
else if (scanned === 0) out(`Nothing checked${skippedNote()}.`);
|
|
899
|
+
else if (total === 0) out(`No ${opts.aiConfig ? 'hidden text, ' : ''}invisible or control characters in ${plural(scanned, kind)}${skippedNote()}.`);
|
|
900
|
+
else out(`${plural(total, 'finding')}${opts.aiConfig ? ` (${plural(hiddenRunCount, 'hidden run')})` : ''} in ${files.length} of ${plural(scanned, kind)}${skippedNote()}.`);
|
|
770
901
|
}
|
|
771
|
-
return errors > 0 ? 2 : total > 0 ? 1 : 0;
|
|
902
|
+
return errors.length > 0 ? 2 : total > 0 ? 1 : 0;
|
|
772
903
|
}
|
|
773
904
|
|
|
774
905
|
if (opts.command === 'reveal') {
|
|
@@ -781,7 +912,7 @@ async function main(argv) {
|
|
|
781
912
|
}
|
|
782
913
|
process.stdout.write(revealInvisible(text));
|
|
783
914
|
});
|
|
784
|
-
return errors > 0 ? 2 : 0;
|
|
915
|
+
return errors.length > 0 ? 2 : 0;
|
|
785
916
|
}
|
|
786
917
|
|
|
787
918
|
// fix
|
|
@@ -839,8 +970,7 @@ async function main(argv) {
|
|
|
839
970
|
try {
|
|
840
971
|
writeFileSync(resolve(cwd, input.path), cleaned, 'utf8');
|
|
841
972
|
} catch (e) {
|
|
842
|
-
|
|
843
|
-
errors++;
|
|
973
|
+
addError(label, 'write_failed', `${label}: ${e.code || e.message}`, e.code);
|
|
844
974
|
return;
|
|
845
975
|
}
|
|
846
976
|
}
|
|
@@ -848,7 +978,7 @@ async function main(argv) {
|
|
|
848
978
|
});
|
|
849
979
|
|
|
850
980
|
if (opts.format === 'json') {
|
|
851
|
-
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));
|
|
852
982
|
} else {
|
|
853
983
|
const verb = opts.dryRun ? 'would fix' : 'fixed';
|
|
854
984
|
for (const f of files) {
|
|
@@ -858,11 +988,13 @@ async function main(argv) {
|
|
|
858
988
|
if (f.newlinesNormalized) parts.push('normalized newlines');
|
|
859
989
|
report(`${verb} ${f.path}: ${parts.join('; ')}`);
|
|
860
990
|
}
|
|
861
|
-
report(
|
|
862
|
-
? `Nothing
|
|
863
|
-
:
|
|
991
|
+
report(scanned === 0
|
|
992
|
+
? `Nothing checked${skippedNote()}.`
|
|
993
|
+
: files.length === 0
|
|
994
|
+
? `Nothing to fix in ${plural(scanned, 'file')}${skippedNote()}.`
|
|
995
|
+
: `${opts.dryRun ? 'Would change' : 'Changed'} ${files.length} of ${plural(scanned, 'file')}${skippedNote()}.`);
|
|
864
996
|
}
|
|
865
|
-
return errors > 0 ? 2 : opts.dryRun && files.length > 0 ? 1 : 0;
|
|
997
|
+
return errors.length > 0 ? 2 : opts.dryRun && files.length > 0 ? 1 : 0;
|
|
866
998
|
}
|
|
867
999
|
|
|
868
1000
|
main(process.argv.slice(2)).then(
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
var y=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]+/g,
|
|
2
|
-
`||n==="\r"||n>=" "&&n<="~")&&!m(
|
|
3
|
-
`){return e.replace(
|
|
1
|
+
var y=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]+/g,S=/[\u00A0\u00AD\u034F\u061C\u115F\u1160\u17B4\u17B5\u180B-\u180F\u2000-\u200F\u202A-\u202F\u205F-\u206F\u3000\u3164\uFE00-\uFE0F\uFEFF\uFFA0\uFFF0-\uFFF8\u{1BCA0}-\u{1BCA3}\u{1D173}-\u{1D17A}\u{E0000}-\u{E0FFF}]+/gu;function I(e){return e<8192?v(e):B(e)}function v(e){return e<174?e<32?e<11?e<1?"nul":e<9?"c0_control":null:e<13?"c0_control":e<14?null:"c0_control":e<160?e<127?null:e<128?"c0_control":"c1_control":e<161?"nonbreaking_space":e<173?null:"invisible_format":e<4449?e<1564?e<847?null:e<848?"invisible_format":null:e<1565?"bidi_control":e<4447?null:"hangul_filler":e<6155?e<6068?null:e<6070?"invisible_format":null:e<6159?e<6158?"variation_selector":"invisible_format":e<6160?"variation_selector":null}function B(e){return e<8304?e<8240?e<8208?e<8203?"nonbreaking_space":e<8206?"zero_width":"bidi_control":e<8234?null:e<8239?"bidi_control":"nonbreaking_space":e<8289?e<8287?null:e<8288?"nonbreaking_space":"zero_width":e<8294?"invisible_format":e<8298?"bidi_control":"invisible_format":e<65040?e<12644?e<12288?null:e<12289?"nonbreaking_space":null:e<12645?"hangul_filler":e<65024?null:"variation_selector":e<65440?e<65279?null:e<65280?"bom":null:e<65520?e<65441?"hangul_filler":null:e<65529?"invisible_format":null}function b(e){return e<917504?e<113828?e<113824?null:"invisible_format":e<119155?null:e<119163?"invisible_format":null:e<917760?e<917632?"tag":"invisible_format":e<918e3?"variation_selector":e<921600?"invisible_format":null}var g=/[^\x20-\x7E\r\n]+/gu,G=/[\u2018\u2019\u201A\u201B\u2032\u2035]/g,X=/[\u201C\u201D\u201E\u201F\u2033\u2036\u00AB\u00BB]/g,M=/[\u2012\u2013\u2014\u2015\u2212]/g,O=/\u2026/g,k=/[\u2022\u00B7]/g,P=/[\uFF01-\uFF5E]/g;function d(e){return e.replace(G,"'").replace(X,'"').replace(M,"-").replace(O,"...").replace(k,"*").replace(P,r=>String.fromCharCode(r.charCodeAt(0)-65248))}function U(){try{return new RegExp("(?:\\p{Extended_Pictographic}(?:\\uFE0F|\\uFE0E)?(?:\\u200D(?:\\p{Extended_Pictographic}|\\w)+)*)","gu")}catch{return/[\u{2700}-\u{27BF}\u{1F300}-\u{1FAFF}\u{1F3FB}-\u{1F3FF}\u200D\uFE0F\u{1F1E6}-\u{1F1FF}]/gu}}var p=U();function m(e,r){return e.lastIndex=0,e.test(r)}function z(e){let r=d(e);if(!m(g,r))return!1;for(let n of r)if(!(n===`
|
|
2
|
+
`||n==="\r"||n>=" "&&n<="~")&&!m(p,n))return!0;return!1}function j(e){if(typeof e!="string")throw new TypeError("inspectText expects a string input.");let r=[],n={hasControlChars:!1,hasInvisibleChars:!1,hasMixedNewlines:!1,newlineStyle:null,hasEmojis:!1,hasNonKeyboardChars:!1,summary:r},u=(l,c,f)=>{l&&(n[c]=!0,r.push(f))};u(m(y,e),"hasControlChars","Control characters detected."),u(m(S,e),"hasInvisibleChars","Invisible Unicode characters detected."),u(m(p,e),"hasEmojis","Emojis detected.");let{mixed:o,types:i}=R(e);return n.hasMixedNewlines=o,n.newlineStyle=o?"Mixed":i[0]||null,n.newlineStyle&&r.push(o?"Mixed newline styles detected.":`Consistent newline style: ${n.newlineStyle}`),u(z(e),"hasNonKeyboardChars","Non-keyboard characters detected."),n}var K=100;function A(e,r={}){if(typeof e!="string")throw new TypeError("inspectTextDetailed expects a string input.");let n=r&&r.maxFindings,u=n===1/0||Number.isInteger(n)&&n>=0?n:K,o={bidi_control:0,zero_width:0,nonbreaking_space:0,bom:0,nul:0,c0_control:0,c1_control:0,invisible_format:0,hangul_filler:0,variation_selector:0,tag:0},i=[],l=0,c=1,f=0;for(let a=0,h=e.length;a<h;a++){let s=e.charCodeAt(a);if(s>=32&&s<127)continue;if(s===10){c++,f=a+1;continue}if(s===13){e.charCodeAt(a+1)!==10&&(c++,f=a+1);continue}let x=a,F=s,E;if(s>=55296&&s<=56319){let t=e.charCodeAt(a+1);if(!(t>=56320&&t<=57343))continue;F=65536+(s-55296<<10)+(t-56320),E=b(F),a++}else E=I(s);if(E!==null&&(o[E]++,l++,i.length<u)){let t={offset:x,line:c,column:x-f+1,codePoint:F,category:E};E==="bom"&&(t.atStart=x===0),i.push(t)}}return{findings:i,counts:o,total:l,truncated:l>i.length}}function R(e){if(typeof e!="string")throw new TypeError("getNewlineStats expects a string input.");let r=0,n=0,u=0;for(let l=0,c=e.length;l<c;l++){let f=e.charCodeAt(l);f===13?e.charCodeAt(l+1)===10?(r++,l++):n++:f===10&&u++}let o={crlf:r,cr:n,lf:u},i=[];return o.crlf>0&&i.push("CRLF"),o.cr>0&&i.push("CR"),o.lf>0&&i.push("LF"),{...o,types:i,mixed:i.length>1}}function H(e){return{normalizeSpaces:e.hasInvisibleChars,purgeInvisibleChars:e.hasInvisibleChars,purgeEmojis:!1,nukeControls:e.hasControlChars,keyboardOnlyFilter:!1,normalizeNewlines:e.hasMixedNewlines||e.newlineStyle==="CRLF"||e.newlineStyle==="CR"}}function W(e={}){let r=!!e.normalizeSpaces,n=!!e.purgeZeroWidthSpaces,u=!!e.purgeInvisibleChars,o=!!e.purgeEmojis,i=!!e.nukeControls,l=!!e.keyboardOnlyFilter,c=!!e.normalizeNewlines,f=!!e.trimSpacesAroundNewlines,a=!!e.collapseNewLines,h=!!e.preserveParagraphs,s=!!e.collapseSpaces,x=!!e.finalTrim,F=Number.isFinite(e.maxLength)&&e.maxLength>=0?e.maxLength:1/0,E=e.onMaxLength==="truncate"||e.onMaxLength==="noop"||e.onMaxLength==="throw"?e.onMaxLength:"throw";return t=>_(t,u,o,i,l,c,f,a,h,s,x,F,E,r,n)}function w(e){return _(e,!0,!0,!0,!1,!0,!0,!0,!1,!0,!0)}function N(e){return _(e,!1,!1,!1,!1,!0,!0,!0,!0,!0,!0)}function T(e){return _(e,!1,!1,!1,!0,!0,!0,!1,!1,!1,!0)}function L(e){return _(e,!0,!0,!0,!0,!0,!0,!0,!1,!0,!0)}function D(e){return _(e,!1,!1,!1,!1,!0,!1,!1,!1,!1,!1,1/0,"throw",!0,!0)}var J=Object.assign(W,{strict:w,loose:N,keyboardOnlyEmoji:T,keyboardOnly:L,pasted:D});function _(e,r=!1,n=!1,u=!1,o=!1,i=!1,l=!1,c=!1,f=!1,a=!1,h=!1,s=1/0,x="throw",F=!1,E=!1){if(typeof e!="string")throw new TypeError("sanctifyText expects a string input.");if(e.length>s)switch(x){case"truncate":{let C=e.charCodeAt(s-1);e=e.slice(0,C>=55296&&C<=56319?s-1:s)}break;case"noop":return e;default:throw new RangeError(`sanctifyText input length ${e.length} exceeds maxLength ${s}.`)}let t=e.replace($,"\uFFFD");return F&&(t=t.replace(V," ")),E&&(t=t.replace(Z,"")),r&&(t=Y(t)),n&&(t=ee(t)),u&&(t=fe(t)),o&&(t=Q(t,n)),i&&(t=ne(t)),l&&(t=ie(t)),c&&(t=se(t,f)),a&&(t=ae(t)),h?t.trim():t}var V=/[\u00A0\u2000-\u200A\u202F\u205F]/g,$=/[\uD800-\uDFFF]/gu,Z=/[\u200B\u2060\uFEFF]/g;function Y(e){return e.replace(S,"")}function q(e){if(p.lastIndex=0,!p.test(e))return"";p.lastIndex=0;let r="";for(let n of e.matchAll(p))r+=n[0];return r}function Q(e,r=!1){let n=d(e);return r?n.replace(g,""):n.replace(g,u=>q(u))}function ee(e){return e.replace(p,"")}var re=/\r\n|\r|\n/g;function ne(e,r=`
|
|
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
|
-
`):e.replace(
|
|
6
|
-
`)}var
|
|
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.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};
|
|
@@ -43,7 +43,10 @@ export function inspectTextDetailed(text: string, options?: InspectDetailedOptio
|
|
|
43
43
|
*/
|
|
44
44
|
export function getNewlineStats(text: string): NewlineStats;
|
|
45
45
|
/**
|
|
46
|
-
* Creates defaultOptions for summonSanctifier based on inspectText result
|
|
46
|
+
* Creates defaultOptions for summonSanctifier based on inspectText result.
|
|
47
|
+
* Recommends only what removes trash without losing meaning: non-ASCII text and emoji are
|
|
48
|
+
* content, so `purgeEmojis` and `keyboardOnlyFilter` stay opt-in, and look-alike spaces
|
|
49
|
+
* become ordinary spaces so that words do not run together.
|
|
47
50
|
* @param {!UnicodeTrashReport} report
|
|
48
51
|
* @return {!SanctifyOptions}
|
|
49
52
|
*/
|
|
@@ -68,7 +68,7 @@ export function sanctifyPasted(text: string): string;
|
|
|
68
68
|
* @param {number} [maxLength=Infinity] - Hard input length cap (UTF-16 code units via `text.length`).
|
|
69
69
|
* @param {'throw'|'truncate'|'noop'} [onMaxLength='throw'] - Behavior when `text.length` exceeds `maxLength`:
|
|
70
70
|
* - `'throw'`: throw a `RangeError`
|
|
71
|
-
* - `'truncate'`: slice to `maxLength` before processing
|
|
71
|
+
* - `'truncate'`: slice to at most `maxLength` before processing, never through a surrogate pair
|
|
72
72
|
* - `'noop'`: return the original input unchanged (skips processing)
|
|
73
73
|
* @param {boolean} [normalizeSpaces=false] - Replace look-alike spaces with an ordinary space. Runs first,
|
|
74
74
|
* so `purgeInvisibleChars` no longer joins the words around them.
|
|
@@ -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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "text-sanctifier",
|
|
3
|
-
"version": "1.3.
|
|
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": {
|
|
@@ -48,15 +48,17 @@
|
|
|
48
48
|
"build": "node scripts/build.js",
|
|
49
49
|
"build:types": "tsc -p tsconfig.types.json",
|
|
50
50
|
"test:types": "tsc -p tests/types/tsconfig.json",
|
|
51
|
-
"test": "node tests/sanctifyText.test.js && node tests/inspectTextDetailed.test.js && node --expose-gc tests/hiddenText.test.js && node --expose-gc tests/context.test.js && node tests/cli.test.js && node tests/release.test.js",
|
|
51
|
+
"test": "node tests/sanctifyText.test.js && node tests/inspectTextDetailed.test.js && node --expose-gc tests/hiddenText.test.js && node --expose-gc tests/context.test.js && node tests/cli.test.js && node tests/release.test.js && node tests/names.test.js && node tests/docs.test.js && node tests/repo.test.js",
|
|
52
52
|
"test-min": "node tests/sanctifyText.test.min.js",
|
|
53
53
|
"check:self": "node bin/text-sanctifier.js check .",
|
|
54
54
|
"check:names": "node scripts/check-names.js",
|
|
55
|
-
"
|
|
55
|
+
"check:repo": "node scripts/check-repo.js",
|
|
56
|
+
"ci": "npm run gen:check && npm run check:names && npm run check:repo && npm run check:self && npm run test && npm run build && npm run size && npm run build:types && npm run test:types && npm run test-min",
|
|
56
57
|
"size": "node scripts/check-size.js",
|
|
57
58
|
"bench": "node --expose-gc scripts/bench.js",
|
|
58
59
|
"release:prep": "node scripts/release.js prep",
|
|
59
60
|
"release:verify": "node scripts/release.js verify",
|
|
61
|
+
"prepublishOnly": "node scripts/release.js check-publish",
|
|
60
62
|
"hooks:install": "git config core.hooksPath scripts/hooks"
|
|
61
63
|
},
|
|
62
64
|
"keywords": [
|
|
@@ -83,7 +85,7 @@
|
|
|
83
85
|
"test": "tests"
|
|
84
86
|
},
|
|
85
87
|
"devDependencies": {
|
|
86
|
-
"esbuild": "
|
|
87
|
-
"typescript": "
|
|
88
|
+
"esbuild": "0.25.12",
|
|
89
|
+
"typescript": "5.9.3"
|
|
88
90
|
}
|
|
89
91
|
}
|
package/src/inspectText.js
CHANGED
|
@@ -322,16 +322,20 @@ export function getNewlineStats(text) {
|
|
|
322
322
|
}
|
|
323
323
|
|
|
324
324
|
/**
|
|
325
|
-
* Creates defaultOptions for summonSanctifier based on inspectText result
|
|
325
|
+
* Creates defaultOptions for summonSanctifier based on inspectText result.
|
|
326
|
+
* Recommends only what removes trash without losing meaning: non-ASCII text and emoji are
|
|
327
|
+
* content, so `purgeEmojis` and `keyboardOnlyFilter` stay opt-in, and look-alike spaces
|
|
328
|
+
* become ordinary spaces so that words do not run together.
|
|
326
329
|
* @param {!UnicodeTrashReport} report
|
|
327
330
|
* @return {!SanctifyOptions}
|
|
328
331
|
*/
|
|
329
332
|
export function getRecommendedSanctifierOptions(report) {
|
|
330
333
|
return {
|
|
334
|
+
normalizeSpaces: report.hasInvisibleChars,
|
|
331
335
|
purgeInvisibleChars: report.hasInvisibleChars,
|
|
332
|
-
purgeEmojis:
|
|
336
|
+
purgeEmojis: false,
|
|
333
337
|
nukeControls: report.hasControlChars,
|
|
334
|
-
keyboardOnlyFilter:
|
|
338
|
+
keyboardOnlyFilter: false,
|
|
335
339
|
normalizeNewlines:
|
|
336
340
|
report.hasMixedNewlines || report.newlineStyle === 'CRLF' || report.newlineStyle === 'CR',
|
|
337
341
|
};
|
package/src/sanctifyText.js
CHANGED
|
@@ -294,7 +294,7 @@ export const summonSanctifier = /* @__PURE__ */ Object.assign(createSanctifier,
|
|
|
294
294
|
* @param {number} [maxLength=Infinity] - Hard input length cap (UTF-16 code units via `text.length`).
|
|
295
295
|
* @param {'throw'|'truncate'|'noop'} [onMaxLength='throw'] - Behavior when `text.length` exceeds `maxLength`:
|
|
296
296
|
* - `'throw'`: throw a `RangeError`
|
|
297
|
-
* - `'truncate'`: slice to `maxLength` before processing
|
|
297
|
+
* - `'truncate'`: slice to at most `maxLength` before processing, never through a surrogate pair
|
|
298
298
|
* - `'noop'`: return the original input unchanged (skips processing)
|
|
299
299
|
* @param {boolean} [normalizeSpaces=false] - Replace look-alike spaces with an ordinary space. Runs first,
|
|
300
300
|
* so `purgeInvisibleChars` no longer joins the words around them.
|
|
@@ -324,26 +324,27 @@ export function sanctifyText(
|
|
|
324
324
|
throw new TypeError('sanctifyText expects a string input.');
|
|
325
325
|
}
|
|
326
326
|
|
|
327
|
-
if (onMaxLength !== 'truncate' && onMaxLength !== 'noop' && onMaxLength !== 'throw') {
|
|
328
|
-
onMaxLength = 'throw';
|
|
329
|
-
}
|
|
330
|
-
|
|
331
327
|
if (text.length > maxLength) {
|
|
332
328
|
switch (onMaxLength) {
|
|
333
329
|
case 'truncate':
|
|
334
|
-
|
|
330
|
+
{
|
|
331
|
+
// Cut before a surrogate pair, not through it
|
|
332
|
+
const last = text.charCodeAt(maxLength - 1);
|
|
333
|
+
text = text.slice(0, last >= 0xD800 && last <= 0xDBFF ? maxLength - 1 : maxLength);
|
|
334
|
+
}
|
|
335
335
|
break;
|
|
336
336
|
case 'noop':
|
|
337
337
|
return text;
|
|
338
|
-
|
|
339
|
-
default:
|
|
338
|
+
default: // 'throw', and any invalid value
|
|
340
339
|
throw new RangeError(
|
|
341
340
|
`sanctifyText input length ${text.length} exceeds maxLength ${maxLength}.`
|
|
342
341
|
);
|
|
343
342
|
}
|
|
344
343
|
}
|
|
345
344
|
|
|
346
|
-
|
|
345
|
+
// Unpaired surrogates become U+FFFD first, so removing a character between two
|
|
346
|
+
// halves cannot join them into a new character, such as a hidden tag.
|
|
347
|
+
let cleaned = text.replace(LONE_SURROGATES_REGEX, '\uFFFD');
|
|
347
348
|
|
|
348
349
|
// Look-alike spaces become ordinary spaces, before anything can delete them
|
|
349
350
|
if (normalizeSpaces) cleaned = cleaned.replace(SPACE_LOOKALIKES_REGEX, ' ');
|
|
@@ -389,6 +390,9 @@ export function sanctifyText(
|
|
|
389
390
|
*/
|
|
390
391
|
export const SPACE_LOOKALIKES_REGEX = /[\u00A0\u2000-\u200A\u202F\u205F]/g;
|
|
391
392
|
|
|
393
|
+
/** With the `u` flag a surrogate pair is one character, so this matches only unpaired halves. */
|
|
394
|
+
const LONE_SURROGATES_REGEX = /[\uD800-\uDFFF]/gu;
|
|
395
|
+
|
|
392
396
|
/** Zero-width space, word joiner and BOM: invisible and never needed in plain text. */
|
|
393
397
|
export const ZERO_WIDTH_SPACES_REGEX = /[\u200B\u2060\uFEFF]/g;
|
|
394
398
|
|
|
@@ -497,9 +501,11 @@ function normalizeNewlineChars(text, normalized = '\n') {
|
|
|
497
501
|
* @param {string} text
|
|
498
502
|
* @returns {string}
|
|
499
503
|
*/
|
|
500
|
-
|
|
504
|
+
// The second branch consumes a run with no newline after it, so matching resumes past the
|
|
505
|
+
// run instead of retrying from each of its characters, which took quadratic time.
|
|
506
|
+
const TRIM_SPACES_AROUND_NEWLINES_REGEX = /[ \t]*(\n+)[ \t]*|([ \t]+)/g;
|
|
501
507
|
function trimSpacesAroundNewlineChars(text) {
|
|
502
|
-
return text.replace(TRIM_SPACES_AROUND_NEWLINES_REGEX, '$1');
|
|
508
|
+
return text.replace(TRIM_SPACES_AROUND_NEWLINES_REGEX, '$1$2');
|
|
503
509
|
}
|
|
504
510
|
|
|
505
511
|
|
package/src/version.generated.js
CHANGED
package/src/index.d.ts
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import { inspectText } from './inspectText.js';
|
|
2
|
-
import { inspectTextDetailed } from './inspectText.js';
|
|
3
|
-
import { getNewlineStats } from './inspectText.js';
|
|
4
|
-
import { getRecommendedSanctifierOptions } from './inspectText.js';
|
|
5
|
-
import { summonSanctifier } from './sanctifyText.js';
|
|
6
|
-
import { sanctifyStrict } from './sanctifyText.js';
|
|
7
|
-
import { sanctifyLoose } from './sanctifyText.js';
|
|
8
|
-
import { sanctifyKeyboardOnlyEmoji } from './sanctifyText.js';
|
|
9
|
-
import { sanctifyKeyboardOnly } from './sanctifyText.js';
|
|
10
|
-
import { sanctifyPasted } from './sanctifyText.js';
|
|
11
|
-
import { revealInvisible } from './reveal.js';
|
|
12
|
-
import { VERSION } from './version.generated.js';
|
|
13
|
-
export { inspectText, inspectTextDetailed, getNewlineStats, getRecommendedSanctifierOptions, summonSanctifier, sanctifyStrict, sanctifyLoose, sanctifyKeyboardOnlyEmoji, sanctifyKeyboardOnly, sanctifyPasted, revealInvisible, VERSION };
|
|
14
|
-
export type UnicodeTrashReport = import('./inspectText.js').UnicodeTrashReport;
|
|
15
|
-
export type TrashCategory = import('./inspectText.js').TrashCategory;
|
|
16
|
-
export type TrashFinding = import('./inspectText.js').TrashFinding;
|
|
17
|
-
export type TrashCounts = import('./inspectText.js').TrashCounts;
|
|
18
|
-
export type DetailedTrashReport = import('./inspectText.js').DetailedTrashReport;
|
|
19
|
-
export type InspectDetailedOptions = import('./inspectText.js').InspectDetailedOptions;
|
|
20
|
-
export type NewlineStats = import('./inspectText.js').NewlineStats;
|
|
21
|
-
export type SanctifyOptions = import('./sanctifyText.js').SanctifyOptions;
|
|
22
|
-
export type RevealOptions = import('./reveal.js').RevealOptions;
|