text-sanctifier 1.3.9 → 1.3.11
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 +91 -84
- package/bin/text-sanctifier.js +214 -69
- package/dist/text-sanctifier.min.js +5 -5
- package/dist/types/hiddenScan.d.ts +30 -0
- package/dist/types/hiddenText.d.ts +40 -0
- 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 +8 -6
- package/src/hiddenScan.js +171 -0
- package/src/hiddenText.js +4 -151
- 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/bin/text-sanctifier.js
CHANGED
|
@@ -10,14 +10,17 @@
|
|
|
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';
|
|
17
|
+
import { createHash } from 'node:crypto';
|
|
17
18
|
import { inspectTextDetailed, summonSanctifier, revealInvisible } from '../src/index.js';
|
|
18
19
|
import { getNewlineStats } from '../src/inspectText.js';
|
|
19
20
|
import { SPACE_LOOKALIKES_REGEX } from '../src/sanctifyText.js';
|
|
20
|
-
import {
|
|
21
|
+
import { scanHiddenSpans } from '../src/hiddenScan.js';
|
|
22
|
+
import { inspectTextInContext } from '../src/context.js';
|
|
23
|
+
import { classifyCodeUnit, classifyAstral } from '../src/categories.generated.js';
|
|
21
24
|
|
|
22
25
|
const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
23
26
|
|
|
@@ -29,8 +32,12 @@ Usage:
|
|
|
29
32
|
text-sanctifier reveal [paths...]
|
|
30
33
|
|
|
31
34
|
Paths may be files, directories or globs (quote globs so the shell leaves them
|
|
32
|
-
alone). "-"
|
|
33
|
-
|
|
35
|
+
alone). "-" reads stdin. With no paths, check reads piped input, or scans the current
|
|
36
|
+
directory when stdin is a terminal, the null device or empty. Fix and reveal read
|
|
37
|
+
piped input. Directories and globs skip files ignored by git (inside
|
|
38
|
+
a git work tree). Binary files are skipped; files that are text apart from a stray
|
|
39
|
+
NUL or bad byte are not, and UTF-16 files with a BOM are decoded. A path or glob that
|
|
40
|
+
matches no files, or a run that checks nothing, exits 2.
|
|
34
41
|
|
|
35
42
|
check options:
|
|
36
43
|
--format F text (default), json or sarif (SARIF 2.1.0)
|
|
@@ -46,6 +53,7 @@ fix options:
|
|
|
46
53
|
--normalize-spaces replace look-alike spaces (NBSP, narrow NBSP, typographic
|
|
47
54
|
spaces) with an ordinary space instead of removing them
|
|
48
55
|
--purge-invisible remove zero-width, non-breaking space, bidi and BOM characters
|
|
56
|
+
--keep-benign keep contextually legitimate joiners and selectors when purging
|
|
49
57
|
--nuke-controls remove C0/C1 control characters and bidi controls
|
|
50
58
|
--normalize-newlines convert CRLF and CR to LF
|
|
51
59
|
--strip-bom also remove a BOM at the start of a file (kept by default)
|
|
@@ -73,6 +81,7 @@ const BOM = 0xFEFF;
|
|
|
73
81
|
const STDIN = '-';
|
|
74
82
|
const STDIN_LABEL = '<stdin>';
|
|
75
83
|
const BINARY_SNIFF_BYTES = 8000;
|
|
84
|
+
const BINARY_CONTROL_SHARE = 0.05;
|
|
76
85
|
const WALK_SKIP_DIRS = new Set(['.git', 'node_modules']);
|
|
77
86
|
const CASE_INSENSITIVE_FS = process.platform === 'win32' || process.platform === 'darwin';
|
|
78
87
|
|
|
@@ -86,11 +95,12 @@ const BOOLEAN_FLAGS = {
|
|
|
86
95
|
'--strip-bom': 'stripBom',
|
|
87
96
|
'--normalize-spaces': 'normalizeSpaces',
|
|
88
97
|
'--purge-invisible': 'purgeInvisible',
|
|
98
|
+
'--keep-benign': 'keepBenign',
|
|
89
99
|
'--nuke-controls': 'nukeControls',
|
|
90
100
|
'--normalize-newlines': 'normalizeNewlines',
|
|
91
101
|
'--dry-run': 'dryRun',
|
|
92
102
|
};
|
|
93
|
-
const FIX_ONLY_FLAGS = new Set(['normalizeSpaces', 'purgeInvisible', 'nukeControls', 'normalizeNewlines', 'dryRun']);
|
|
103
|
+
const FIX_ONLY_FLAGS = new Set(['normalizeSpaces', 'purgeInvisible', 'keepBenign', 'nukeControls', 'normalizeNewlines', 'dryRun']);
|
|
94
104
|
const CHECK_ONLY_FLAGS = new Set(['aiConfig']);
|
|
95
105
|
const FORMATS = new Set(['text', 'json', 'sarif']);
|
|
96
106
|
|
|
@@ -106,6 +116,7 @@ function parseArgs(argv) {
|
|
|
106
116
|
stripBom: false,
|
|
107
117
|
normalizeSpaces: false,
|
|
108
118
|
purgeInvisible: false,
|
|
119
|
+
keepBenign: false,
|
|
109
120
|
nukeControls: false,
|
|
110
121
|
normalizeNewlines: false,
|
|
111
122
|
dryRun: false,
|
|
@@ -200,21 +211,36 @@ function isGitWorkTree(cwd) {
|
|
|
200
211
|
}
|
|
201
212
|
|
|
202
213
|
/**
|
|
203
|
-
* Lists tracked and untracked-but-not-ignored files under
|
|
214
|
+
* Lists tracked and untracked-but-not-ignored files under the given pathspecs.
|
|
204
215
|
* Returns null when git cannot answer (for example a path outside the work tree).
|
|
205
216
|
* @param {string} cwd
|
|
206
|
-
* @param {string}
|
|
217
|
+
* @param {string[]} pathspecs
|
|
207
218
|
* @returns {string[]|null}
|
|
208
219
|
*/
|
|
209
|
-
function gitListFiles(cwd,
|
|
220
|
+
function gitListFiles(cwd, pathspecs) {
|
|
210
221
|
const r = spawnSync(
|
|
211
222
|
'git',
|
|
212
|
-
['ls-files', '-z', '--cached', '--others', '--exclude-standard', '--',
|
|
223
|
+
['ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', ...pathspecs],
|
|
213
224
|
{ cwd, encoding: 'utf8', maxBuffer: 1 << 28 }
|
|
214
225
|
);
|
|
215
226
|
if (r.error || r.status !== 0) return null;
|
|
216
|
-
// --cached lists tracked files deleted from the work tree
|
|
217
|
-
|
|
227
|
+
// --cached also lists tracked files deleted from the work tree, submodules and
|
|
228
|
+
// symlinks to directories; keep only what can be read as a file
|
|
229
|
+
return r.stdout.split('\0').filter((f) => {
|
|
230
|
+
if (!f) return false;
|
|
231
|
+
try { return statSync(resolve(cwd, f)).isFile(); } catch { return false; }
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Expands `{a,b}` into one glob per alternative: git pathspecs have no brace syntax.
|
|
237
|
+
* @param {string} glob
|
|
238
|
+
* @returns {string[]}
|
|
239
|
+
*/
|
|
240
|
+
function expandBraces(glob) {
|
|
241
|
+
const m = /\{([^{}]*)\}/.exec(glob);
|
|
242
|
+
if (!m) return [glob];
|
|
243
|
+
return m[1].split(',').flatMap((alt) => expandBraces(glob.slice(0, m.index) + alt + glob.slice(m.index + m[0].length)));
|
|
218
244
|
}
|
|
219
245
|
|
|
220
246
|
/**
|
|
@@ -360,7 +386,7 @@ function collectInputs(args, cwd, warn, aiConfig = false) {
|
|
|
360
386
|
|
|
361
387
|
if (inGit === undefined) inGit = isGitWorkTree(cwd);
|
|
362
388
|
let listed =
|
|
363
|
-
(inGit && gitListFiles(cwd, isGlob ? `:(glob)${
|
|
389
|
+
(inGit && gitListFiles(cwd, isGlob ? expandBraces(toSlash(arg)).map((g) => `:(glob)${g}`) : [toSlash(arg)])) ||
|
|
364
390
|
(isGlob ? walkGlob(cwd, arg) : walk(cwd, arg));
|
|
365
391
|
|
|
366
392
|
if (aiConfig && !isGlob) {
|
|
@@ -368,6 +394,7 @@ function collectInputs(args, cwd, warn, aiConfig = false) {
|
|
|
368
394
|
listed = listed.filter((f) => isAiConfigPath(toSlash(relative(base, resolve(cwd, f)))));
|
|
369
395
|
} else if (listed.length === 0) {
|
|
370
396
|
warn(`${arg}: no files matched`);
|
|
397
|
+
errors++;
|
|
371
398
|
}
|
|
372
399
|
for (const f of listed) add(f, false);
|
|
373
400
|
}
|
|
@@ -379,24 +406,57 @@ function collectInputs(args, cwd, warn, aiConfig = false) {
|
|
|
379
406
|
|
|
380
407
|
const utf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
|
381
408
|
const utf8Lenient = new TextDecoder('utf-8', { ignoreBOM: true });
|
|
409
|
+
const utf16 = new TextDecoder('utf-16le', { ignoreBOM: true });
|
|
382
410
|
|
|
383
411
|
/**
|
|
384
|
-
*
|
|
385
|
-
* BINARY_SNIFF_BYTES
|
|
386
|
-
* and set `lossy`, so one bad byte cannot hide the rest of a file from `check`.
|
|
412
|
+
* A NUL plus a share of control bytes that text does not have, in the first
|
|
413
|
+
* BINARY_SNIFF_BYTES. One stray NUL is not enough, or it would hide a file from `check`.
|
|
387
414
|
* @param {Uint8Array} bytes
|
|
415
|
+
*/
|
|
416
|
+
function looksBinary(bytes) {
|
|
417
|
+
const sample = bytes.subarray(0, BINARY_SNIFF_BYTES);
|
|
418
|
+
if (!sample.includes(0)) return false;
|
|
419
|
+
let controls = 0;
|
|
420
|
+
for (const b of sample) if (b < 9 || (b > 13 && b < 32)) controls++;
|
|
421
|
+
return controls / sample.length > BINARY_CONTROL_SHARE;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Decodes UTF-8, or UTF-16 with a BOM (setting `wide`), keeping a leading BOM. With
|
|
426
|
+
* `sniff`, data that is not UTF-8 and looks binary is not decoded. Other bytes that are
|
|
427
|
+
* not UTF-8 become U+FFFD and set `lossy`, so one bad byte cannot hide the rest of a file.
|
|
428
|
+
* @param {Buffer} bytes
|
|
388
429
|
* @param {boolean} sniff
|
|
389
|
-
* @returns {{ text: string|null, reason?: string, lossy?: boolean }}
|
|
430
|
+
* @returns {{ text: string|null, reason?: string, lossy?: boolean, wide?: boolean }}
|
|
390
431
|
*/
|
|
391
432
|
function decode(bytes, sniff) {
|
|
392
|
-
if (
|
|
433
|
+
if (bytes[0] === 0xFF && bytes[1] === 0xFE) return { text: utf16.decode(bytes), wide: true };
|
|
434
|
+
if (bytes[0] === 0xFE && bytes[1] === 0xFF) {
|
|
435
|
+
return { text: utf16.decode(Buffer.from(bytes.subarray(0, bytes.length & ~1)).swap16()), wide: true };
|
|
436
|
+
}
|
|
393
437
|
try {
|
|
394
438
|
return { text: utf8.decode(bytes) };
|
|
395
|
-
} catch {
|
|
439
|
+
} catch (e) {
|
|
440
|
+
if (!(e instanceof TypeError)) throw e; // too large for a string, not bad UTF-8
|
|
441
|
+
if (sniff && looksBinary(bytes)) return { text: null, reason: 'binary' };
|
|
396
442
|
return { text: utf8Lenient.decode(bytes), lossy: true };
|
|
397
443
|
}
|
|
398
444
|
}
|
|
399
445
|
|
|
446
|
+
/**
|
|
447
|
+
* True when stdin may hold input for check with no paths: anything but a terminal,
|
|
448
|
+
* the null device or an empty file. Pipes must be read to detect empty input;
|
|
449
|
+
* Windows pipes do not report isFIFO().
|
|
450
|
+
*/
|
|
451
|
+
function stdinHasInput() {
|
|
452
|
+
try {
|
|
453
|
+
const stat = fstatSync(0);
|
|
454
|
+
return !stat.isCharacterDevice() && !(stat.isFile() && stat.size === 0);
|
|
455
|
+
} catch {
|
|
456
|
+
return false;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
400
460
|
/** @returns {Promise<Buffer>} */
|
|
401
461
|
function readStdin() {
|
|
402
462
|
return new Promise((res, rej) => {
|
|
@@ -458,46 +518,56 @@ const RUN_NOUNS = { tag: 'tag character', variation_selector: 'variation selecto
|
|
|
458
518
|
* @param {boolean} stripBom
|
|
459
519
|
*/
|
|
460
520
|
function inspectAiConfig(text, maxFindings, stripBom) {
|
|
461
|
-
const full = inspect(text,
|
|
462
|
-
const
|
|
463
|
-
|
|
464
|
-
const kept = [];
|
|
465
|
-
let b = 0;
|
|
466
|
-
for (const f of full.findings) {
|
|
467
|
-
while (b < benign.length && benign[b][1] <= f.offset) b++;
|
|
468
|
-
if (b < benign.length && benign[b][0] <= f.offset) { full.counts[f.category]--; continue; }
|
|
469
|
-
kept.push(f);
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
// Every run character is a finding, so each run starts at a kept finding
|
|
473
|
-
const hiddenRuns = [];
|
|
521
|
+
const full = inspect(text, 0, stripBom);
|
|
522
|
+
const findings = [];
|
|
474
523
|
const loose = [];
|
|
475
|
-
let
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
524
|
+
let looseCount = 0;
|
|
525
|
+
let position = 0, line = 1, lineStart = 0;
|
|
526
|
+
const scan = (end, inRun, benign) => {
|
|
527
|
+
while (position < end) {
|
|
528
|
+
const offset = position;
|
|
529
|
+
const codePoint = text.codePointAt(position);
|
|
530
|
+
position += codePoint > 0xFFFF ? 2 : 1;
|
|
531
|
+
if (codePoint === 10 || (codePoint === 13 && text.charCodeAt(position) !== 10)) {
|
|
532
|
+
line++; lineStart = position; continue;
|
|
533
|
+
}
|
|
534
|
+
const category = codePoint > 0xFFFF ? classifyAstral(codePoint) : classifyCodeUnit(codePoint);
|
|
535
|
+
if (!category || (offset === 0 && codePoint === BOM && !stripBom)) continue;
|
|
536
|
+
if (benign) { full.counts[category]--; full.total--; continue; }
|
|
537
|
+
if (findings.length < maxFindings || (!inRun && loose.length < maxFindings)) {
|
|
538
|
+
const f = { offset, line, column: offset - lineStart + 1, codePoint, category };
|
|
539
|
+
if (category === 'bom') f.atStart = offset === 0;
|
|
540
|
+
if (findings.length < maxFindings) findings.push(f);
|
|
541
|
+
if (!inRun && loose.length < maxFindings) loose.push(f);
|
|
542
|
+
}
|
|
543
|
+
if (!inRun) looseCount++;
|
|
544
|
+
}
|
|
545
|
+
};
|
|
546
|
+
// Decode beyond the cut point by one marker so slicing matches the original output.
|
|
547
|
+
const hidden = scanHiddenSpans(text, { maxRuns: maxFindings, maxDecodedLength: MAX_DECODED + 10 }, (span) => {
|
|
548
|
+
scan(span.offset, false, false);
|
|
549
|
+
scan(span.end, !span.benign, span.benign);
|
|
550
|
+
});
|
|
551
|
+
scan(text.length, false, false);
|
|
552
|
+
const hiddenRuns = hidden.runs.map((run) => {
|
|
553
|
+
const entry = { kind: run.kind, offset: run.offset, line: run.line, column: run.column, codePoint: text.codePointAt(run.offset), count: run.count };
|
|
482
554
|
if (run.decoded !== undefined) {
|
|
483
555
|
entry.decoded = run.decoded.slice(0, MAX_DECODED);
|
|
484
|
-
entry.decodedTruncated = run.decoded.length > MAX_DECODED;
|
|
556
|
+
entry.decodedTruncated = run.decodedTruncated || run.decoded.length > MAX_DECODED;
|
|
485
557
|
}
|
|
486
|
-
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
const findings = kept.slice(0, maxFindings);
|
|
558
|
+
return entry;
|
|
559
|
+
});
|
|
490
560
|
return {
|
|
491
561
|
report: {
|
|
492
562
|
...full,
|
|
493
563
|
findings,
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
hiddenRunCount: hiddenRuns.length,
|
|
564
|
+
truncated: full.total > findings.length,
|
|
565
|
+
hiddenRuns,
|
|
566
|
+
hiddenRunCount: hidden.total,
|
|
498
567
|
},
|
|
499
568
|
hiddenRuns,
|
|
500
569
|
loose,
|
|
570
|
+
entryCount: hidden.total + looseCount,
|
|
501
571
|
};
|
|
502
572
|
}
|
|
503
573
|
|
|
@@ -530,6 +600,30 @@ const CATEGORY_RULES = {
|
|
|
530
600
|
const SARIF_SCHEMA = 'https://json.schemastore.org/sarif-2.1.0.json';
|
|
531
601
|
const INFORMATION_URI = 'https://github.com/iWhatty/text-sanctifier';
|
|
532
602
|
const ABSOLUTE_PATH = /^(?:[a-zA-Z]:)?[\\/]/;
|
|
603
|
+
const fingerprints = new WeakMap();
|
|
604
|
+
|
|
605
|
+
function fingerprintFindings(label, text, findings, cwd) {
|
|
606
|
+
const path = label === STDIN_LABEL ? 'stdin' : toSlash(relative(cwd, resolve(cwd, label)));
|
|
607
|
+
let previousLine = 0, start = 0, end = 0;
|
|
608
|
+
let occurrences = new Map();
|
|
609
|
+
const hashOccurrences = new Map();
|
|
610
|
+
for (const finding of findings) {
|
|
611
|
+
if (finding.line !== previousLine) {
|
|
612
|
+
start = finding.offset - finding.column + 1;
|
|
613
|
+
end = start;
|
|
614
|
+
while (end < text.length && text.charCodeAt(end) !== 10 && text.charCodeAt(end) !== 13) end++;
|
|
615
|
+
occurrences = new Map();
|
|
616
|
+
previousLine = finding.line;
|
|
617
|
+
}
|
|
618
|
+
const index = occurrences.get(finding.category) || 0;
|
|
619
|
+
occurrences.set(finding.category, index + 1);
|
|
620
|
+
const hash = createHash('sha256').update(JSON.stringify([finding.category, path, text.slice(start, end), index])).digest('hex');
|
|
621
|
+
const count = (hashOccurrences.get(hash) || 0) + 1;
|
|
622
|
+
hashOccurrences.set(hash, count);
|
|
623
|
+
fingerprints.set(finding, count === 1 ? hash :
|
|
624
|
+
createHash('sha256').update(JSON.stringify([hash, count])).digest('hex'));
|
|
625
|
+
}
|
|
626
|
+
}
|
|
533
627
|
|
|
534
628
|
/** @param {string} label */
|
|
535
629
|
function sarifArtifact(label) {
|
|
@@ -559,6 +653,7 @@ function sarifLog(files, { cwd, total, filesScanned, skipped, succeeded }) {
|
|
|
559
653
|
const run = runAt.get(x.offset);
|
|
560
654
|
results.push({
|
|
561
655
|
ruleId: x.category,
|
|
656
|
+
partialFingerprints: { 'textSanctifier/v1': fingerprints.get(x) },
|
|
562
657
|
ruleIndex: ruleIds.indexOf(x.category),
|
|
563
658
|
level: CATEGORY_RULES[x.category][0],
|
|
564
659
|
message: { text: `${hex(x.codePoint)} ${x.category}${run ? `, start of a ${describeRun(run)}` : ''}` },
|
|
@@ -615,11 +710,23 @@ async function main(argv) {
|
|
|
615
710
|
if (opts.version) { out(VERSION); return 0; }
|
|
616
711
|
|
|
617
712
|
const cwd = process.cwd();
|
|
713
|
+
const warn = (msg) => err(`text-sanctifier: ${msg}`);
|
|
618
714
|
let paths = opts.paths;
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
715
|
+
/** @type {Buffer | undefined} */
|
|
716
|
+
let stdinBytes;
|
|
717
|
+
// check with no paths reads nonempty stdin once, otherwise scans the current
|
|
718
|
+
// directory. AI configuration discovery always defaults to the directory.
|
|
719
|
+
if (paths.length === 0 && opts.command === 'check') {
|
|
622
720
|
paths = ['.'];
|
|
721
|
+
if (!opts.aiConfig && stdinHasInput()) {
|
|
722
|
+
try {
|
|
723
|
+
stdinBytes = await readStdin();
|
|
724
|
+
} catch (e) {
|
|
725
|
+
warn(`${STDIN_LABEL}: ${e.code || e.message}`);
|
|
726
|
+
return 2;
|
|
727
|
+
}
|
|
728
|
+
if (stdinBytes.length > 0) paths = [STDIN];
|
|
729
|
+
}
|
|
623
730
|
} else if (paths.length === 0) {
|
|
624
731
|
if (process.stdin.isTTY) {
|
|
625
732
|
err('text-sanctifier: no paths given and nothing piped on stdin.\nRun "text-sanctifier --help" for usage.');
|
|
@@ -628,7 +735,6 @@ async function main(argv) {
|
|
|
628
735
|
paths = [STDIN];
|
|
629
736
|
}
|
|
630
737
|
|
|
631
|
-
const warn = (msg) => err(`text-sanctifier: ${msg}`);
|
|
632
738
|
const { inputs, errors: pathErrors } = collectInputs(paths, cwd, warn, opts.aiConfig);
|
|
633
739
|
let errors = pathErrors;
|
|
634
740
|
/** @type {Array<{ path: string, reason: string }>} */
|
|
@@ -641,11 +747,12 @@ async function main(argv) {
|
|
|
641
747
|
* @param {(label: string, text: string, input: { path: string, explicit: boolean }) => void} visit
|
|
642
748
|
*/
|
|
643
749
|
const forEachText = async (visit) => {
|
|
750
|
+
let visited = 0;
|
|
644
751
|
for (const input of inputs) {
|
|
645
752
|
const label = input.path === STDIN ? STDIN_LABEL : input.path;
|
|
646
753
|
let bytes;
|
|
647
754
|
try {
|
|
648
|
-
bytes = input.path === STDIN ? await readStdin() : readFileSync(resolve(cwd, input.path));
|
|
755
|
+
bytes = input.path === STDIN ? (stdinBytes ?? await readStdin()) : readFileSync(resolve(cwd, input.path));
|
|
649
756
|
} catch (e) {
|
|
650
757
|
warn(`${label}: ${e.code || e.message}`);
|
|
651
758
|
errors++;
|
|
@@ -653,26 +760,45 @@ async function main(argv) {
|
|
|
653
760
|
}
|
|
654
761
|
// Files that look binary are skipped. Stdin and AI configuration files are text
|
|
655
762
|
// by intent, so a NUL in one is a finding, not a reason to skip it.
|
|
656
|
-
|
|
657
|
-
|
|
763
|
+
let decoded;
|
|
764
|
+
try {
|
|
765
|
+
decoded = decode(bytes, input.path !== STDIN && !opts.aiConfig);
|
|
766
|
+
} catch (e) {
|
|
767
|
+
warn(`${label}: ${e.message}`);
|
|
768
|
+
errors++;
|
|
769
|
+
continue;
|
|
770
|
+
}
|
|
771
|
+
const { text, lossy, wide } = decoded;
|
|
772
|
+
let { reason } = decoded;
|
|
773
|
+
// fix writes UTF-8 in place, so it never rewrites what it could not decode or
|
|
774
|
+
// what was not UTF-8. A directory scan also leaves any file with a NUL alone;
|
|
775
|
+
// a file you name is cleaned if it is otherwise UTF-8.
|
|
776
|
+
if (opts.command === 'fix' && !reason) {
|
|
777
|
+
if (lossy) reason = 'not UTF-8';
|
|
778
|
+
else if (wide) reason = 'UTF-16';
|
|
779
|
+
else if (!input.explicit && bytes.subarray(0, BINARY_SNIFF_BYTES).includes(0)) reason = 'binary';
|
|
780
|
+
}
|
|
781
|
+
if (reason) {
|
|
658
782
|
skipped.push({ path: label, reason });
|
|
659
|
-
if (input.explicit) warn(`${label}: skipped (${reason})`);
|
|
783
|
+
if (input.explicit || reason !== 'binary') warn(`${label}: skipped (${reason})`);
|
|
784
|
+
// Asked to clean this input and did not: that must not look like success
|
|
785
|
+
if (opts.command === 'fix' && input.explicit) errors++;
|
|
660
786
|
continue;
|
|
661
787
|
}
|
|
662
788
|
if (lossy) {
|
|
663
|
-
// fix never rewrites bytes it could not decode
|
|
664
|
-
if (opts.command === 'fix') {
|
|
665
|
-
skipped.push({ path: label, reason: 'not UTF-8' });
|
|
666
|
-
warn(`${label}: skipped (not UTF-8)`);
|
|
667
|
-
continue;
|
|
668
|
-
}
|
|
669
789
|
notUtf8.push(label);
|
|
670
790
|
warn(`${label}: not valid UTF-8; checked with invalid bytes read as U+FFFD`);
|
|
671
791
|
}
|
|
792
|
+
visited++;
|
|
672
793
|
visit(label, text, input);
|
|
673
794
|
}
|
|
795
|
+
// A clean result must mean something was read. Finding no AI configuration files is
|
|
796
|
+
// an answer; --ai-config reports that itself.
|
|
797
|
+
if (visited === 0 && errors === 0 && !opts.aiConfig) errors++;
|
|
674
798
|
};
|
|
675
799
|
|
|
800
|
+
const skippedNote = () => (skipped.length > 0 ? `; ${plural(skipped.length, 'file')} skipped` : '');
|
|
801
|
+
|
|
676
802
|
if (opts.command === 'check') {
|
|
677
803
|
const files = [];
|
|
678
804
|
let scanned = 0;
|
|
@@ -689,16 +815,18 @@ async function main(argv) {
|
|
|
689
815
|
const report = inspect(text, opts.maxFindings, opts.stripBom);
|
|
690
816
|
total += report.total;
|
|
691
817
|
if (report.total === 0) return;
|
|
818
|
+
if (opts.format === 'sarif') fingerprintFindings(label, text, report.findings, cwd);
|
|
692
819
|
files.push({ path: label, ...report });
|
|
693
820
|
for (const x of report.findings) lines.push(findingLine(label, x));
|
|
694
821
|
if (report.truncated) lines.push(`${label}: ${report.total - report.findings.length} more not listed (--max-findings)`);
|
|
695
822
|
return;
|
|
696
823
|
}
|
|
697
824
|
|
|
698
|
-
const { report, hiddenRuns, loose } = inspectAiConfig(text, opts.maxFindings, opts.stripBom);
|
|
825
|
+
const { report, hiddenRuns, loose, entryCount } = inspectAiConfig(text, opts.maxFindings, opts.stripBom);
|
|
699
826
|
total += report.total;
|
|
700
827
|
hiddenRunCount += report.hiddenRunCount;
|
|
701
828
|
if (report.total === 0) return;
|
|
829
|
+
if (opts.format === 'sarif') fingerprintFindings(label, text, report.findings, cwd);
|
|
702
830
|
files.push({ path: label, ...report });
|
|
703
831
|
// Text output lists each run once, in place of its characters
|
|
704
832
|
const entries = [
|
|
@@ -706,7 +834,7 @@ async function main(argv) {
|
|
|
706
834
|
...loose.map((x) => ({ offset: x.offset, text: findingLine(label, x) })),
|
|
707
835
|
].sort((a, b) => a.offset - b.offset);
|
|
708
836
|
for (const e of entries.slice(0, opts.maxFindings)) lines.push(e.text);
|
|
709
|
-
if (
|
|
837
|
+
if (entryCount > opts.maxFindings) lines.push(`${label}: ${entryCount - opts.maxFindings} more not listed (--max-findings)`);
|
|
710
838
|
});
|
|
711
839
|
|
|
712
840
|
const kind = opts.aiConfig ? 'AI configuration file' : 'file';
|
|
@@ -722,8 +850,9 @@ async function main(argv) {
|
|
|
722
850
|
out('No AI configuration files found.');
|
|
723
851
|
if (opts.paths.length === 0 && !process.stdin.isTTY) out('To check piped text, pass "-": text-sanctifier check --ai-config -');
|
|
724
852
|
}
|
|
725
|
-
else if (
|
|
726
|
-
else
|
|
853
|
+
else if (scanned === 0) out(`Nothing checked${skippedNote()}.`);
|
|
854
|
+
else if (total === 0) out(`No ${opts.aiConfig ? 'hidden text, ' : ''}invisible or control characters in ${plural(scanned, kind)}${skippedNote()}.`);
|
|
855
|
+
else out(`${plural(total, 'finding')}${opts.aiConfig ? ` (${plural(hiddenRunCount, 'hidden run')})` : ''} in ${files.length} of ${plural(scanned, kind)}${skippedNote()}.`);
|
|
727
856
|
}
|
|
728
857
|
return errors > 0 ? 2 : total > 0 ? 1 : 0;
|
|
729
858
|
}
|
|
@@ -757,7 +886,21 @@ async function main(argv) {
|
|
|
757
886
|
scanned++;
|
|
758
887
|
// The BOM at offset 0 is set aside so purgeInvisibleChars cannot take it
|
|
759
888
|
const leadingBom = text.charCodeAt(0) === BOM;
|
|
760
|
-
|
|
889
|
+
const body = leadingBom ? text.slice(1) : text;
|
|
890
|
+
let cleaned;
|
|
891
|
+
if (opts.keepBenign && opts.purgeInvisible) {
|
|
892
|
+
const benign = inspectTextInContext(body, { maxFindings: Infinity }).findings.filter((f) => f.benign);
|
|
893
|
+
let start = 0;
|
|
894
|
+
const parts = [];
|
|
895
|
+
for (const f of benign) {
|
|
896
|
+
parts.push(clean(body.slice(start, f.offset)));
|
|
897
|
+
const end = f.offset + (f.codePoint > 0xFFFF ? 2 : 1);
|
|
898
|
+
parts.push(body.slice(f.offset, end));
|
|
899
|
+
start = end;
|
|
900
|
+
}
|
|
901
|
+
parts.push(clean(body.slice(start)));
|
|
902
|
+
cleaned = parts.join('');
|
|
903
|
+
} else cleaned = clean(body);
|
|
761
904
|
if (leadingBom && !opts.stripBom) cleaned = String.fromCharCode(BOM) + cleaned;
|
|
762
905
|
|
|
763
906
|
if (input.path === STDIN && !opts.dryRun) process.stdout.write(cleaned);
|
|
@@ -801,9 +944,11 @@ async function main(argv) {
|
|
|
801
944
|
if (f.newlinesNormalized) parts.push('normalized newlines');
|
|
802
945
|
report(`${verb} ${f.path}: ${parts.join('; ')}`);
|
|
803
946
|
}
|
|
804
|
-
report(
|
|
805
|
-
? `Nothing
|
|
806
|
-
:
|
|
947
|
+
report(scanned === 0
|
|
948
|
+
? `Nothing checked${skippedNote()}.`
|
|
949
|
+
: files.length === 0
|
|
950
|
+
? `Nothing to fix in ${plural(scanned, 'file')}${skippedNote()}.`
|
|
951
|
+
: `${opts.dryRun ? 'Would change' : 'Changed'} ${files.length} of ${plural(scanned, 'file')}${skippedNote()}.`);
|
|
807
952
|
}
|
|
808
953
|
return errors > 0 ? 2 : opts.dryRun && files.length > 0 ? 1 : 0;
|
|
809
954
|
}
|
|
@@ -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.11";export{Ee as VERSION,R as getNewlineStats,H as getRecommendedSanctifierOptions,j as inspectText,A as inspectTextDetailed,ce as revealInvisible,L as sanctifyKeyboardOnly,T as sanctifyKeyboardOnlyEmoji,N as sanctifyLoose,D as sanctifyPasted,w as sanctifyStrict,J as summonSanctifier};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decodes text[start, end) as decodeTagText does, stopping before the output would
|
|
3
|
+
* pass `limit` UTF-16 units. A `<U+XXXX>` marker is written whole or not at all.
|
|
4
|
+
* @param {string} text
|
|
5
|
+
* @param {number} start
|
|
6
|
+
* @param {number} end
|
|
7
|
+
* @param {number} limit
|
|
8
|
+
* @returns {{ decoded: string, truncated: boolean }}
|
|
9
|
+
*/
|
|
10
|
+
export function decodeRange(text: string, start: number, end: number, limit: number): {
|
|
11
|
+
decoded: string;
|
|
12
|
+
truncated: boolean;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* @param {string} text
|
|
16
|
+
* @param {HiddenScanOptions} [options]
|
|
17
|
+
* @param {(span: {offset: number, end: number, benign: boolean, kind: HiddenRunKind}) => void} [onSpan] - internal streaming visitor
|
|
18
|
+
* @returns {HiddenTextScan}
|
|
19
|
+
*/
|
|
20
|
+
export function scanHiddenSpans(text: string, options?: HiddenScanOptions, onSpan?: (span: {
|
|
21
|
+
offset: number;
|
|
22
|
+
end: number;
|
|
23
|
+
benign: boolean;
|
|
24
|
+
kind: HiddenRunKind;
|
|
25
|
+
}) => void): HiddenTextScan;
|
|
26
|
+
export type HiddenRunKind = import("./hiddenText.js").HiddenRunKind;
|
|
27
|
+
export type HiddenRun = import("./hiddenText.js").HiddenRun;
|
|
28
|
+
export type HiddenRunCounts = import("./hiddenText.js").HiddenRunCounts;
|
|
29
|
+
export type HiddenTextScan = import("./hiddenText.js").HiddenTextScan;
|
|
30
|
+
export type HiddenScanOptions = import("./hiddenText.js").HiddenScanOptions;
|
|
@@ -1,3 +1,43 @@
|
|
|
1
|
+
/** @typedef {'tag' | 'variation_selector' | 'invisible_operator' | 'reserved_tag_plane'} HiddenRunKind */
|
|
2
|
+
/**
|
|
3
|
+
* @typedef {Object} HiddenRun
|
|
4
|
+
* @property {HiddenRunKind} kind
|
|
5
|
+
* @property {number} offset - UTF-16 index of the first character
|
|
6
|
+
* @property {number} end - UTF-16 index just past the last character
|
|
7
|
+
* @property {number} count - number of code points in the run
|
|
8
|
+
* @property {number} line - 1-based line of the first character (as in inspectTextDetailed)
|
|
9
|
+
* @property {number} column - 1-based column of the first character, in UTF-16 units
|
|
10
|
+
* @property {string} [decoded] - tag runs only: the ASCII the tags spell (see decodeTagText),
|
|
11
|
+
* at most `maxDecodedLength` UTF-16 units
|
|
12
|
+
* @property {boolean} [decodedTruncated] - tag runs only: true when `decoded` stops short of the run
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {Object} HiddenRunCounts
|
|
16
|
+
* @property {number} tag
|
|
17
|
+
* @property {number} variation_selector
|
|
18
|
+
* @property {number} invisible_operator
|
|
19
|
+
* @property {number} reserved_tag_plane
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {Object} HiddenTextScan
|
|
23
|
+
* @property {HiddenRun[]} runs - runs that may hide text, in order, at most `maxRuns` long
|
|
24
|
+
* @property {Array<[number, number]>} benign - [offset, end) spans of legitimate
|
|
25
|
+
* sequences (subdivision flags, a single emoji or text presentation selector), at most `maxRuns` long
|
|
26
|
+
* @property {HiddenRunCounts} counts - every run per kind, listed or not
|
|
27
|
+
* @property {HiddenRunCounts} codePointCounts - code points in those runs per kind, listed or not
|
|
28
|
+
* @property {number} supplementarySelectorCount - code points U+E0100–U+E01EF in those runs, which
|
|
29
|
+
* (unlike the emoji selectors U+FE00–U+FE0F) can carry data; included in codePointCounts.variation_selector
|
|
30
|
+
* @property {number} total - sum of `counts`
|
|
31
|
+
* @property {number} benignTotal - every benign span, listed or not
|
|
32
|
+
* @property {boolean} truncated - true when runs or benign spans were left out of the lists
|
|
33
|
+
*/
|
|
34
|
+
/**
|
|
35
|
+
* @typedef {Object} HiddenScanOptions
|
|
36
|
+
* @property {number} [maxRuns=Infinity] - cap on listed runs, and separately on listed benign spans
|
|
37
|
+
* (non-negative integer or Infinity). Counts stay exact.
|
|
38
|
+
* @property {number} [maxDecodedLength=Infinity] - cap on each run's `decoded` text, in UTF-16 units.
|
|
39
|
+
* For both, any other value (negative, fractional, NaN, a string) means the default, no cap.
|
|
40
|
+
*/
|
|
1
41
|
/**
|
|
2
42
|
* Decodes tag characters to the ASCII they shadow: U+E0020–U+E007E become
|
|
3
43
|
* U+0020–U+007E. Anything else (U+E0001 LANGUAGE TAG, U+E007F CANCEL TAG, unassigned
|
|
@@ -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
|
*/
|