text-sanctifier 1.3.1 → 1.3.2
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 +75 -20
- package/bin/text-sanctifier.js +261 -27
- package/dist/text-sanctifier.min.js +15 -14
- package/dist/types/categories.generated.d.ts +27 -0
- package/dist/types/hiddenText.d.ts +50 -0
- package/dist/types/inspectText.d.ts +7 -7
- package/dist/types/sanctifyText.d.ts +3 -29
- package/package.json +76 -74
- package/src/categories.generated.js +223 -0
- package/src/hiddenText.js +119 -0
- package/src/inspectText.js +9 -56
- package/src/sanctifyText.js +11 -16
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
[](https://github.com/iWhatty/text-sanctifier)
|
|
8
8
|
[](https://www.npmjs.com/package/text-sanctifier)
|
|
9
9
|
|
|
10
|
-
Brutal text normalizer and invisible Unicode scrubber for modern web projects. ~2.
|
|
10
|
+
Brutal text normalizer and invisible Unicode scrubber for modern web projects. ~2.57 KB gzipped.
|
|
11
11
|
|
|
12
12
|
## Features
|
|
13
13
|
|
|
@@ -15,7 +15,8 @@ Brutal text normalizer and invisible Unicode scrubber for modern web projects. ~
|
|
|
15
15
|
- Purges bidi controls, including the isolates (U+2066–U+2069) and the Arabic letter mark (U+061C) used in Trojan Source attacks
|
|
16
16
|
- Purges Unicode tag characters (U+E0000–U+E007F), the "ASCII smuggling" channel for hiding instructions from humans in text an LLM reads, plus variation selectors, Hangul fillers, soft hyphens and invisible operators
|
|
17
17
|
- Locates invisible and control characters by offset, line and column (`inspectTextDetailed`)
|
|
18
|
-
- `text-sanctifier check` / `fix` command line for CI and coding agents, with JSON output and
|
|
18
|
+
- `text-sanctifier check` / `fix` command line for CI and coding agents, with JSON or SARIF output, exit codes and pre-commit hooks
|
|
19
|
+
- `check --ai-config` finds and decodes hidden text in agent instruction files (`CLAUDE.md`, `AGENTS.md`, Cursor rules, `SKILL.md`, MCP configuration)
|
|
19
20
|
- Normalizes line endings (CRLF, CR, LF) to LF
|
|
20
21
|
- Collapses unwanted spaces and paragraphs
|
|
21
22
|
- Nukes control characters (if enabled)
|
|
@@ -163,23 +164,25 @@ truncated: false
|
|
|
163
164
|
*/
|
|
164
165
|
```
|
|
165
166
|
|
|
166
|
-
|
|
167
|
-
|
|
|
168
|
-
|
|
|
169
|
-
| `
|
|
170
|
-
| `
|
|
171
|
-
| `
|
|
172
|
-
| `
|
|
173
|
-
| `
|
|
174
|
-
| `
|
|
175
|
-
| `
|
|
176
|
-
| `
|
|
177
|
-
| `
|
|
178
|
-
| `
|
|
167
|
+
<!-- categories:start (generated from scripts/categories.js by `npm run gen`; do not edit) -->
|
|
168
|
+
| Category | Code points | Removed by |
|
|
169
|
+
| --- | --- | --- |
|
|
170
|
+
| `bidi_control` | U+061C, U+200E, U+200F, U+202A–U+202E, U+2066–U+2069 | `nukeControls`, `purgeInvisibleChars` |
|
|
171
|
+
| `zero_width` | U+200B–U+200D, U+2060 | `purgeInvisibleChars` |
|
|
172
|
+
| `nonbreaking_space` | U+00A0, U+2000–U+200A, U+202F, U+205F, U+3000 (non-breaking and typographic spaces) | `purgeInvisibleChars` |
|
|
173
|
+
| `bom` | U+FEFF (`atStart` is true at offset 0, where a BOM is legitimate) | `purgeInvisibleChars` |
|
|
174
|
+
| `nul` | U+0000 | `nukeControls` |
|
|
175
|
+
| `c0_control` | U+0001–U+0008, U+000B, U+000C, U+000E–U+001F, U+007F (tab, LF and CR are whitespace, not trash) | `nukeControls` |
|
|
176
|
+
| `c1_control` | U+0080–U+009F | `nukeControls` |
|
|
177
|
+
| `invisible_format` | U+00AD (soft hyphen), U+034F (combining grapheme joiner), U+17B4, U+17B5 (Khmer inherent vowels), U+180E (Mongolian vowel separator), U+2061–U+2064 (invisible operators), U+2065 (reserved), U+206A–U+206F (deprecated format characters), U+FFF0–U+FFF8 (reserved), U+1BCA0–U+1BCA3 (shorthand format controls), U+1D173–U+1D17A (musical formatting), U+E0080–U+E00FF (reserved), U+E01F0–U+E0FFF (reserved) (every other Default_Ignorable_Code_Point, which renderers draw as nothing) | `purgeInvisibleChars` |
|
|
178
|
+
| `hangul_filler` | U+115F, U+1160, U+3164, U+FFA0 (letters that render blank) | `purgeInvisibleChars` |
|
|
179
|
+
| `variation_selector` | U+180B–U+180D (Mongolian FVS1-FVS3), U+180F (Mongolian FVS4), U+FE00–U+FE0F (VS1-VS16, including VS16 in emoji), U+E0100–U+E01EF (VS17-VS256) | `purgeInvisibleChars` |
|
|
180
|
+
| `tag` | U+E0000–U+E007F (tag characters, used for "ASCII smuggling" of hidden instructions) | `purgeInvisibleChars` |
|
|
181
|
+
<!-- categories:end -->
|
|
179
182
|
|
|
180
183
|
`codePoint` is the full code point, so a tag character reports `0xE0041`, not a surrogate. `offset` is a 0-based UTF-16 index (for a code point above U+FFFF, that of its high surrogate); `line` and `column` are 1-based, with columns in UTF-16 code units and lines ending at LF, CR or CRLF. Every occurrence is counted; only the first `maxFindings` (default 100, `Infinity` for all) are listed, and `truncated` says when some were not. The scan is one pass with no regex and no copy of the input.
|
|
181
184
|
|
|
182
|
-
**Choosing `maxFindings`:** the scan itself uses almost no memory, but every listed finding is an object, so the cost of `maxFindings` grows with the number of findings, not the size of the input. 16 MB of non-breaking spaces is 16.7 million findings. With a cap of 20, it scans in about
|
|
185
|
+
**Choosing `maxFindings`:** the scan itself uses almost no memory, but every listed finding is an object, so the cost of `maxFindings` grows with the number of findings, not the size of the input. 16 MB of non-breaking spaces is 16.7 million findings. With a cap of 20, it scans in about 80 ms and uses 0.2 MB. With `Infinity`, expect roughly 100–200 bytes per finding: about 1.5 GB for 8 million. On untrusted input, keep a cap. Use `maxFindings: 0` for a counts-only report: it builds no finding objects, and `counts`, `total` and `truncated` stay exact.
|
|
183
186
|
|
|
184
187
|
### `getNewlineStats(text: string): NewlineStats`
|
|
185
188
|
|
|
@@ -191,9 +194,9 @@ import { getNewlineStats } from 'text-sanctifier';
|
|
|
191
194
|
getNewlineStats('a\r\nb\n'); // { crlf: 1, cr: 0, lf: 1, types: ['CRLF', 'LF'], mixed: true }
|
|
192
195
|
```
|
|
193
196
|
|
|
194
|
-
`zero_width` also flags U+200D (zero width joiner) inside emoji ZWJ sequences such as 👨👩👧, so a finding there is not necessarily hidden text. Filter by neighbouring code points if you need to tell them apart. (`purgeInvisibleChars` removes those joiners too, splitting the emoji; `keyboardOnlyEmoji` keeps the sequences whole.) The same holds for `variation_selector`, which flags VS16 (U+FE0F) in emoji such as ✌️ and the ideographic variation selectors in CJK names, and for `tag`, which flags the tag characters inside subdivision flags such as the England flag (U+1F3F4 followed by tags). `purgeInvisibleChars` strips them all; `keyboardOnlyEmoji` keeps VS16.
|
|
197
|
+
`zero_width` also flags U+200D (zero width joiner) inside emoji ZWJ sequences such as 👨👩👧, so a finding there is not necessarily hidden text. Filter by neighbouring code points if you need to tell them apart. (`purgeInvisibleChars` removes those joiners too, splitting the emoji; `keyboardOnlyEmoji` keeps the sequences whole.) The same holds for `variation_selector`, which flags VS16 (U+FE0F) in emoji such as ✌️ and the ideographic variation selectors in CJK names, and for `tag`, which flags the tag characters inside subdivision flags such as the England flag (U+1F3F4 followed by tags). `purgeInvisibleChars` strips them all; `keyboardOnlyEmoji` keeps VS16. The Mongolian free variation selectors (U+180B–U+180D, U+180F) are also `variation_selector`: they are legitimate in Mongolian text, which `purgeInvisibleChars` can change, so don't purge Mongolian text with it.
|
|
195
198
|
|
|
196
|
-
The categories cover exactly what `nukeControls` and `purgeInvisibleChars` strip.
|
|
199
|
+
The categories cover exactly what `nukeControls` and `purgeInvisibleChars` strip. Every code point with the Unicode property Default_Ignorable_Code_Point (characters renderers must draw as nothing) is in a category, and a test checks this against the running Node's Unicode data.
|
|
197
200
|
|
|
198
201
|
---
|
|
199
202
|
|
|
@@ -216,9 +219,61 @@ src/auth.js:40:3 U+202E bidi_control
|
|
|
216
219
|
2 findings in 2 of 31 files.
|
|
217
220
|
```
|
|
218
221
|
|
|
219
|
-
- `--json
|
|
222
|
+
- `--json` (or `--format json`): one JSON object on stdout, `{ files, total, filesScanned, filesWithFindings, skipped }`. Each entry in `files` is `{ path, ...inspectTextDetailed(text) }`; only files with findings are listed.
|
|
223
|
+
- `--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).
|
|
220
224
|
- `--max-findings N`: findings listed per file (default 100; `all` for no cap). Counts always cover every occurrence.
|
|
221
225
|
- `--strip-bom`: also report a BOM at offset 0, which is skipped by default.
|
|
226
|
+
- `--ai-config`: check agent instruction files for hidden text. See [AI configuration files](#ai-configuration-files).
|
|
227
|
+
|
|
228
|
+
### AI configuration files
|
|
229
|
+
|
|
230
|
+
`check --ai-config` looks for hidden instructions in the files coding agents read: rules files, skill files and MCP configuration. Tag characters, variation selectors and invisible operators can carry text that people reviewing the file cannot see but a model reads.
|
|
231
|
+
|
|
232
|
+
```sh
|
|
233
|
+
npx text-sanctifier check --ai-config
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
```
|
|
237
|
+
skills/review/SKILL.md:1:17 U+E0049 run of 15 tag characters spelling "Ignore the user"
|
|
238
|
+
skills/review/SKILL.md:2:3 U+FE00 run of 3 variation selectors
|
|
239
|
+
.cursor/rules/style.mdc:4:9 U+200B zero_width
|
|
240
|
+
19 findings (2 hidden runs) in 2 of 6 AI configuration files.
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
- With no paths it searches the current directory; directories you name are searched the same way, and files you name are checked whatever they are called. In a git work tree, files `.gitignore` excludes are skipped.
|
|
244
|
+
- Files it looks for, matched case-insensitively: `CLAUDE.md`, `CLAUDE.local.md`, `AGENTS.md`, `AGENT.md`, `GEMINI.md` and `SKILL.md` at any depth; and at the top of the directory, `.claude/**`, `.cursorrules`, `.cursor/rules/**`, `.cursor/mcp.json`, `.windsurfrules`, `.windsurf/rules/**`, `.clinerules` (file or directory), `.roorules`, `.roo/rules/**`, `.github/copilot-instructions.md`, `.github/{instructions,prompts,chatmodes,agents}/**`, `.mcp.json`, `mcp.json` and `.vscode/mcp.json`.
|
|
245
|
+
- Consecutive tag characters, variation selectors or invisible operators are reported once, as a run, at the first character. Tag runs are decoded: U+E0020–U+E007E become the ASCII they shadow, and other tags are shown as `<U+XXXX>`.
|
|
246
|
+
- Every other invisible or control character is reported as in a plain `check`.
|
|
247
|
+
- Two legitimate sequences are left out: the flags of England, Scotland and Wales (U+1F3F4, tag letters, U+E007F), and a single U+FE0E or U+FE0F after another character (text or emoji presentation). A plain `check` still reports both.
|
|
248
|
+
- `--json` adds `hiddenRuns` (`{ kind, offset, line, column, codePoint, count, decoded?, decodedTruncated? }`, at most `--max-findings` per file) and `hiddenRunCount` to each file, and `hiddenRunCount` to the summary. `kind` is `tag`, `variation_selector` or `invisible_operator`; `decoded` is cut at 1000 characters. `total` and `counts` leave out the legitimate sequences.
|
|
249
|
+
|
|
250
|
+
### SARIF output
|
|
251
|
+
|
|
252
|
+
`check --format sarif` (with or without `--ai-config`) prints one SARIF 2.1.0 log with one result per listed finding:
|
|
253
|
+
|
|
254
|
+
- `ruleId` is the category, and `tool.driver.rules` lists every category.
|
|
255
|
+
- `level`: `error` for `bidi_control` and `tag`, `note` for `nonbreaking_space`, `warning` for the rest.
|
|
256
|
+
- 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"`).
|
|
257
|
+
- With `--ai-config`, the message of the first result in a run describes the run and its decoded text.
|
|
258
|
+
- `--max-findings` caps results per file as usual. `runs[0].properties` holds `total`, `filesScanned`, `filesWithFindings`, `truncated` (files with findings left out) and `skipped`.
|
|
259
|
+
|
|
260
|
+
### pre-commit
|
|
261
|
+
|
|
262
|
+
The repository publishes a [pre-commit](https://pre-commit.com) hook manifest, so the check runs locally on the files being committed:
|
|
263
|
+
|
|
264
|
+
```yaml
|
|
265
|
+
# .pre-commit-config.yaml
|
|
266
|
+
repos:
|
|
267
|
+
- repo: https://github.com/iWhatty/text-sanctifier
|
|
268
|
+
rev: v1.3.2 # a release tag
|
|
269
|
+
hooks:
|
|
270
|
+
- id: text-sanctifier # every staged text file
|
|
271
|
+
- id: text-sanctifier-ai-config # agent instruction files, with --ai-config
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
- `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.
|
|
275
|
+
- `text-sanctifier-ai-config` runs `text-sanctifier check --ai-config` on staged files that match the `--ai-config` file list.
|
|
276
|
+
- A hook fails the commit when it finds something (exit code 1). Neither hook changes files; run `text-sanctifier fix` yourself.
|
|
222
277
|
|
|
223
278
|
### `fix [paths...]`
|
|
224
279
|
|
|
@@ -230,7 +285,7 @@ Rewrites files in place and reports what it removed.
|
|
|
230
285
|
- With none of the three, `fix` uses `--purge-invisible --nuke-controls`, which removes exactly what `check` reports.
|
|
231
286
|
- `--strip-bom`: also remove a BOM at offset 0. It is kept by default.
|
|
232
287
|
- `--dry-run`: write nothing; report what would change.
|
|
233
|
-
- `--json
|
|
288
|
+
- `--json` (or `--format json`): `{ files, dryRun, filesScanned, filesChanged, skipped }`, where each file is `{ path, removed, removedTotal, newlinesNormalized }`.
|
|
234
289
|
|
|
235
290
|
`fix` deletes characters; it does not replace them. A non-breaking space between two words joins them, and emoji lose their zero width joiners and variation selectors. Run `check` or `fix --dry-run` first on prose.
|
|
236
291
|
|
package/bin/text-sanctifier.js
CHANGED
|
@@ -3,16 +3,19 @@
|
|
|
3
3
|
//
|
|
4
4
|
// Command-line front end for coding agents and CI:
|
|
5
5
|
// text-sanctifier check [paths...] report invisible and control characters
|
|
6
|
+
// text-sanctifier check --ai-config report hidden text in agent instruction files
|
|
6
7
|
// text-sanctifier fix [paths...] remove them in place
|
|
7
8
|
//
|
|
8
9
|
// Plain Node (>=18), no dependencies. Exit codes: 0 clean, 1 findings (check,
|
|
9
10
|
// or fix --dry-run with changes pending), 2 usage or I/O error.
|
|
10
11
|
|
|
11
12
|
import { readFileSync, writeFileSync, statSync, readdirSync, existsSync } from 'node:fs';
|
|
12
|
-
import { resolve, join, sep } from 'node:path';
|
|
13
|
+
import { resolve, join, sep, relative } from 'node:path';
|
|
13
14
|
import { spawnSync } from 'node:child_process';
|
|
15
|
+
import { pathToFileURL } from 'node:url';
|
|
14
16
|
import { inspectTextDetailed, summonSanctifier } from '../src/index.js';
|
|
15
17
|
import { getNewlineStats } from '../src/inspectText.js';
|
|
18
|
+
import { findHiddenRuns } from '../src/hiddenText.js';
|
|
16
19
|
|
|
17
20
|
const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
18
21
|
|
|
@@ -27,9 +30,14 @@ alone). "-", or no paths with piped input, reads stdin. Directories and globs
|
|
|
27
30
|
skip files ignored by git (inside a git work tree) and skip binary files.
|
|
28
31
|
|
|
29
32
|
check options:
|
|
30
|
-
--json
|
|
33
|
+
--format F text (default), json or sarif (SARIF 2.1.0)
|
|
34
|
+
--json same as --format json
|
|
31
35
|
--max-findings N findings listed per file (default 100, "all" for no cap)
|
|
32
36
|
--strip-bom also report a BOM at the start of a file
|
|
37
|
+
--ai-config check agent instruction files (CLAUDE.md, AGENTS.md,
|
|
38
|
+
.cursor/rules, SKILL.md, MCP config, ...) for hidden text;
|
|
39
|
+
directories, or "." when no paths are given, are searched
|
|
40
|
+
for those files, and tag text is decoded
|
|
33
41
|
|
|
34
42
|
fix options:
|
|
35
43
|
--purge-invisible remove zero-width, non-breaking space, bidi and BOM characters
|
|
@@ -37,7 +45,8 @@ fix options:
|
|
|
37
45
|
--normalize-newlines convert CRLF and CR to LF
|
|
38
46
|
--strip-bom also remove a BOM at the start of a file (kept by default)
|
|
39
47
|
--dry-run report what would change; write nothing
|
|
40
|
-
--json
|
|
48
|
+
--format F text (default) or json
|
|
49
|
+
--json same as --format json
|
|
41
50
|
With no cleaning flag, fix uses --purge-invisible --nuke-controls.
|
|
42
51
|
stdin is cleaned to stdout; the report then goes to stderr.
|
|
43
52
|
|
|
@@ -62,7 +71,7 @@ class UsageError extends Error {}
|
|
|
62
71
|
// --- Arguments ---
|
|
63
72
|
|
|
64
73
|
const BOOLEAN_FLAGS = {
|
|
65
|
-
'--
|
|
74
|
+
'--ai-config': 'aiConfig',
|
|
66
75
|
'--strip-bom': 'stripBom',
|
|
67
76
|
'--purge-invisible': 'purgeInvisible',
|
|
68
77
|
'--nuke-controls': 'nukeControls',
|
|
@@ -70,6 +79,8 @@ const BOOLEAN_FLAGS = {
|
|
|
70
79
|
'--dry-run': 'dryRun',
|
|
71
80
|
};
|
|
72
81
|
const FIX_ONLY_FLAGS = new Set(['purgeInvisible', 'nukeControls', 'normalizeNewlines', 'dryRun']);
|
|
82
|
+
const CHECK_ONLY_FLAGS = new Set(['aiConfig']);
|
|
83
|
+
const FORMATS = new Set(['text', 'json', 'sarif']);
|
|
73
84
|
|
|
74
85
|
/**
|
|
75
86
|
* @param {string[]} argv
|
|
@@ -78,7 +89,8 @@ function parseArgs(argv) {
|
|
|
78
89
|
const opts = {
|
|
79
90
|
command: null,
|
|
80
91
|
paths: [],
|
|
81
|
-
|
|
92
|
+
format: null,
|
|
93
|
+
aiConfig: false,
|
|
82
94
|
stripBom: false,
|
|
83
95
|
purgeInvisible: false,
|
|
84
96
|
nukeControls: false,
|
|
@@ -108,6 +120,15 @@ function parseArgs(argv) {
|
|
|
108
120
|
opts.maxFindings = parseMaxFindings(value);
|
|
109
121
|
continue;
|
|
110
122
|
}
|
|
123
|
+
if (name === '--format' || (name === '--json' && inline === undefined)) {
|
|
124
|
+
const value = name === '--json' ? 'json' : inline !== undefined ? inline : argv[++i];
|
|
125
|
+
if (!FORMATS.has(value)) {
|
|
126
|
+
throw new UsageError(`--format expects text, json or sarif, got ${value === undefined ? 'nothing' : JSON.stringify(value)}.`);
|
|
127
|
+
}
|
|
128
|
+
if (opts.format !== null && opts.format !== value) throw new UsageError(`Conflicting formats: ${opts.format} and ${value}.`);
|
|
129
|
+
opts.format = value;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
111
132
|
if (name in BOOLEAN_FLAGS && inline === undefined) {
|
|
112
133
|
opts[BOOLEAN_FLAGS[name]] = true;
|
|
113
134
|
continue;
|
|
@@ -119,11 +140,12 @@ function parseArgs(argv) {
|
|
|
119
140
|
if (opts.command !== 'check' && opts.command !== 'fix') {
|
|
120
141
|
throw new UsageError(opts.command === null ? 'Missing command.' : `Unknown command: ${opts.command}`);
|
|
121
142
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
}
|
|
143
|
+
for (const [flag, key] of Object.entries(BOOLEAN_FLAGS)) {
|
|
144
|
+
if (opts.command === 'check' && FIX_ONLY_FLAGS.has(key) && opts[key]) throw new UsageError(`${flag} only applies to fix.`);
|
|
145
|
+
if (opts.command === 'fix' && CHECK_ONLY_FLAGS.has(key) && opts[key]) throw new UsageError(`${flag} only applies to check.`);
|
|
126
146
|
}
|
|
147
|
+
if (opts.format === null) opts.format = 'text';
|
|
148
|
+
if (opts.command === 'fix' && opts.format === 'sarif') throw new UsageError('--format sarif only applies to check.');
|
|
127
149
|
if (opts.command === 'fix' && !opts.purgeInvisible && !opts.nukeControls && !opts.normalizeNewlines) {
|
|
128
150
|
opts.purgeInvisible = true;
|
|
129
151
|
opts.nukeControls = true;
|
|
@@ -255,13 +277,37 @@ function walkGlob(cwd, glob) {
|
|
|
255
277
|
}
|
|
256
278
|
|
|
257
279
|
/**
|
|
258
|
-
*
|
|
280
|
+
* Agent instruction and configuration files, relative to the directory searched.
|
|
281
|
+
* Matched case-insensitively. Keep .pre-commit-hooks.yaml (the text-sanctifier-ai-config
|
|
282
|
+
* hook's `files`) in step; tests/cli.test.js checks both against the same paths.
|
|
283
|
+
*/
|
|
284
|
+
const AI_CONFIG_GLOBS = [
|
|
285
|
+
'**/CLAUDE.md', '**/CLAUDE.local.md', '**/AGENTS.md', '**/AGENT.md', '**/GEMINI.md', '**/SKILL.md',
|
|
286
|
+
'.claude/**',
|
|
287
|
+
'.cursorrules', '.cursor/rules/**', '.cursor/mcp.json',
|
|
288
|
+
'.windsurfrules', '.windsurf/rules/**',
|
|
289
|
+
'.clinerules', '.clinerules/**',
|
|
290
|
+
'.roorules', '.roo/rules/**',
|
|
291
|
+
'.github/copilot-instructions.md', '.github/instructions/**', '.github/prompts/**',
|
|
292
|
+
'.github/chatmodes/**', '.github/agents/**',
|
|
293
|
+
'.mcp.json', 'mcp.json', '.vscode/mcp.json',
|
|
294
|
+
];
|
|
295
|
+
|
|
296
|
+
const AI_CONFIG_RES = AI_CONFIG_GLOBS.map((g) => new RegExp(globToRegExp(g).source, 'i'));
|
|
297
|
+
|
|
298
|
+
/** @param {string} rel - slash-separated path relative to the directory searched */
|
|
299
|
+
const isAiConfigPath = (rel) => AI_CONFIG_RES.some((re) => re.test(rel));
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Expands the path arguments into a de-duplicated input list. With `aiConfig`,
|
|
303
|
+
* directories contribute only agent instruction files (AI_CONFIG_GLOBS).
|
|
259
304
|
* @param {string[]} args
|
|
260
305
|
* @param {string} cwd
|
|
261
306
|
* @param {(msg: string) => void} warn
|
|
307
|
+
* @param {boolean} [aiConfig]
|
|
262
308
|
* @returns {{ inputs: Array<{ path: string, explicit: boolean }>, errors: number }}
|
|
263
309
|
*/
|
|
264
|
-
function collectInputs(args, cwd, warn) {
|
|
310
|
+
function collectInputs(args, cwd, warn, aiConfig = false) {
|
|
265
311
|
const inputs = [];
|
|
266
312
|
const seen = new Set();
|
|
267
313
|
let errors = 0;
|
|
@@ -291,11 +337,16 @@ function collectInputs(args, cwd, warn) {
|
|
|
291
337
|
}
|
|
292
338
|
|
|
293
339
|
if (inGit === undefined) inGit = isGitWorkTree(cwd);
|
|
294
|
-
|
|
340
|
+
let listed =
|
|
295
341
|
(inGit && gitListFiles(cwd, isGlob ? `:(glob)${toSlash(arg)}` : toSlash(arg))) ||
|
|
296
342
|
(isGlob ? walkGlob(cwd, arg) : walk(cwd, arg));
|
|
297
343
|
|
|
298
|
-
if (
|
|
344
|
+
if (aiConfig && !isGlob) {
|
|
345
|
+
const base = resolve(cwd, arg);
|
|
346
|
+
listed = listed.filter((f) => isAiConfigPath(toSlash(relative(base, resolve(cwd, f)))));
|
|
347
|
+
} else if (listed.length === 0) {
|
|
348
|
+
warn(`${arg}: no files matched`);
|
|
349
|
+
}
|
|
299
350
|
for (const f of listed) add(f, false);
|
|
300
351
|
}
|
|
301
352
|
return { inputs, errors };
|
|
@@ -360,6 +411,161 @@ const hex = (cp) => 'U+' + cp.toString(16).toUpperCase().padStart(4, '0');
|
|
|
360
411
|
const formatCounts = (counts) =>
|
|
361
412
|
Object.entries(counts).filter(([, n]) => n > 0).map(([k, n]) => `${k} ${n}`).join(', ');
|
|
362
413
|
|
|
414
|
+
/**
|
|
415
|
+
* @param {number} n
|
|
416
|
+
* @param {string} word
|
|
417
|
+
*/
|
|
418
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
// --- check --ai-config ---
|
|
422
|
+
|
|
423
|
+
const MAX_DECODED = 1000;
|
|
424
|
+
const RUN_NOUNS = { tag: 'tag character', variation_selector: 'variation selector', invisible_operator: 'invisible operator' };
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* inspect() for agent instruction files: drops findings that belong to legitimate
|
|
428
|
+
* sequences (subdivision flags, a lone emoji presentation selector) and adds the runs
|
|
429
|
+
* of tag characters, variation selectors and invisible operators, with tag text decoded.
|
|
430
|
+
* @param {string} text
|
|
431
|
+
* @param {number} maxFindings
|
|
432
|
+
* @param {boolean} stripBom
|
|
433
|
+
*/
|
|
434
|
+
function inspectAiConfig(text, maxFindings, stripBom) {
|
|
435
|
+
const full = inspect(text, Infinity, stripBom);
|
|
436
|
+
const { runs, benign } = findHiddenRuns(text);
|
|
437
|
+
|
|
438
|
+
const kept = [];
|
|
439
|
+
let b = 0;
|
|
440
|
+
for (const f of full.findings) {
|
|
441
|
+
while (b < benign.length && benign[b][1] <= f.offset) b++;
|
|
442
|
+
if (b < benign.length && benign[b][0] <= f.offset) { full.counts[f.category]--; continue; }
|
|
443
|
+
kept.push(f);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Every run character is a finding, so each run starts at a kept finding
|
|
447
|
+
const hiddenRuns = [];
|
|
448
|
+
const loose = [];
|
|
449
|
+
let r = 0;
|
|
450
|
+
for (const f of kept) {
|
|
451
|
+
while (r < runs.length && runs[r].end <= f.offset) r++;
|
|
452
|
+
const run = runs[r];
|
|
453
|
+
if (!run || f.offset < run.offset) { loose.push(f); continue; }
|
|
454
|
+
if (f.offset !== run.offset) continue;
|
|
455
|
+
const entry = { kind: run.kind, offset: run.offset, line: f.line, column: f.column, codePoint: f.codePoint, count: run.count };
|
|
456
|
+
if (run.decoded !== undefined) {
|
|
457
|
+
entry.decoded = run.decoded.slice(0, MAX_DECODED);
|
|
458
|
+
entry.decodedTruncated = run.decoded.length > MAX_DECODED;
|
|
459
|
+
}
|
|
460
|
+
hiddenRuns.push(entry);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const findings = kept.slice(0, maxFindings);
|
|
464
|
+
return {
|
|
465
|
+
report: {
|
|
466
|
+
...full,
|
|
467
|
+
findings,
|
|
468
|
+
total: kept.length,
|
|
469
|
+
truncated: kept.length > findings.length,
|
|
470
|
+
hiddenRuns: hiddenRuns.slice(0, maxFindings),
|
|
471
|
+
hiddenRunCount: hiddenRuns.length,
|
|
472
|
+
},
|
|
473
|
+
hiddenRuns,
|
|
474
|
+
loose,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** @param {{ kind: string, count: number, decoded?: string, decodedTruncated?: boolean }} run */
|
|
479
|
+
function describeRun(run) {
|
|
480
|
+
const what = plural(run.count, RUN_NOUNS[run.kind]);
|
|
481
|
+
if (run.decoded === undefined) return `run of ${what}`;
|
|
482
|
+
return `run of ${what} spelling ${JSON.stringify(run.decoded)}${run.decodedTruncated ? ' (cut short)' : ''}`;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
// --- SARIF ---
|
|
487
|
+
|
|
488
|
+
// Level and description per category, for SARIF rules. Every category needs an entry;
|
|
489
|
+
// tests/cli.test.js checks the rules against inspectTextDetailed's counts.
|
|
490
|
+
const CATEGORY_RULES = {
|
|
491
|
+
bidi_control: ['error', 'Bidirectional control character, which can make text display in a different order than it is read (Trojan Source)'],
|
|
492
|
+
zero_width: ['warning', 'Zero-width character'],
|
|
493
|
+
nonbreaking_space: ['note', 'Non-breaking or typographic space that looks like an ordinary space'],
|
|
494
|
+
bom: ['warning', 'Byte order mark (U+FEFF) that is not at the start of the file'],
|
|
495
|
+
nul: ['warning', 'NUL character'],
|
|
496
|
+
c0_control: ['warning', 'C0 control character other than tab, LF and CR'],
|
|
497
|
+
c1_control: ['warning', 'C1 control character'],
|
|
498
|
+
invisible_format: ['warning', 'Invisible format character, such as a soft hyphen or an invisible operator'],
|
|
499
|
+
hangul_filler: ['warning', 'Hangul filler, a letter that renders blank'],
|
|
500
|
+
variation_selector: ['warning', 'Variation selector, which can carry hidden data'],
|
|
501
|
+
tag: ['error', 'Tag character, which can hide ASCII text that people cannot see but language models read'],
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
const SARIF_SCHEMA = 'https://json.schemastore.org/sarif-2.1.0.json';
|
|
505
|
+
const INFORMATION_URI = 'https://github.com/iWhatty/text-sanctifier';
|
|
506
|
+
const ABSOLUTE_PATH = /^(?:[a-zA-Z]:)?[\\/]/;
|
|
507
|
+
|
|
508
|
+
/** @param {string} label */
|
|
509
|
+
function sarifArtifact(label) {
|
|
510
|
+
if (label === STDIN_LABEL) return { uri: 'stdin', description: { text: 'standard input' } };
|
|
511
|
+
if (ABSOLUTE_PATH.test(label)) return { uri: pathToFileURL(label).href };
|
|
512
|
+
return { uri: encodeURI(toSlash(label)).replace(/[?#]/g, encodeURIComponent), uriBaseId: '%SRCROOT%' };
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Builds a SARIF 2.1.0 log with one result per listed finding; the rule is the category.
|
|
517
|
+
* @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
|
|
518
|
+
* @param {{ cwd: string, total: number, filesScanned: number, skipped: Array<{ path: string, reason: string }>, succeeded: boolean }} info
|
|
519
|
+
*/
|
|
520
|
+
function sarifLog(files, { cwd, total, filesScanned, skipped, succeeded }) {
|
|
521
|
+
const ruleIds = Object.keys(inspectTextDetailed('', { maxFindings: 0 }).counts);
|
|
522
|
+
const rules = ruleIds.map((id) => ({
|
|
523
|
+
id,
|
|
524
|
+
shortDescription: { text: CATEGORY_RULES[id][1] },
|
|
525
|
+
defaultConfiguration: { level: CATEGORY_RULES[id][0] },
|
|
526
|
+
}));
|
|
527
|
+
|
|
528
|
+
const results = [];
|
|
529
|
+
for (const f of files) {
|
|
530
|
+
const runAt = new Map((f.hiddenRuns || []).map((run) => [run.offset, run]));
|
|
531
|
+
const artifactLocation = sarifArtifact(f.path);
|
|
532
|
+
for (const x of f.findings) {
|
|
533
|
+
const run = runAt.get(x.offset);
|
|
534
|
+
results.push({
|
|
535
|
+
ruleId: x.category,
|
|
536
|
+
ruleIndex: ruleIds.indexOf(x.category),
|
|
537
|
+
level: CATEGORY_RULES[x.category][0],
|
|
538
|
+
message: { text: `${hex(x.codePoint)} ${x.category}${run ? `, start of a ${describeRun(run)}` : ''}` },
|
|
539
|
+
locations: [{
|
|
540
|
+
physicalLocation: {
|
|
541
|
+
artifactLocation,
|
|
542
|
+
region: { startLine: x.line, startColumn: x.column, endColumn: x.column + (x.codePoint > 0xFFFF ? 2 : 1) },
|
|
543
|
+
},
|
|
544
|
+
}],
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
return {
|
|
550
|
+
$schema: SARIF_SCHEMA,
|
|
551
|
+
version: '2.1.0',
|
|
552
|
+
runs: [{
|
|
553
|
+
tool: { driver: { name: 'text-sanctifier', version: VERSION, semanticVersion: VERSION, informationUri: INFORMATION_URI, rules } },
|
|
554
|
+
originalUriBaseIds: { '%SRCROOT%': { uri: pathToFileURL(cwd.endsWith(sep) ? cwd : cwd + sep).href } },
|
|
555
|
+
columnKind: 'utf16CodeUnits',
|
|
556
|
+
invocations: [{ executionSuccessful: succeeded }],
|
|
557
|
+
results,
|
|
558
|
+
properties: {
|
|
559
|
+
total,
|
|
560
|
+
filesScanned,
|
|
561
|
+
filesWithFindings: files.length,
|
|
562
|
+
truncated: files.filter((f) => f.truncated).map((f) => f.path),
|
|
563
|
+
skipped,
|
|
564
|
+
},
|
|
565
|
+
}],
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
|
|
363
569
|
|
|
364
570
|
// --- Main ---
|
|
365
571
|
|
|
@@ -384,7 +590,9 @@ async function main(argv) {
|
|
|
384
590
|
|
|
385
591
|
const cwd = process.cwd();
|
|
386
592
|
let paths = opts.paths;
|
|
387
|
-
if (paths.length === 0) {
|
|
593
|
+
if (paths.length === 0 && opts.aiConfig) {
|
|
594
|
+
paths = ['.'];
|
|
595
|
+
} else if (paths.length === 0) {
|
|
388
596
|
if (process.stdin.isTTY) {
|
|
389
597
|
err('text-sanctifier: no paths given and nothing piped on stdin.\nRun "text-sanctifier --help" for usage.');
|
|
390
598
|
return 2;
|
|
@@ -393,7 +601,7 @@ async function main(argv) {
|
|
|
393
601
|
}
|
|
394
602
|
|
|
395
603
|
const warn = (msg) => err(`text-sanctifier: ${msg}`);
|
|
396
|
-
const { inputs, errors: pathErrors } = collectInputs(paths, cwd, warn);
|
|
604
|
+
const { inputs, errors: pathErrors } = collectInputs(paths, cwd, warn, opts.aiConfig);
|
|
397
605
|
let errors = pathErrors;
|
|
398
606
|
/** @type {Array<{ path: string, reason: string }>} */
|
|
399
607
|
const skipped = [];
|
|
@@ -428,23 +636,49 @@ async function main(argv) {
|
|
|
428
636
|
let scanned = 0;
|
|
429
637
|
let total = 0;
|
|
430
638
|
|
|
639
|
+
let hiddenRunCount = 0;
|
|
640
|
+
/** @type {string[]} */
|
|
641
|
+
const lines = [];
|
|
642
|
+
const findingLine = (label, x) => `${label}:${x.line}:${x.column} ${hex(x.codePoint)} ${x.category}`;
|
|
643
|
+
|
|
431
644
|
await forEachText((label, text) => {
|
|
432
645
|
scanned++;
|
|
433
|
-
|
|
646
|
+
if (!opts.aiConfig) {
|
|
647
|
+
const report = inspect(text, opts.maxFindings, opts.stripBom);
|
|
648
|
+
total += report.total;
|
|
649
|
+
if (report.total === 0) return;
|
|
650
|
+
files.push({ path: label, ...report });
|
|
651
|
+
for (const x of report.findings) lines.push(findingLine(label, x));
|
|
652
|
+
if (report.truncated) lines.push(`${label}: ${report.total - report.findings.length} more not listed (--max-findings)`);
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const { report, hiddenRuns, loose } = inspectAiConfig(text, opts.maxFindings, opts.stripBom);
|
|
434
657
|
total += report.total;
|
|
435
|
-
|
|
658
|
+
hiddenRunCount += report.hiddenRunCount;
|
|
659
|
+
if (report.total === 0) return;
|
|
660
|
+
files.push({ path: label, ...report });
|
|
661
|
+
// Text output lists each run once, in place of its characters
|
|
662
|
+
const entries = [
|
|
663
|
+
...hiddenRuns.map((run) => ({ offset: run.offset, text: `${label}:${run.line}:${run.column} ${hex(run.codePoint)} ${describeRun(run)}` })),
|
|
664
|
+
...loose.map((x) => ({ offset: x.offset, text: findingLine(label, x) })),
|
|
665
|
+
].sort((a, b) => a.offset - b.offset);
|
|
666
|
+
for (const e of entries.slice(0, opts.maxFindings)) lines.push(e.text);
|
|
667
|
+
if (entries.length > opts.maxFindings) lines.push(`${label}: ${entries.length - opts.maxFindings} more not listed (--max-findings)`);
|
|
436
668
|
});
|
|
437
669
|
|
|
438
|
-
|
|
439
|
-
|
|
670
|
+
const kind = opts.aiConfig ? 'AI configuration file' : 'file';
|
|
671
|
+
if (opts.format === 'json') {
|
|
672
|
+
const summary = { files, total, filesScanned: scanned, filesWithFindings: files.length, skipped };
|
|
673
|
+
if (opts.aiConfig) summary.hiddenRunCount = hiddenRunCount;
|
|
674
|
+
out(JSON.stringify(summary, null, 2));
|
|
675
|
+
} else if (opts.format === 'sarif') {
|
|
676
|
+
out(JSON.stringify(sarifLog(files, { cwd, total, filesScanned: scanned, skipped, succeeded: errors === 0 }), null, 2));
|
|
440
677
|
} else {
|
|
441
|
-
for (const
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
}
|
|
445
|
-
out(total === 0
|
|
446
|
-
? `No invisible or control characters in ${scanned} file${scanned === 1 ? '' : 's'}.`
|
|
447
|
-
: `${total} finding${total === 1 ? '' : 's'} in ${files.length} of ${scanned} file${scanned === 1 ? '' : 's'}.`);
|
|
678
|
+
for (const line of lines) out(line);
|
|
679
|
+
if (opts.aiConfig && scanned === 0 && skipped.length === 0) out('No AI configuration files found.');
|
|
680
|
+
else if (total === 0) out(`No ${opts.aiConfig ? 'hidden text, ' : ''}invisible or control characters in ${plural(scanned, kind)}.`);
|
|
681
|
+
else out(`${plural(total, 'finding')}${opts.aiConfig ? ` (${plural(hiddenRunCount, 'hidden run')})` : ''} in ${files.length} of ${plural(scanned, kind)}.`);
|
|
448
682
|
}
|
|
449
683
|
return errors > 0 ? 2 : total > 0 ? 1 : 0;
|
|
450
684
|
}
|
|
@@ -493,7 +727,7 @@ async function main(argv) {
|
|
|
493
727
|
files.push({ path: label, removed, removedTotal, newlinesNormalized });
|
|
494
728
|
});
|
|
495
729
|
|
|
496
|
-
if (opts.json) {
|
|
730
|
+
if (opts.format === 'json') {
|
|
497
731
|
report(JSON.stringify({ files, dryRun: opts.dryRun, filesScanned: scanned, filesChanged: files.length, skipped }, null, 2));
|
|
498
732
|
} else {
|
|
499
733
|
const verb = opts.dryRun ? 'would fix' : 'fixed';
|
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
l&&(a=a.
|
|
5
|
-
function w(a,c=!1){a=E(a);return c?a.replace(D,""):a.replace(D,d=>{
|
|
6
|
-
function E(a){return a.replace(F,"'").replace(G,'"').replace(H,"-").replace(I,"...").replace(J,"*").replace(K,c=>String.fromCharCode(c.charCodeAt(0)-65248))}var
|
|
7
|
-
function L(a,c){a.lastIndex=0;return a.test(c)}
|
|
8
|
-
function M(a){if("string"!==typeof a)throw new TypeError("inspectText expects a string input.");const c=[],d={hasControlChars:!1,hasInvisibleChars:!1,hasMixedNewlines:!1,newlineStyle:null,hasEmojis:!1,hasNonKeyboardChars:!1,summary:c};L(
|
|
9
|
-
|
|
10
|
-
function O(a,c={}){if("string"!==typeof a)throw new TypeError("inspectTextDetailed expects a string input.");c=c&&c.maxFindings;c=Infinity===c||Number.isInteger(c)&&0<=c?c:100;const d={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},
|
|
11
|
-
l=k+1);continue}const m=k;var
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
function
|
|
1
|
+
var n=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]+/g,q=/[\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;
|
|
2
|
+
function t(a={}){const c=!!a.purgeInvisibleChars,d=!!a.purgeEmojis,f=!!a.nukeControls,h=!!a.keyboardOnlyFilter,g=!!a.normalizeNewlines,l=!!a.trimSpacesAroundNewlines,b=!!a.collapseNewLines,e=!!a.preserveParagraphs,k=!!a.collapseSpaces,r=!!a.finalTrim,m=Number.isFinite(a.maxLength)&&0<=a.maxLength?a.maxLength:Infinity,p="truncate"===a.g||"noop"===a.g||"throw"===a.g?a.g:"throw";return A=>u(A,c,d,f,h,g,l,b,e,k,r,m,p)}t.strict=a=>u(a,!0,!0,!0,!1,!0,!0,!0,!1,!0,!0);
|
|
3
|
+
t.loose=a=>u(a,!1,!1,!1,!1,!0,!0,!0,!0,!0,!0);t.keyboardOnlyEmoji=a=>u(a,!1,!1,!1,!0,!0,!0,!1,!1,!1,!0);t.keyboardOnly=a=>u(a,!0,!0,!0,!0,!0,!0,!0,!1,!0,!0);
|
|
4
|
+
function u(a,c=!1,d=!1,f=!1,h=!1,g=!1,l=!1,b=!1,e=!1,k=!1,r=!1,m=Infinity,p="throw"){if("string"!==typeof a)throw new TypeError("sanctifyText expects a string input.");"truncate"!==p&&"noop"!==p&&"throw"!==p&&(p="throw");if(a.length>m)switch(p){case "truncate":a=a.slice(0,m);break;case "noop":return a;default:throw new RangeError(`sanctifyText input length ${a.length} exceeds maxLength ${m}.`);}c&&(a=a.replace(q,""));d&&(a=a.replace(v,""));f&&(a=a.replace(n,""));h&&(a=w(a,d));g&&(a=a.replace(x,"\n"));
|
|
5
|
+
l&&(a=a.replace(y,"$1"));b&&(c=a,a=e?c.replace(z,"\n\n"):c.replace(B,"\n"));k&&(a=a.replace(C," "));return r?a.trim():a}var D=/[^\x20-\x7E\r\n]+/gu;function w(a,c=!1){a=E(a);return c?a.replace(D,""):a.replace(D,d=>{v.lastIndex=0;if(v.test(d)){v.lastIndex=0;var f="";for(const h of d.matchAll(v))f+=h[0];d=f}else d="";return d})}
|
|
6
|
+
var F=/[\u2018\u2019\u201A\u201B\u2032\u2035]/g,G=/[\u201C\u201D\u201E\u201F\u2033\u2036\u00AB\u00BB]/g,H=/[\u2012\u2013\u2014\u2015\u2212]/g,I=/\u2026/g,J=/[\u2022\u00B7]/g,K=/[\uFF01-\uFF5E]/g;function E(a){return a.replace(F,"'").replace(G,'"').replace(H,"-").replace(I,"...").replace(J,"*").replace(K,c=>String.fromCharCode(c.charCodeAt(0)-65248))}var v;try{v=RegExp("(?:\\p{Extended_Pictographic}(?:\\uFE0F|\\uFE0E)?(?:\\u200D(?:\\p{Extended_Pictographic}|\\w)+)*)","gu")}catch{v=/[\u{2700}-\u{27BF}\u{1F300}-\u{1FAFF}\u{1F3FB}-\u{1F3FF}\u200D\uFE0F\u{1F1E6}-\u{1F1FF}]/gu}
|
|
7
|
+
var x=/\r\n|\r|\n/g,y=/[ \t]*(\n+)[ \t]*/g,B=/\n{2,}/g,z=/\n{3,}/g,C=/ {2,}/g;function L(a,c){a.lastIndex=0;return a.test(c)}
|
|
8
|
+
function M(a){if("string"!==typeof a)throw new TypeError("inspectText expects a string input.");const c=[],d={hasControlChars:!1,hasInvisibleChars:!1,hasMixedNewlines:!1,newlineStyle:null,hasEmojis:!1,hasNonKeyboardChars:!1,summary:c};L(n,a)&&(d.hasControlChars=!0,c.push("Control characters detected."));L(q,a)&&(d.hasInvisibleChars=!0,c.push("Invisible Unicode characters detected."));L(v,a)&&(d.hasEmojis=!0,c.push("Emojis detected."));const {mixed:f,types:h}=N(a);d.hasMixedNewlines=f;d.newlineStyle=
|
|
9
|
+
f?"Mixed":h[0]||null;d.newlineStyle&&c.push(f?"Mixed newline styles detected.":`Consistent newline style: ${d.newlineStyle}`);a:{a=E(a);if(L(D,a))for(g of a)if(!("\n"===g||"\r"===g||" "<=g&&"~">=g||L(v,g))){var g=!0;break a}g=!1}g&&(d.hasNonKeyboardChars=!0,c.push("Non-keyboard characters detected."));return d}
|
|
10
|
+
function O(a,c={}){if("string"!==typeof a)throw new TypeError("inspectTextDetailed expects a string input.");c=c&&c.maxFindings;c=Infinity===c||Number.isInteger(c)&&0<=c?c:100;const d={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},f=[];let h=0,g=1,l=0;for(let k=0,r=a.length;k<r;k++){var b=a.charCodeAt(k);if(32<=b&&127>b)continue;if(10===b){g++;l=k+1;continue}if(13===b){10!==a.charCodeAt(k+1)&&(g++,
|
|
11
|
+
l=k+1);continue}const m=k;var e=b;if(55296<=b&&56319>=b){e=a.charCodeAt(k+1);if(!(56320<=e&&57343>=e))continue;e=65536+(b-55296<<10)+(e-56320);b=917504>e?113828>e?113824>e?null:"invisible_format":119155>e?null:119163>e?"invisible_format":null:917760>e?917632>e?"tag":"invisible_format":918E3>e?"variation_selector":921600>e?"invisible_format":null;k++}else b=8192>b?174>b?32>b?11>b?1>b?"nul":9>b?"c0_control":null:13>b?"c0_control":14>b?null:"c0_control":160>b?127>b?null:128>b?"c0_control":"c1_control":
|
|
12
|
+
161>b?"nonbreaking_space":173>b?null:"invisible_format":4449>b?1564>b?847>b?null:848>b?"invisible_format":null:1565>b?"bidi_control":4447>b?null:"hangul_filler":6155>b?6068>b?null:6070>b?"invisible_format":null:6159>b?6158>b?"variation_selector":"invisible_format":6160>b?"variation_selector":null:8304>b?8240>b?8208>b?8203>b?"nonbreaking_space":8206>b?"zero_width":"bidi_control":8234>b?null:8239>b?"bidi_control":"nonbreaking_space":8289>b?8287>b?null:8288>b?"nonbreaking_space":"zero_width":8294>b?
|
|
13
|
+
"invisible_format":8298>b?"bidi_control":"invisible_format":65040>b?12644>b?12288>b?null:12289>b?"nonbreaking_space":null:12645>b?"hangul_filler":65024>b?null:"variation_selector":65440>b?65279>b?null:65280>b?"bom":null:65520>b?65441>b?"hangul_filler":null:65529>b?"invisible_format":null;null!==b&&(d[b]++,h++,f.length<c&&(e={offset:m,line:g,column:m-l+1,codePoint:e,category:b},"bom"===b&&(e.atStart=0===m),f.push(e)))}return{findings:f,counts:d,total:h,truncated:h>f.length}}
|
|
14
|
+
function N(a){if("string"!==typeof a)throw new TypeError("getNewlineStats expects a string input.");var c=0;let d=0,f=0;for(let h=0,g=a.length;h<g;h++){const l=a.charCodeAt(h);13===l?10===a.charCodeAt(h+1)?(c++,h++):d++:10===l&&f++}a={crlf:c,cr:d,lf:f};c=[];0<a.crlf&&c.push("CRLF");0<a.cr&&c.push("CR");0<a.lf&&c.push("LF");return{...a,types:c,mixed:1<c.length}}
|
|
15
|
+
function P(a){return{purgeInvisibleChars:a.hasInvisibleChars,purgeEmojis:a.hasEmojis,nukeControls:a.hasControlChars,keyboardOnlyFilter:a.hasNonKeyboardChars,normalizeNewlines:a.hasMixedNewlines||"CRLF"===a.newlineStyle||"CR"===a.newlineStyle}}export { t as summonSanctifier, M as inspectText, P as getRecommendedSanctifierOptions, O as inspectTextDetailed, N as getNewlineStats };
|