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 CHANGED
@@ -7,7 +7,7 @@
7
7
  [![stars](https://img.shields.io/github/stars/iWhatty/text-sanctifier?style=social)](https://github.com/iWhatty/text-sanctifier)
8
8
  [![types](https://img.shields.io/npm/types/text-sanctifier)](https://www.npmjs.com/package/text-sanctifier)
9
9
 
10
- Brutal text normalizer and invisible Unicode scrubber for modern web projects. ~2.44 KB gzipped.
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 exit codes
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
- | Category | Code points |
167
- | --- | --- |
168
- | `bidi_control` | U+061C, U+200E, U+200F, U+202A–U+202E, U+2066–U+2069 |
169
- | `zero_width` | U+200B–U+200D, U+2060 |
170
- | `nonbreaking_space` | U+00A0, U+2000–U+200A, U+202F, U+205F, U+3000 (non-breaking and typographic spaces) |
171
- | `bom` | U+FEFF; `atStart` is true at offset 0, where a BOM is legitimate |
172
- | `nul` | U+0000 |
173
- | `c0_control` | U+0001–U+001F except tab, LF and CR; U+007F |
174
- | `c1_control` | U+0080–U+009F |
175
- | `invisible_format` | U+00AD (soft hyphen), U+034F, U+180E, U+2061–U+2064 (invisible operators) |
176
- | `hangul_filler` | U+115F, U+1160, U+3164, U+FFA0 (letters that render blank) |
177
- | `variation_selector` | U+FE00–U+FE0F, U+E0100–U+E01EF |
178
- | `tag` | U+E0000–U+E007F (tag characters, used for "ASCII smuggling" of hidden instructions) |
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 70 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.
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`: one JSON object on stdout, `{ files, total, filesScanned, filesWithFindings, skipped }`. Each entry in `files` is `{ path, ...inspectTextDetailed(text) }`; only files with findings are listed.
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`: `{ files, dryRun, filesScanned, filesChanged, skipped }`, where each file is `{ path, removed, removedTotal, newlinesNormalized }`.
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
 
@@ -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 JSON report on stdout
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 JSON report
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
- '--json': 'json',
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
- json: false,
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
- if (opts.command === 'check') {
123
- for (const [flag, key] of Object.entries(BOOLEAN_FLAGS)) {
124
- if (FIX_ONLY_FLAGS.has(key) && opts[key]) throw new UsageError(`${flag} only applies to fix.`);
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
- * Expands the path arguments into a de-duplicated input list.
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
- const listed =
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 (listed.length === 0) warn(`${arg}: no files matched`);
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
- const report = inspect(text, opts.maxFindings, opts.stripBom);
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
- if (report.total > 0) files.push({ path: label, ...report });
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
- if (opts.json) {
439
- out(JSON.stringify({ files, total, filesScanned: scanned, filesWithFindings: files.length, skipped }, null, 2));
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 f of files) {
442
- for (const x of f.findings) out(`${f.path}:${x.line}:${x.column} ${hex(x.codePoint)} ${x.category}`);
443
- if (f.truncated) out(`${f.path}: ${f.total - f.findings.length} more not listed (--max-findings)`);
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
- function n(a={}){const c=!!a.purgeInvisibleChars,d=!!a.purgeEmojis,e=!!a.nukeControls,g=!!a.keyboardOnlyFilter,f=!!a.normalizeNewlines,l=!!a.trimSpacesAroundNewlines,b=!!a.collapseNewLines,h=!!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=>q(A,c,d,e,g,f,l,b,h,k,r,m,p)}n.strict=a=>q(a,!0,!0,!0,!1,!0,!0,!0,!1,!0,!0);
2
- n.loose=a=>q(a,!1,!1,!1,!1,!0,!0,!0,!0,!0,!0);n.keyboardOnlyEmoji=a=>q(a,!1,!1,!1,!0,!0,!0,!1,!1,!1,!0);n.keyboardOnly=a=>q(a,!0,!0,!0,!0,!0,!0,!0,!1,!0,!0);
3
- function q(a,c=!1,d=!1,e=!1,g=!1,f=!1,l=!1,b=!1,h=!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(t,""));d&&(a=a.replace(u,""));e&&(a=a.replace(v,""));g&&(a=w(a,d));f&&(a=a.replace(x,"\n"));
4
- l&&(a=a.replace(y,"$1"));b&&(c=a,a=h?c.replace(z,"\n\n"):c.replace(B,"\n"));k&&(a=a.replace(C," "));return r?a.trim():a}var t=/[\u00a0\u00ad\u034f\u061c\u115f\u1160\u180e\u2000-\u200f\u202a-\u202f\u205f\u2060-\u2064\u2066-\u2069\u3000\u3164\ufe00-\ufe0f\ufeff\uffa0\u{E0000}-\u{E007F}\u{E0100}-\u{E01EF}]+/gu,D=/[^\x20-\x7E\r\n]+/gu;
5
- function w(a,c=!1){a=E(a);return c?a.replace(D,""):a.replace(D,d=>{u.lastIndex=0;if(u.test(d)){u.lastIndex=0;var e="";for(const g of d.matchAll(u))e+=g[0];d=e}else d="";return d})}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;
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 u;try{u=RegExp("(?:\\p{Extended_Pictographic}(?:\\uFE0F|\\uFE0E)?(?:\\u200D(?:\\p{Extended_Pictographic}|\\w)+)*)","gu")}catch{u=/[\u{2700}-\u{27BF}\u{1F300}-\u{1FAFF}\u{1F3FB}-\u{1F3FF}\u200D\uFE0F\u{1F1E6}-\u{1F1FF}]/gu}var x=/\r\n|\r|\n/g,y=/[ \t]*(\n+)[ \t]*/g,B=/\n{2,}/g,z=/\n{3,}/g,C=/ {2,}/g,v=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F\u0080-\u009F\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]+/g;
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(v,a)&&(d.hasControlChars=!0,c.push("Control characters detected."));L(t,a)&&(d.hasInvisibleChars=!0,c.push("Invisible Unicode characters detected."));L(u,a)&&(d.hasEmojis=!0,c.push("Emojis detected."));const {mixed:e,types:g}=N(a);d.hasMixedNewlines=e;d.newlineStyle=
9
- e?"Mixed":g[0]||null;d.newlineStyle&&c.push(e?"Mixed newline styles detected.":`Consistent newline style: ${d.newlineStyle}`);a:{a=E(a);if(L(D,a))for(f of a)if(!("\n"===f||"\r"===f||" "<=f&&"~">=f||L(u,f))){var f=!0;break a}f=!1}f&&(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},e=[];let g=0,f=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){f++;l=k+1;continue}if(13===b){10!==a.charCodeAt(k+1)&&(f++,
11
- l=k+1);continue}const m=k;var h=b;if(56128===b){h=a.charCodeAt(k+1);if(!(56320<=h&&57343>=h))continue;h=65536+(b-55296<<10)+(h-56320);b=917504<=h&&917631>=h?"tag":917760<=h&&917999>=h?"variation_selector":null;k++}else b=128>b?0===b?"nul":32>b?9===b||10===b||13===b?null:"c0_control":127===b?"c0_control":null:159>=b?"c1_control":160===b?"nonbreaking_space":173===b||847===b||6158===b?"invisible_format":1564===b?"bidi_control":4447===b||4448===b?"hangul_filler":8192>b?null:8202>=b?"nonbreaking_space":
12
- 8205>=b?"zero_width":8207>=b||8234<=b&&8238>=b?"bidi_control":8239===b||8287===b?"nonbreaking_space":8288===b?"zero_width":8289<=b&&8292>=b?"invisible_format":8294<=b&&8297>=b?"bidi_control":12288===b?"nonbreaking_space":12644===b||65440===b?"hangul_filler":65024<=b&&65039>=b?"variation_selector":65279===b?"bom":null;null!==b&&(d[b]++,g++,e.length<c&&(h={offset:m,line:f,column:m-l+1,codePoint:h,category:b},"bom"===b&&(h.atStart=0===m),e.push(h)))}return{findings:e,counts:d,total:g,truncated:g>e.length}}
13
- function N(a){if("string"!==typeof a)throw new TypeError("getNewlineStats expects a string input.");var c=0;let d=0,e=0;for(let g=0,f=a.length;g<f;g++){const l=a.charCodeAt(g);13===l?10===a.charCodeAt(g+1)?(c++,g++):d++:10===l&&e++}a={crlf:c,cr:d,lf:e};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}}
14
- 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 { n as summonSanctifier, M as inspectText, P as getRecommendedSanctifierOptions, O as inspectTextDetailed, N as getNewlineStats };
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 };