text-sanctifier 1.3.3 → 1.3.5

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.
@@ -1,753 +1,794 @@
1
- #!/usr/bin/env node
2
- // bin/text-sanctifier.js
3
- //
4
- // Command-line front end for coding agents and CI:
5
- // text-sanctifier check [paths...] report invisible and control characters
6
- // text-sanctifier check --ai-config report hidden text in agent instruction files
7
- // text-sanctifier fix [paths...] remove them in place
8
- //
9
- // Plain Node (>=18), no dependencies. Exit codes: 0 clean, 1 findings (check,
10
- // or fix --dry-run with changes pending), 2 usage or I/O error.
11
-
12
- import { readFileSync, writeFileSync, statSync, readdirSync, existsSync } from 'node:fs';
13
- import { resolve, join, sep, relative } from 'node:path';
14
- import { spawnSync } from 'node:child_process';
15
- import { pathToFileURL } from 'node:url';
16
- import { inspectTextDetailed, summonSanctifier } from '../src/index.js';
17
- import { getNewlineStats } from '../src/inspectText.js';
18
- import { findHiddenRuns } from '../src/hiddenText.js';
19
-
20
- const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
21
-
22
- const USAGE = `text-sanctifier ${VERSION}
23
-
24
- Usage:
25
- text-sanctifier check [options] [paths...]
26
- text-sanctifier fix [options] [paths...]
27
-
28
- Paths may be files, directories or globs (quote globs so the shell leaves them
29
- alone). "-", or no paths with piped input, reads stdin. Directories and globs
30
- skip files ignored by git (inside a git work tree) and skip binary files.
31
-
32
- check options:
33
- --format F text (default), json or sarif (SARIF 2.1.0)
34
- --json same as --format json
35
- --max-findings N findings listed per file (default 100, "all" for no cap)
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
41
-
42
- fix options:
43
- --purge-invisible remove zero-width, non-breaking space, bidi and BOM characters
44
- --nuke-controls remove C0/C1 control characters and bidi controls
45
- --normalize-newlines convert CRLF and CR to LF
46
- --strip-bom also remove a BOM at the start of a file (kept by default)
47
- --dry-run report what would change; write nothing
48
- --format F text (default) or json
49
- --json same as --format json
50
- With no cleaning flag, fix uses --purge-invisible --nuke-controls.
51
- stdin is cleaned to stdout; the report then goes to stderr.
52
-
53
- Other:
54
- -h, --help show this help
55
- -v, --version show the version
56
-
57
- Exit codes: 0 clean, 1 findings (check) or changes pending (fix --dry-run),
58
- 2 usage or I/O error.
59
- `;
60
-
61
- const BOM = 0xFEFF;
62
- const STDIN = '-';
63
- const STDIN_LABEL = '<stdin>';
64
- const BINARY_SNIFF_BYTES = 8000;
65
- const WALK_SKIP_DIRS = new Set(['.git', 'node_modules']);
66
- const CASE_INSENSITIVE_FS = process.platform === 'win32' || process.platform === 'darwin';
67
-
68
- class UsageError extends Error {}
69
-
70
-
71
- // --- Arguments ---
72
-
73
- const BOOLEAN_FLAGS = {
74
- '--ai-config': 'aiConfig',
75
- '--strip-bom': 'stripBom',
76
- '--purge-invisible': 'purgeInvisible',
77
- '--nuke-controls': 'nukeControls',
78
- '--normalize-newlines': 'normalizeNewlines',
79
- '--dry-run': 'dryRun',
80
- };
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']);
84
-
85
- /**
86
- * @param {string[]} argv
87
- */
88
- function parseArgs(argv) {
89
- const opts = {
90
- command: null,
91
- paths: [],
92
- format: null,
93
- aiConfig: false,
94
- stripBom: false,
95
- purgeInvisible: false,
96
- nukeControls: false,
97
- normalizeNewlines: false,
98
- dryRun: false,
99
- maxFindings: 100,
100
- help: false,
101
- version: false,
102
- };
103
- let endOfOptions = false;
104
-
105
- for (let i = 0; i < argv.length; i++) {
106
- const arg = argv[i];
107
-
108
- if (endOfOptions || arg === STDIN || !arg.startsWith('-')) {
109
- if (opts.command === null && !endOfOptions) opts.command = arg;
110
- else opts.paths.push(arg);
111
- continue;
112
- }
113
- if (arg === '--') { endOfOptions = true; continue; }
114
- if (arg === '-h' || arg === '--help') { opts.help = true; continue; }
115
- if (arg === '-v' || arg === '--version') { opts.version = true; continue; }
116
-
117
- const [name, inline] = arg.split(/=(.*)/s, 2);
118
- if (name === '--max-findings') {
119
- const value = inline !== undefined ? inline : argv[++i];
120
- opts.maxFindings = parseMaxFindings(value);
121
- continue;
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
- }
132
- if (name in BOOLEAN_FLAGS && inline === undefined) {
133
- opts[BOOLEAN_FLAGS[name]] = true;
134
- continue;
135
- }
136
- throw new UsageError(`Unknown option: ${arg}`);
137
- }
138
-
139
- if (opts.help || opts.version) return opts;
140
- if (opts.command !== 'check' && opts.command !== 'fix') {
141
- throw new UsageError(opts.command === null ? 'Missing command.' : `Unknown command: ${opts.command}`);
142
- }
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.`);
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.');
149
- if (opts.command === 'fix' && !opts.purgeInvisible && !opts.nukeControls && !opts.normalizeNewlines) {
150
- opts.purgeInvisible = true;
151
- opts.nukeControls = true;
152
- }
153
- return opts;
154
- }
155
-
156
- /** @param {string|undefined} value */
157
- function parseMaxFindings(value) {
158
- if (value === 'all') return Infinity;
159
- const n = Number(value);
160
- if (value === undefined || value === '' || !Number.isInteger(n) || n < 0) {
161
- throw new UsageError(`--max-findings expects a non-negative integer or "all", got ${value === undefined ? 'nothing' : JSON.stringify(value)}.`);
162
- }
163
- return n;
164
- }
165
-
166
-
167
- // --- File collection ---
168
-
169
- const GLOB_CHARS = /[*?[{]/;
170
-
171
- /** @param {string} p */
172
- const toSlash = (p) => p.split(sep).join('/');
173
-
174
- /** @param {string} cwd */
175
- function isGitWorkTree(cwd) {
176
- const r = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd, encoding: 'utf8' });
177
- return !r.error && r.status === 0 && r.stdout.trim() === 'true';
178
- }
179
-
180
- /**
181
- * Lists tracked and untracked-but-not-ignored files under one pathspec.
182
- * Returns null when git cannot answer (for example a path outside the work tree).
183
- * @param {string} cwd
184
- * @param {string} pathspec
185
- * @returns {string[]|null}
186
- */
187
- function gitListFiles(cwd, pathspec) {
188
- const r = spawnSync(
189
- 'git',
190
- ['ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', pathspec],
191
- { cwd, encoding: 'utf8', maxBuffer: 1 << 28 }
192
- );
193
- if (r.error || r.status !== 0) return null;
194
- // --cached lists tracked files deleted from the work tree; drop them
195
- return r.stdout.split('\0').filter((f) => f && existsSync(resolve(cwd, f)));
196
- }
197
-
198
- /**
199
- * Recursive file walk used outside git. Skips .git and node_modules.
200
- * @param {string} cwd
201
- * @param {string} dir - relative or absolute, as the user typed it
202
- * @returns {string[]}
203
- */
204
- function walk(cwd, dir) {
205
- const out = [];
206
- const stack = [dir];
207
- while (stack.length) {
208
- const current = stack.pop();
209
- let entries;
210
- try {
211
- entries = readdirSync(resolve(cwd, current), { withFileTypes: true });
212
- } catch {
213
- continue;
214
- }
215
- entries.sort((a, b) => (a.name < b.name ? 1 : a.name > b.name ? -1 : 0));
216
- for (const e of entries) {
217
- const p = current === '.' ? e.name : join(current, e.name);
218
- if (e.isDirectory()) {
219
- if (!WALK_SKIP_DIRS.has(e.name)) stack.push(p);
220
- } else if (e.isFile()) {
221
- out.push(toSlash(p));
222
- }
223
- }
224
- }
225
- return out.sort();
226
- }
227
-
228
- /**
229
- * Glob to RegExp for the non-git fallback: `**`, `*`, `?`, `[...]` and `{a,b}`.
230
- * @param {string} glob
231
- */
232
- function globToRegExp(glob) {
233
- let re = '';
234
- for (let i = 0; i < glob.length; i++) {
235
- const c = glob[i];
236
- if (c === '*') {
237
- if (glob[i + 1] === '*') {
238
- const slashAfter = glob[i + 2] === '/';
239
- re += slashAfter ? '(?:.*/)?' : '.*';
240
- i += slashAfter ? 2 : 1;
241
- } else {
242
- re += '[^/]*';
243
- }
244
- } else if (c === '?') {
245
- re += '[^/]';
246
- } else if (c === '[') {
247
- const close = glob.indexOf(']', i + 1);
248
- if (close === -1) { re += '\\['; continue; }
249
- let body = glob.slice(i + 1, close).replace(/\\/g, '\\\\');
250
- if (body[0] === '!') body = '^' + body.slice(1);
251
- re += `[${body}]`;
252
- i = close;
253
- } else if (c === '{') {
254
- const close = glob.indexOf('}', i + 1);
255
- if (close === -1) { re += '\\{'; continue; }
256
- const alts = glob.slice(i + 1, close).split(',').map((a) => a.replace(/[.+^$()|\\]/g, '\\$&'));
257
- re += `(?:${alts.join('|')})`;
258
- i = close;
259
- } else {
260
- re += c.replace(/[.+^$()|\\\]}]/g, '\\$&');
261
- }
262
- }
263
- return new RegExp(`^${re}$`);
264
- }
265
-
266
- /**
267
- * @param {string} cwd
268
- * @param {string} glob
269
- */
270
- function walkGlob(cwd, glob) {
271
- const pattern = toSlash(glob).replace(/^\.\//, '');
272
- const segments = pattern.split('/');
273
- const firstGlob = segments.findIndex((s) => GLOB_CHARS.test(s));
274
- const base = segments.slice(0, firstGlob).join('/') || '.';
275
- const re = globToRegExp(pattern);
276
- return walk(cwd, base).filter((f) => re.test(f));
277
- }
278
-
279
- /**
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).
304
- * @param {string[]} args
305
- * @param {string} cwd
306
- * @param {(msg: string) => void} warn
307
- * @param {boolean} [aiConfig]
308
- * @returns {{ inputs: Array<{ path: string, explicit: boolean }>, errors: number }}
309
- */
310
- function collectInputs(args, cwd, warn, aiConfig = false) {
311
- const inputs = [];
312
- const seen = new Set();
313
- let errors = 0;
314
- let inGit;
315
-
316
- const add = (path, explicit) => {
317
- const abs = resolve(cwd, path);
318
- const key = path === STDIN ? STDIN : CASE_INSENSITIVE_FS ? abs.toLowerCase() : abs;
319
- if (seen.has(key)) return;
320
- seen.add(key);
321
- inputs.push({ path, explicit });
322
- };
323
-
324
- for (const arg of args) {
325
- if (arg === STDIN) { add(STDIN, true); continue; }
326
-
327
- let stat = null;
328
- try { stat = statSync(resolve(cwd, arg)); } catch { /* not a plain path */ }
329
-
330
- if (stat && stat.isFile()) { add(arg, true); continue; }
331
-
332
- const isGlob = !stat && GLOB_CHARS.test(arg);
333
- if (!stat && !isGlob) {
334
- warn(`${arg}: no such file or directory`);
335
- errors++;
336
- continue;
337
- }
338
-
339
- if (inGit === undefined) inGit = isGitWorkTree(cwd);
340
- let listed =
341
- (inGit && gitListFiles(cwd, isGlob ? `:(glob)${toSlash(arg)}` : toSlash(arg))) ||
342
- (isGlob ? walkGlob(cwd, arg) : walk(cwd, arg));
343
-
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
- }
350
- for (const f of listed) add(f, false);
351
- }
352
- return { inputs, errors };
353
- }
354
-
355
-
356
- // --- Reading ---
357
-
358
- const utf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
359
-
360
- /**
361
- * Decodes UTF-8, keeping a leading BOM. Returns null for binary or non-UTF-8 data.
362
- * @param {Uint8Array} bytes
363
- * @returns {{ text: string|null, reason?: string }}
364
- */
365
- function decode(bytes) {
366
- if (bytes.subarray(0, BINARY_SNIFF_BYTES).includes(0)) return { text: null, reason: 'binary' };
367
- try {
368
- return { text: utf8.decode(bytes) };
369
- } catch {
370
- return { text: null, reason: 'not UTF-8' };
371
- }
372
- }
373
-
374
- /** @returns {Promise<Buffer>} */
375
- function readStdin() {
376
- return new Promise((res, rej) => {
377
- const chunks = [];
378
- process.stdin.on('data', (c) => chunks.push(c));
379
- process.stdin.on('end', () => res(Buffer.concat(chunks)));
380
- process.stdin.on('error', rej);
381
- });
382
- }
383
-
384
-
385
- // --- check ---
386
-
387
- /**
388
- * inspectTextDetailed, minus a BOM at offset 0 unless stripBom.
389
- * @param {string} text
390
- * @param {number} maxFindings
391
- * @param {boolean} stripBom
392
- */
393
- function inspect(text, maxFindings, stripBom) {
394
- const leadingBom = !stripBom && text.charCodeAt(0) === BOM;
395
- const report = inspectTextDetailed(text, { maxFindings: leadingBom ? maxFindings + 1 : maxFindings });
396
- if (leadingBom) {
397
- report.findings.shift();
398
- report.counts.bom--;
399
- report.total--;
400
- report.truncated = report.total > report.findings.length;
401
- }
402
- return report;
403
- }
404
-
405
- /** @param {number} cp */
406
- const hex = (cp) => 'U+' + cp.toString(16).toUpperCase().padStart(4, '0');
407
-
408
- /**
409
- * @param {Record<string, number>} counts
410
- */
411
- const formatCounts = (counts) =>
412
- Object.entries(counts).filter(([, n]) => n > 0).map(([k, n]) => `${k} ${n}`).join(', ');
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
-
569
-
570
- // --- Main ---
571
-
572
- /**
573
- * @param {string[]} argv
574
- * @returns {Promise<number>} exit code
575
- */
576
- async function main(argv) {
577
- const out = (s) => process.stdout.write(s + '\n');
578
- const err = (s) => process.stderr.write(s + '\n');
579
-
580
- let opts;
581
- try {
582
- opts = parseArgs(argv);
583
- } catch (e) {
584
- if (!(e instanceof UsageError)) throw e;
585
- err(`text-sanctifier: ${e.message}\nRun "text-sanctifier --help" for usage.`);
586
- return 2;
587
- }
588
- if (opts.help) { out(USAGE); return 0; }
589
- if (opts.version) { out(VERSION); return 0; }
590
-
591
- const cwd = process.cwd();
592
- let paths = opts.paths;
593
- if (paths.length === 0 && opts.aiConfig) {
594
- paths = ['.'];
595
- } else if (paths.length === 0) {
596
- if (process.stdin.isTTY) {
597
- err('text-sanctifier: no paths given and nothing piped on stdin.\nRun "text-sanctifier --help" for usage.');
598
- return 2;
599
- }
600
- paths = [STDIN];
601
- }
602
-
603
- const warn = (msg) => err(`text-sanctifier: ${msg}`);
604
- const { inputs, errors: pathErrors } = collectInputs(paths, cwd, warn, opts.aiConfig);
605
- let errors = pathErrors;
606
- /** @type {Array<{ path: string, reason: string }>} */
607
- const skipped = [];
608
-
609
- /**
610
- * Reads every input in order, calling `visit(label, text, input)` for each decodable one.
611
- * @param {(label: string, text: string, input: { path: string, explicit: boolean }) => void} visit
612
- */
613
- const forEachText = async (visit) => {
614
- for (const input of inputs) {
615
- const label = input.path === STDIN ? STDIN_LABEL : input.path;
616
- let bytes;
617
- try {
618
- bytes = input.path === STDIN ? await readStdin() : readFileSync(resolve(cwd, input.path));
619
- } catch (e) {
620
- warn(`${label}: ${e.code || e.message}`);
621
- errors++;
622
- continue;
623
- }
624
- const { text, reason } = decode(bytes);
625
- if (text === null) {
626
- skipped.push({ path: label, reason });
627
- if (input.explicit) warn(`${label}: skipped (${reason})`);
628
- continue;
629
- }
630
- visit(label, text, input);
631
- }
632
- };
633
-
634
- if (opts.command === 'check') {
635
- const files = [];
636
- let scanned = 0;
637
- let total = 0;
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
-
644
- await forEachText((label, text) => {
645
- scanned++;
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);
657
- total += report.total;
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)`);
668
- });
669
-
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));
677
- } else {
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)}.`);
682
- }
683
- return errors > 0 ? 2 : total > 0 ? 1 : 0;
684
- }
685
-
686
- // fix
687
- const clean = summonSanctifier({
688
- purgeInvisibleChars: opts.purgeInvisible,
689
- nukeControls: opts.nukeControls,
690
- normalizeNewlines: opts.normalizeNewlines,
691
- });
692
- const writesStdout = !opts.dryRun && inputs.some((i) => i.path === STDIN);
693
- const report = writesStdout ? err : out;
694
- const files = [];
695
- let scanned = 0;
696
-
697
- await forEachText((label, text, input) => {
698
- scanned++;
699
- // The BOM at offset 0 is set aside so purgeInvisibleChars cannot take it
700
- const leadingBom = text.charCodeAt(0) === BOM;
701
- let cleaned = clean(leadingBom ? text.slice(1) : text);
702
- if (leadingBom && !opts.stripBom) cleaned = String.fromCharCode(BOM) + cleaned;
703
-
704
- if (input.path === STDIN && !opts.dryRun) process.stdout.write(cleaned);
705
- if (cleaned === text) return;
706
-
707
- const before = inspectTextDetailed(text, { maxFindings: 0 }).counts;
708
- const after = inspectTextDetailed(cleaned, { maxFindings: 0 }).counts;
709
- const removed = {};
710
- let removedTotal = 0;
711
- for (const k of Object.keys(before)) {
712
- const n = before[k] - (after[k] || 0);
713
- if (n > 0) { removed[k] = n; removedTotal += n; }
714
- }
715
- const nl = getNewlineStats(text);
716
- const newlinesNormalized = opts.normalizeNewlines && nl.crlf + nl.cr > 0;
717
-
718
- if (input.path !== STDIN && !opts.dryRun) {
719
- try {
720
- writeFileSync(resolve(cwd, input.path), cleaned, 'utf8');
721
- } catch (e) {
722
- warn(`${label}: ${e.code || e.message}`);
723
- errors++;
724
- return;
725
- }
726
- }
727
- files.push({ path: label, removed, removedTotal, newlinesNormalized });
728
- });
729
-
730
- if (opts.format === 'json') {
731
- report(JSON.stringify({ files, dryRun: opts.dryRun, filesScanned: scanned, filesChanged: files.length, skipped }, null, 2));
732
- } else {
733
- const verb = opts.dryRun ? 'would fix' : 'fixed';
734
- for (const f of files) {
735
- const parts = [];
736
- if (f.removedTotal > 0) parts.push(`removed ${f.removedTotal} (${formatCounts(f.removed)})`);
737
- if (f.newlinesNormalized) parts.push('normalized newlines');
738
- report(`${verb} ${f.path}: ${parts.join('; ')}`);
739
- }
740
- report(files.length === 0
741
- ? `Nothing to fix in ${scanned} file${scanned === 1 ? '' : 's'}.`
742
- : `${opts.dryRun ? 'Would change' : 'Changed'} ${files.length} of ${scanned} file${scanned === 1 ? '' : 's'}.`);
743
- }
744
- return errors > 0 ? 2 : opts.dryRun && files.length > 0 ? 1 : 0;
745
- }
746
-
747
- main(process.argv.slice(2)).then(
748
- (code) => { process.exitCode = code; },
749
- (e) => {
750
- process.stderr.write(`text-sanctifier: ${e && e.stack ? e.stack : e}\n`);
751
- process.exitCode = 2;
752
- }
753
- );
1
+ #!/usr/bin/env node
2
+ // bin/text-sanctifier.js
3
+ //
4
+ // Command-line front end for coding agents and CI:
5
+ // text-sanctifier check [paths...] report invisible and control characters
6
+ // text-sanctifier check --ai-config report hidden text in agent instruction files
7
+ // text-sanctifier fix [paths...] remove them in place
8
+ // text-sanctifier reveal [paths...] print the text with hidden characters shown as <U+XXXX>
9
+ //
10
+ // Plain Node (>=18), no dependencies. Exit codes: 0 clean, 1 findings (check,
11
+ // or fix --dry-run with changes pending), 2 usage or I/O error.
12
+
13
+ import { readFileSync, writeFileSync, statSync, readdirSync, existsSync } from 'node:fs';
14
+ import { resolve, join, sep, relative } from 'node:path';
15
+ import { spawnSync } from 'node:child_process';
16
+ import { pathToFileURL } from 'node:url';
17
+ import { inspectTextDetailed, summonSanctifier, revealInvisible } from '../src/index.js';
18
+ import { getNewlineStats } from '../src/inspectText.js';
19
+ import { SPACE_LOOKALIKES_REGEX } from '../src/sanctifyText.js';
20
+ import { findHiddenRuns } from '../src/hiddenText.js';
21
+
22
+ const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
23
+
24
+ const USAGE = `text-sanctifier ${VERSION}
25
+
26
+ Usage:
27
+ text-sanctifier check [options] [paths...]
28
+ text-sanctifier fix [options] [paths...]
29
+ text-sanctifier reveal [paths...]
30
+
31
+ Paths may be files, directories or globs (quote globs so the shell leaves them
32
+ alone). "-", or no paths with piped input, reads stdin. Directories and globs
33
+ skip files ignored by git (inside a git work tree) and skip binary files.
34
+
35
+ check options:
36
+ --format F text (default), json or sarif (SARIF 2.1.0)
37
+ --json same as --format json
38
+ --max-findings N findings listed per file (default 100, "all" for no cap)
39
+ --strip-bom also report a BOM at the start of a file
40
+ --ai-config check agent instruction files (CLAUDE.md, AGENTS.md,
41
+ .cursor/rules, SKILL.md, MCP config, ...) for hidden text;
42
+ directories, or "." when no paths are given, are searched
43
+ for those files, and tag text is decoded
44
+
45
+ fix options:
46
+ --normalize-spaces replace look-alike spaces (NBSP, narrow NBSP, typographic
47
+ spaces) with an ordinary space instead of removing them
48
+ --purge-invisible remove zero-width, non-breaking space, bidi and BOM characters
49
+ --nuke-controls remove C0/C1 control characters and bidi controls
50
+ --normalize-newlines convert CRLF and CR to LF
51
+ --strip-bom also remove a BOM at the start of a file (kept by default)
52
+ --dry-run report what would change; write nothing
53
+ --format F text (default) or json
54
+ --json same as --format json
55
+ With no cleaning flag, fix uses --normalize-spaces --purge-invisible --nuke-controls.
56
+ stdin is cleaned to stdout; the report then goes to stderr.
57
+
58
+ reveal:
59
+ Prints each input with every character check would report replaced by a
60
+ marker such as <U+200B>; everything else, including newlines, is unchanged.
61
+ Several inputs are printed one after another under "==> path <==" headers.
62
+ Takes no options. Exit code 0 unless an input can't be read.
63
+
64
+ Other:
65
+ -h, --help show this help
66
+ -v, --version show the version
67
+
68
+ Exit codes: 0 clean, 1 findings (check) or changes pending (fix --dry-run),
69
+ 2 usage or I/O error.
70
+ `;
71
+
72
+ const BOM = 0xFEFF;
73
+ const STDIN = '-';
74
+ const STDIN_LABEL = '<stdin>';
75
+ const BINARY_SNIFF_BYTES = 8000;
76
+ const WALK_SKIP_DIRS = new Set(['.git', 'node_modules']);
77
+ const CASE_INSENSITIVE_FS = process.platform === 'win32' || process.platform === 'darwin';
78
+
79
+ class UsageError extends Error {}
80
+
81
+
82
+ // --- Arguments ---
83
+
84
+ const BOOLEAN_FLAGS = {
85
+ '--ai-config': 'aiConfig',
86
+ '--strip-bom': 'stripBom',
87
+ '--normalize-spaces': 'normalizeSpaces',
88
+ '--purge-invisible': 'purgeInvisible',
89
+ '--nuke-controls': 'nukeControls',
90
+ '--normalize-newlines': 'normalizeNewlines',
91
+ '--dry-run': 'dryRun',
92
+ };
93
+ const FIX_ONLY_FLAGS = new Set(['normalizeSpaces', 'purgeInvisible', 'nukeControls', 'normalizeNewlines', 'dryRun']);
94
+ const CHECK_ONLY_FLAGS = new Set(['aiConfig']);
95
+ const FORMATS = new Set(['text', 'json', 'sarif']);
96
+
97
+ /**
98
+ * @param {string[]} argv
99
+ */
100
+ function parseArgs(argv) {
101
+ const opts = {
102
+ command: null,
103
+ paths: [],
104
+ format: null,
105
+ aiConfig: false,
106
+ stripBom: false,
107
+ normalizeSpaces: false,
108
+ purgeInvisible: false,
109
+ nukeControls: false,
110
+ normalizeNewlines: false,
111
+ dryRun: false,
112
+ maxFindings: 100,
113
+ help: false,
114
+ version: false,
115
+ };
116
+ let endOfOptions = false;
117
+
118
+ for (let i = 0; i < argv.length; i++) {
119
+ const arg = argv[i];
120
+
121
+ if (endOfOptions || arg === STDIN || !arg.startsWith('-')) {
122
+ if (opts.command === null && !endOfOptions) opts.command = arg;
123
+ else opts.paths.push(arg);
124
+ continue;
125
+ }
126
+ if (arg === '--') { endOfOptions = true; continue; }
127
+ if (arg === '-h' || arg === '--help') { opts.help = true; continue; }
128
+ if (arg === '-v' || arg === '--version') { opts.version = true; continue; }
129
+
130
+ const [name, inline] = arg.split(/=(.*)/s, 2);
131
+ if (name === '--max-findings') {
132
+ const value = inline !== undefined ? inline : argv[++i];
133
+ opts.maxFindings = parseMaxFindings(value);
134
+ continue;
135
+ }
136
+ if (name === '--format' || (name === '--json' && inline === undefined)) {
137
+ const value = name === '--json' ? 'json' : inline !== undefined ? inline : argv[++i];
138
+ if (!FORMATS.has(value)) {
139
+ throw new UsageError(`--format expects text, json or sarif, got ${value === undefined ? 'nothing' : JSON.stringify(value)}.`);
140
+ }
141
+ if (opts.format !== null && opts.format !== value) throw new UsageError(`Conflicting formats: ${opts.format} and ${value}.`);
142
+ opts.format = value;
143
+ continue;
144
+ }
145
+ if (name in BOOLEAN_FLAGS && inline === undefined) {
146
+ opts[BOOLEAN_FLAGS[name]] = true;
147
+ continue;
148
+ }
149
+ throw new UsageError(`Unknown option: ${arg}`);
150
+ }
151
+
152
+ if (opts.help || opts.version) return opts;
153
+ if (opts.command !== 'check' && opts.command !== 'fix' && opts.command !== 'reveal') {
154
+ throw new UsageError(opts.command === null ? 'Missing command.' : `Unknown command: ${opts.command}`);
155
+ }
156
+ if (opts.command === 'reveal') {
157
+ for (const [flag, key] of Object.entries(BOOLEAN_FLAGS)) {
158
+ if (opts[key]) throw new UsageError(`${flag} does not apply to reveal.`);
159
+ }
160
+ if (opts.format !== null) throw new UsageError('--format does not apply to reveal.');
161
+ if (opts.maxFindings !== 100) throw new UsageError('--max-findings does not apply to reveal.');
162
+ return opts;
163
+ }
164
+ for (const [flag, key] of Object.entries(BOOLEAN_FLAGS)) {
165
+ if (opts.command === 'check' && FIX_ONLY_FLAGS.has(key) && opts[key]) throw new UsageError(`${flag} only applies to fix.`);
166
+ if (opts.command === 'fix' && CHECK_ONLY_FLAGS.has(key) && opts[key]) throw new UsageError(`${flag} only applies to check.`);
167
+ }
168
+ if (opts.format === null) opts.format = 'text';
169
+ if (opts.command === 'fix' && opts.format === 'sarif') throw new UsageError('--format sarif only applies to check.');
170
+ if (opts.command === 'fix' && !opts.normalizeSpaces && !opts.purgeInvisible && !opts.nukeControls && !opts.normalizeNewlines) {
171
+ opts.normalizeSpaces = true;
172
+ opts.purgeInvisible = true;
173
+ opts.nukeControls = true;
174
+ }
175
+ return opts;
176
+ }
177
+
178
+ /** @param {string|undefined} value */
179
+ function parseMaxFindings(value) {
180
+ if (value === 'all') return Infinity;
181
+ const n = Number(value);
182
+ if (value === undefined || value === '' || !Number.isInteger(n) || n < 0) {
183
+ throw new UsageError(`--max-findings expects a non-negative integer or "all", got ${value === undefined ? 'nothing' : JSON.stringify(value)}.`);
184
+ }
185
+ return n;
186
+ }
187
+
188
+
189
+ // --- File collection ---
190
+
191
+ const GLOB_CHARS = /[*?[{]/;
192
+
193
+ /** @param {string} p */
194
+ const toSlash = (p) => p.split(sep).join('/');
195
+
196
+ /** @param {string} cwd */
197
+ function isGitWorkTree(cwd) {
198
+ const r = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd, encoding: 'utf8' });
199
+ return !r.error && r.status === 0 && r.stdout.trim() === 'true';
200
+ }
201
+
202
+ /**
203
+ * Lists tracked and untracked-but-not-ignored files under one pathspec.
204
+ * Returns null when git cannot answer (for example a path outside the work tree).
205
+ * @param {string} cwd
206
+ * @param {string} pathspec
207
+ * @returns {string[]|null}
208
+ */
209
+ function gitListFiles(cwd, pathspec) {
210
+ const r = spawnSync(
211
+ 'git',
212
+ ['ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', pathspec],
213
+ { cwd, encoding: 'utf8', maxBuffer: 1 << 28 }
214
+ );
215
+ if (r.error || r.status !== 0) return null;
216
+ // --cached lists tracked files deleted from the work tree; drop them
217
+ return r.stdout.split('\0').filter((f) => f && existsSync(resolve(cwd, f)));
218
+ }
219
+
220
+ /**
221
+ * Recursive file walk used outside git. Skips .git and node_modules.
222
+ * @param {string} cwd
223
+ * @param {string} dir - relative or absolute, as the user typed it
224
+ * @returns {string[]}
225
+ */
226
+ function walk(cwd, dir) {
227
+ const out = [];
228
+ const stack = [dir];
229
+ while (stack.length) {
230
+ const current = stack.pop();
231
+ let entries;
232
+ try {
233
+ entries = readdirSync(resolve(cwd, current), { withFileTypes: true });
234
+ } catch {
235
+ continue;
236
+ }
237
+ entries.sort((a, b) => (a.name < b.name ? 1 : a.name > b.name ? -1 : 0));
238
+ for (const e of entries) {
239
+ const p = current === '.' ? e.name : join(current, e.name);
240
+ if (e.isDirectory()) {
241
+ if (!WALK_SKIP_DIRS.has(e.name)) stack.push(p);
242
+ } else if (e.isFile()) {
243
+ out.push(toSlash(p));
244
+ }
245
+ }
246
+ }
247
+ return out.sort();
248
+ }
249
+
250
+ /**
251
+ * Glob to RegExp for the non-git fallback: `**`, `*`, `?`, `[...]` and `{a,b}`.
252
+ * @param {string} glob
253
+ */
254
+ function globToRegExp(glob) {
255
+ let re = '';
256
+ for (let i = 0; i < glob.length; i++) {
257
+ const c = glob[i];
258
+ if (c === '*') {
259
+ if (glob[i + 1] === '*') {
260
+ const slashAfter = glob[i + 2] === '/';
261
+ re += slashAfter ? '(?:.*/)?' : '.*';
262
+ i += slashAfter ? 2 : 1;
263
+ } else {
264
+ re += '[^/]*';
265
+ }
266
+ } else if (c === '?') {
267
+ re += '[^/]';
268
+ } else if (c === '[') {
269
+ const close = glob.indexOf(']', i + 1);
270
+ if (close === -1) { re += '\\['; continue; }
271
+ let body = glob.slice(i + 1, close).replace(/\\/g, '\\\\');
272
+ if (body[0] === '!') body = '^' + body.slice(1);
273
+ re += `[${body}]`;
274
+ i = close;
275
+ } else if (c === '{') {
276
+ const close = glob.indexOf('}', i + 1);
277
+ if (close === -1) { re += '\\{'; continue; }
278
+ const alts = glob.slice(i + 1, close).split(',').map((a) => a.replace(/[.+^$()|\\]/g, '\\$&'));
279
+ re += `(?:${alts.join('|')})`;
280
+ i = close;
281
+ } else {
282
+ re += c.replace(/[.+^$()|\\\]}]/g, '\\$&');
283
+ }
284
+ }
285
+ return new RegExp(`^${re}$`);
286
+ }
287
+
288
+ /**
289
+ * @param {string} cwd
290
+ * @param {string} glob
291
+ */
292
+ function walkGlob(cwd, glob) {
293
+ const pattern = toSlash(glob).replace(/^\.\//, '');
294
+ const segments = pattern.split('/');
295
+ const firstGlob = segments.findIndex((s) => GLOB_CHARS.test(s));
296
+ const base = segments.slice(0, firstGlob).join('/') || '.';
297
+ const re = globToRegExp(pattern);
298
+ return walk(cwd, base).filter((f) => re.test(f));
299
+ }
300
+
301
+ /**
302
+ * Agent instruction and configuration files, relative to the directory searched.
303
+ * Matched case-insensitively. Keep .pre-commit-hooks.yaml (the text-sanctifier-ai-config
304
+ * hook's `files`) in step; tests/cli.test.js checks both against the same paths.
305
+ */
306
+ const AI_CONFIG_GLOBS = [
307
+ '**/CLAUDE.md', '**/CLAUDE.local.md', '**/AGENTS.md', '**/AGENT.md', '**/GEMINI.md', '**/SKILL.md',
308
+ '.claude/**',
309
+ '.cursorrules', '.cursor/rules/**', '.cursor/mcp.json',
310
+ '.windsurfrules', '.windsurf/rules/**',
311
+ '.clinerules', '.clinerules/**',
312
+ '.roorules', '.roo/rules/**',
313
+ '.github/copilot-instructions.md', '.github/instructions/**', '.github/prompts/**',
314
+ '.github/chatmodes/**', '.github/agents/**',
315
+ '.mcp.json', 'mcp.json', '.vscode/mcp.json',
316
+ ];
317
+
318
+ const AI_CONFIG_RES = AI_CONFIG_GLOBS.map((g) => new RegExp(globToRegExp(g).source, 'i'));
319
+
320
+ /** @param {string} rel - slash-separated path relative to the directory searched */
321
+ const isAiConfigPath = (rel) => AI_CONFIG_RES.some((re) => re.test(rel));
322
+
323
+ /**
324
+ * Expands the path arguments into a de-duplicated input list. With `aiConfig`,
325
+ * directories contribute only agent instruction files (AI_CONFIG_GLOBS).
326
+ * @param {string[]} args
327
+ * @param {string} cwd
328
+ * @param {(msg: string) => void} warn
329
+ * @param {boolean} [aiConfig]
330
+ * @returns {{ inputs: Array<{ path: string, explicit: boolean }>, errors: number }}
331
+ */
332
+ function collectInputs(args, cwd, warn, aiConfig = false) {
333
+ const inputs = [];
334
+ const seen = new Set();
335
+ let errors = 0;
336
+ let inGit;
337
+
338
+ const add = (path, explicit) => {
339
+ const abs = resolve(cwd, path);
340
+ const key = path === STDIN ? STDIN : CASE_INSENSITIVE_FS ? abs.toLowerCase() : abs;
341
+ if (seen.has(key)) return;
342
+ seen.add(key);
343
+ inputs.push({ path, explicit });
344
+ };
345
+
346
+ for (const arg of args) {
347
+ if (arg === STDIN) { add(STDIN, true); continue; }
348
+
349
+ let stat = null;
350
+ try { stat = statSync(resolve(cwd, arg)); } catch { /* not a plain path */ }
351
+
352
+ if (stat && stat.isFile()) { add(arg, true); continue; }
353
+
354
+ const isGlob = !stat && GLOB_CHARS.test(arg);
355
+ if (!stat && !isGlob) {
356
+ warn(`${arg}: no such file or directory`);
357
+ errors++;
358
+ continue;
359
+ }
360
+
361
+ if (inGit === undefined) inGit = isGitWorkTree(cwd);
362
+ let listed =
363
+ (inGit && gitListFiles(cwd, isGlob ? `:(glob)${toSlash(arg)}` : toSlash(arg))) ||
364
+ (isGlob ? walkGlob(cwd, arg) : walk(cwd, arg));
365
+
366
+ if (aiConfig && !isGlob) {
367
+ const base = resolve(cwd, arg);
368
+ listed = listed.filter((f) => isAiConfigPath(toSlash(relative(base, resolve(cwd, f)))));
369
+ } else if (listed.length === 0) {
370
+ warn(`${arg}: no files matched`);
371
+ }
372
+ for (const f of listed) add(f, false);
373
+ }
374
+ return { inputs, errors };
375
+ }
376
+
377
+
378
+ // --- Reading ---
379
+
380
+ const utf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
381
+
382
+ /**
383
+ * Decodes UTF-8, keeping a leading BOM. Returns null for binary or non-UTF-8 data.
384
+ * @param {Uint8Array} bytes
385
+ * @returns {{ text: string|null, reason?: string }}
386
+ */
387
+ function decode(bytes) {
388
+ if (bytes.subarray(0, BINARY_SNIFF_BYTES).includes(0)) return { text: null, reason: 'binary' };
389
+ try {
390
+ return { text: utf8.decode(bytes) };
391
+ } catch {
392
+ return { text: null, reason: 'not UTF-8' };
393
+ }
394
+ }
395
+
396
+ /** @returns {Promise<Buffer>} */
397
+ function readStdin() {
398
+ return new Promise((res, rej) => {
399
+ const chunks = [];
400
+ process.stdin.on('data', (c) => chunks.push(c));
401
+ process.stdin.on('end', () => res(Buffer.concat(chunks)));
402
+ process.stdin.on('error', rej);
403
+ });
404
+ }
405
+
406
+
407
+ // --- check ---
408
+
409
+ /**
410
+ * inspectTextDetailed, minus a BOM at offset 0 unless stripBom.
411
+ * @param {string} text
412
+ * @param {number} maxFindings
413
+ * @param {boolean} stripBom
414
+ */
415
+ function inspect(text, maxFindings, stripBom) {
416
+ const leadingBom = !stripBom && text.charCodeAt(0) === BOM;
417
+ const report = inspectTextDetailed(text, { maxFindings: leadingBom ? maxFindings + 1 : maxFindings });
418
+ if (leadingBom) {
419
+ report.findings.shift();
420
+ report.counts.bom--;
421
+ report.total--;
422
+ report.truncated = report.total > report.findings.length;
423
+ }
424
+ return report;
425
+ }
426
+
427
+ /** @param {number} cp */
428
+ const hex = (cp) => 'U+' + cp.toString(16).toUpperCase().padStart(4, '0');
429
+
430
+ /**
431
+ * @param {Record<string, number>} counts
432
+ */
433
+ const formatCounts = (counts) =>
434
+ Object.entries(counts).filter(([, n]) => n > 0).map(([k, n]) => `${k} ${n}`).join(', ');
435
+
436
+ /**
437
+ * @param {number} n
438
+ * @param {string} word
439
+ */
440
+ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
441
+
442
+
443
+ // --- check --ai-config ---
444
+
445
+ const MAX_DECODED = 1000;
446
+ const RUN_NOUNS = { tag: 'tag character', variation_selector: 'variation selector', invisible_operator: 'invisible operator' };
447
+
448
+ /**
449
+ * inspect() for agent instruction files: drops findings that belong to legitimate
450
+ * sequences (subdivision flags, a lone emoji presentation selector) and adds the runs
451
+ * of tag characters, variation selectors and invisible operators, with tag text decoded.
452
+ * @param {string} text
453
+ * @param {number} maxFindings
454
+ * @param {boolean} stripBom
455
+ */
456
+ function inspectAiConfig(text, maxFindings, stripBom) {
457
+ const full = inspect(text, Infinity, stripBom);
458
+ const { runs, benign } = findHiddenRuns(text);
459
+
460
+ const kept = [];
461
+ let b = 0;
462
+ for (const f of full.findings) {
463
+ while (b < benign.length && benign[b][1] <= f.offset) b++;
464
+ if (b < benign.length && benign[b][0] <= f.offset) { full.counts[f.category]--; continue; }
465
+ kept.push(f);
466
+ }
467
+
468
+ // Every run character is a finding, so each run starts at a kept finding
469
+ const hiddenRuns = [];
470
+ const loose = [];
471
+ let r = 0;
472
+ for (const f of kept) {
473
+ while (r < runs.length && runs[r].end <= f.offset) r++;
474
+ const run = runs[r];
475
+ if (!run || f.offset < run.offset) { loose.push(f); continue; }
476
+ if (f.offset !== run.offset) continue;
477
+ const entry = { kind: run.kind, offset: run.offset, line: f.line, column: f.column, codePoint: f.codePoint, count: run.count };
478
+ if (run.decoded !== undefined) {
479
+ entry.decoded = run.decoded.slice(0, MAX_DECODED);
480
+ entry.decodedTruncated = run.decoded.length > MAX_DECODED;
481
+ }
482
+ hiddenRuns.push(entry);
483
+ }
484
+
485
+ const findings = kept.slice(0, maxFindings);
486
+ return {
487
+ report: {
488
+ ...full,
489
+ findings,
490
+ total: kept.length,
491
+ truncated: kept.length > findings.length,
492
+ hiddenRuns: hiddenRuns.slice(0, maxFindings),
493
+ hiddenRunCount: hiddenRuns.length,
494
+ },
495
+ hiddenRuns,
496
+ loose,
497
+ };
498
+ }
499
+
500
+ /** @param {{ kind: string, count: number, decoded?: string, decodedTruncated?: boolean }} run */
501
+ function describeRun(run) {
502
+ const what = plural(run.count, RUN_NOUNS[run.kind]);
503
+ if (run.decoded === undefined) return `run of ${what}`;
504
+ return `run of ${what} spelling ${JSON.stringify(run.decoded)}${run.decodedTruncated ? ' (cut short)' : ''}`;
505
+ }
506
+
507
+
508
+ // --- SARIF ---
509
+
510
+ // Level and description per category, for SARIF rules. Every category needs an entry;
511
+ // tests/cli.test.js checks the rules against inspectTextDetailed's counts.
512
+ const CATEGORY_RULES = {
513
+ bidi_control: ['error', 'Bidirectional control character, which can make text display in a different order than it is read (Trojan Source)'],
514
+ zero_width: ['warning', 'Zero-width character'],
515
+ nonbreaking_space: ['note', 'Non-breaking or typographic space that looks like an ordinary space'],
516
+ bom: ['warning', 'Byte order mark (U+FEFF) that is not at the start of the file'],
517
+ nul: ['warning', 'NUL character'],
518
+ c0_control: ['warning', 'C0 control character other than tab, LF and CR'],
519
+ c1_control: ['warning', 'C1 control character'],
520
+ invisible_format: ['warning', 'Invisible format character, such as a soft hyphen or an invisible operator'],
521
+ hangul_filler: ['warning', 'Hangul filler, a letter that renders blank'],
522
+ variation_selector: ['warning', 'Variation selector, which can carry hidden data'],
523
+ tag: ['error', 'Tag character, which can hide ASCII text that people cannot see but language models read'],
524
+ };
525
+
526
+ const SARIF_SCHEMA = 'https://json.schemastore.org/sarif-2.1.0.json';
527
+ const INFORMATION_URI = 'https://github.com/iWhatty/text-sanctifier';
528
+ const ABSOLUTE_PATH = /^(?:[a-zA-Z]:)?[\\/]/;
529
+
530
+ /** @param {string} label */
531
+ function sarifArtifact(label) {
532
+ if (label === STDIN_LABEL) return { uri: 'stdin', description: { text: 'standard input' } };
533
+ if (ABSOLUTE_PATH.test(label)) return { uri: pathToFileURL(label).href };
534
+ return { uri: encodeURI(toSlash(label)).replace(/[?#]/g, encodeURIComponent), uriBaseId: '%SRCROOT%' };
535
+ }
536
+
537
+ /**
538
+ * Builds a SARIF 2.1.0 log with one result per listed finding; the rule is the category.
539
+ * @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
540
+ * @param {{ cwd: string, total: number, filesScanned: number, skipped: Array<{ path: string, reason: string }>, succeeded: boolean }} info
541
+ */
542
+ function sarifLog(files, { cwd, total, filesScanned, skipped, succeeded }) {
543
+ const ruleIds = Object.keys(inspectTextDetailed('', { maxFindings: 0 }).counts);
544
+ const rules = ruleIds.map((id) => ({
545
+ id,
546
+ shortDescription: { text: CATEGORY_RULES[id][1] },
547
+ defaultConfiguration: { level: CATEGORY_RULES[id][0] },
548
+ }));
549
+
550
+ const results = [];
551
+ for (const f of files) {
552
+ const runAt = new Map((f.hiddenRuns || []).map((run) => [run.offset, run]));
553
+ const artifactLocation = sarifArtifact(f.path);
554
+ for (const x of f.findings) {
555
+ const run = runAt.get(x.offset);
556
+ results.push({
557
+ ruleId: x.category,
558
+ ruleIndex: ruleIds.indexOf(x.category),
559
+ level: CATEGORY_RULES[x.category][0],
560
+ message: { text: `${hex(x.codePoint)} ${x.category}${run ? `, start of a ${describeRun(run)}` : ''}` },
561
+ locations: [{
562
+ physicalLocation: {
563
+ artifactLocation,
564
+ region: { startLine: x.line, startColumn: x.column, endColumn: x.column + (x.codePoint > 0xFFFF ? 2 : 1) },
565
+ },
566
+ }],
567
+ });
568
+ }
569
+ }
570
+
571
+ return {
572
+ $schema: SARIF_SCHEMA,
573
+ version: '2.1.0',
574
+ runs: [{
575
+ tool: { driver: { name: 'text-sanctifier', version: VERSION, semanticVersion: VERSION, informationUri: INFORMATION_URI, rules } },
576
+ originalUriBaseIds: { '%SRCROOT%': { uri: pathToFileURL(cwd.endsWith(sep) ? cwd : cwd + sep).href } },
577
+ columnKind: 'utf16CodeUnits',
578
+ invocations: [{ executionSuccessful: succeeded }],
579
+ results,
580
+ properties: {
581
+ total,
582
+ filesScanned,
583
+ filesWithFindings: files.length,
584
+ truncated: files.filter((f) => f.truncated).map((f) => f.path),
585
+ skipped,
586
+ },
587
+ }],
588
+ };
589
+ }
590
+
591
+
592
+ // --- Main ---
593
+
594
+ /**
595
+ * @param {string[]} argv
596
+ * @returns {Promise<number>} exit code
597
+ */
598
+ async function main(argv) {
599
+ const out = (s) => process.stdout.write(s + '\n');
600
+ const err = (s) => process.stderr.write(s + '\n');
601
+
602
+ let opts;
603
+ try {
604
+ opts = parseArgs(argv);
605
+ } catch (e) {
606
+ if (!(e instanceof UsageError)) throw e;
607
+ err(`text-sanctifier: ${e.message}\nRun "text-sanctifier --help" for usage.`);
608
+ return 2;
609
+ }
610
+ if (opts.help) { out(USAGE); return 0; }
611
+ if (opts.version) { out(VERSION); return 0; }
612
+
613
+ const cwd = process.cwd();
614
+ let paths = opts.paths;
615
+ if (paths.length === 0 && opts.aiConfig) {
616
+ paths = ['.'];
617
+ } else if (paths.length === 0) {
618
+ if (process.stdin.isTTY) {
619
+ err('text-sanctifier: no paths given and nothing piped on stdin.\nRun "text-sanctifier --help" for usage.');
620
+ return 2;
621
+ }
622
+ paths = [STDIN];
623
+ }
624
+
625
+ const warn = (msg) => err(`text-sanctifier: ${msg}`);
626
+ const { inputs, errors: pathErrors } = collectInputs(paths, cwd, warn, opts.aiConfig);
627
+ let errors = pathErrors;
628
+ /** @type {Array<{ path: string, reason: string }>} */
629
+ const skipped = [];
630
+
631
+ /**
632
+ * Reads every input in order, calling `visit(label, text, input)` for each decodable one.
633
+ * @param {(label: string, text: string, input: { path: string, explicit: boolean }) => void} visit
634
+ */
635
+ const forEachText = async (visit) => {
636
+ for (const input of inputs) {
637
+ const label = input.path === STDIN ? STDIN_LABEL : input.path;
638
+ let bytes;
639
+ try {
640
+ bytes = input.path === STDIN ? await readStdin() : readFileSync(resolve(cwd, input.path));
641
+ } catch (e) {
642
+ warn(`${label}: ${e.code || e.message}`);
643
+ errors++;
644
+ continue;
645
+ }
646
+ const { text, reason } = decode(bytes);
647
+ if (text === null) {
648
+ skipped.push({ path: label, reason });
649
+ if (input.explicit) warn(`${label}: skipped (${reason})`);
650
+ continue;
651
+ }
652
+ visit(label, text, input);
653
+ }
654
+ };
655
+
656
+ if (opts.command === 'check') {
657
+ const files = [];
658
+ let scanned = 0;
659
+ let total = 0;
660
+
661
+ let hiddenRunCount = 0;
662
+ /** @type {string[]} */
663
+ const lines = [];
664
+ const findingLine = (label, x) => `${label}:${x.line}:${x.column} ${hex(x.codePoint)} ${x.category}`;
665
+
666
+ await forEachText((label, text) => {
667
+ scanned++;
668
+ if (!opts.aiConfig) {
669
+ const report = inspect(text, opts.maxFindings, opts.stripBom);
670
+ total += report.total;
671
+ if (report.total === 0) return;
672
+ files.push({ path: label, ...report });
673
+ for (const x of report.findings) lines.push(findingLine(label, x));
674
+ if (report.truncated) lines.push(`${label}: ${report.total - report.findings.length} more not listed (--max-findings)`);
675
+ return;
676
+ }
677
+
678
+ const { report, hiddenRuns, loose } = inspectAiConfig(text, opts.maxFindings, opts.stripBom);
679
+ total += report.total;
680
+ hiddenRunCount += report.hiddenRunCount;
681
+ if (report.total === 0) return;
682
+ files.push({ path: label, ...report });
683
+ // Text output lists each run once, in place of its characters
684
+ const entries = [
685
+ ...hiddenRuns.map((run) => ({ offset: run.offset, text: `${label}:${run.line}:${run.column} ${hex(run.codePoint)} ${describeRun(run)}` })),
686
+ ...loose.map((x) => ({ offset: x.offset, text: findingLine(label, x) })),
687
+ ].sort((a, b) => a.offset - b.offset);
688
+ for (const e of entries.slice(0, opts.maxFindings)) lines.push(e.text);
689
+ if (entries.length > opts.maxFindings) lines.push(`${label}: ${entries.length - opts.maxFindings} more not listed (--max-findings)`);
690
+ });
691
+
692
+ const kind = opts.aiConfig ? 'AI configuration file' : 'file';
693
+ if (opts.format === 'json') {
694
+ const summary = { files, total, filesScanned: scanned, filesWithFindings: files.length, skipped };
695
+ if (opts.aiConfig) summary.hiddenRunCount = hiddenRunCount;
696
+ out(JSON.stringify(summary, null, 2));
697
+ } else if (opts.format === 'sarif') {
698
+ out(JSON.stringify(sarifLog(files, { cwd, total, filesScanned: scanned, skipped, succeeded: errors === 0 }), null, 2));
699
+ } else {
700
+ for (const line of lines) out(line);
701
+ if (opts.aiConfig && scanned === 0 && skipped.length === 0) out('No AI configuration files found.');
702
+ else if (total === 0) out(`No ${opts.aiConfig ? 'hidden text, ' : ''}invisible or control characters in ${plural(scanned, kind)}.`);
703
+ else out(`${plural(total, 'finding')}${opts.aiConfig ? ` (${plural(hiddenRunCount, 'hidden run')})` : ''} in ${files.length} of ${plural(scanned, kind)}.`);
704
+ }
705
+ return errors > 0 ? 2 : total > 0 ? 1 : 0;
706
+ }
707
+
708
+ if (opts.command === 'reveal') {
709
+ const many = inputs.length > 1;
710
+ let first = true;
711
+ await forEachText((label, text) => {
712
+ if (many) {
713
+ process.stdout.write(`${first ? '' : '\n'}==> ${label} <==\n`);
714
+ first = false;
715
+ }
716
+ process.stdout.write(revealInvisible(text));
717
+ });
718
+ return errors > 0 ? 2 : 0;
719
+ }
720
+
721
+ // fix
722
+ const clean = summonSanctifier({
723
+ normalizeSpaces: opts.normalizeSpaces,
724
+ purgeInvisibleChars: opts.purgeInvisible,
725
+ nukeControls: opts.nukeControls,
726
+ normalizeNewlines: opts.normalizeNewlines,
727
+ });
728
+ const writesStdout = !opts.dryRun && inputs.some((i) => i.path === STDIN);
729
+ const report = writesStdout ? err : out;
730
+ const files = [];
731
+ let scanned = 0;
732
+
733
+ await forEachText((label, text, input) => {
734
+ scanned++;
735
+ // The BOM at offset 0 is set aside so purgeInvisibleChars cannot take it
736
+ const leadingBom = text.charCodeAt(0) === BOM;
737
+ let cleaned = clean(leadingBom ? text.slice(1) : text);
738
+ if (leadingBom && !opts.stripBom) cleaned = String.fromCharCode(BOM) + cleaned;
739
+
740
+ if (input.path === STDIN && !opts.dryRun) process.stdout.write(cleaned);
741
+ if (cleaned === text) return;
742
+
743
+ const before = inspectTextDetailed(text, { maxFindings: 0 }).counts;
744
+ const after = inspectTextDetailed(cleaned, { maxFindings: 0 }).counts;
745
+ // Look-alike spaces that became ordinary spaces were replaced, not removed
746
+ SPACE_LOOKALIKES_REGEX.lastIndex = 0;
747
+ const spacesReplaced = opts.normalizeSpaces ? (text.match(SPACE_LOOKALIKES_REGEX) || []).length : 0;
748
+ before.nonbreaking_space -= spacesReplaced;
749
+ const removed = {};
750
+ let removedTotal = 0;
751
+ for (const k of Object.keys(before)) {
752
+ const n = before[k] - (after[k] || 0);
753
+ if (n > 0) { removed[k] = n; removedTotal += n; }
754
+ }
755
+ const nl = getNewlineStats(text);
756
+ const newlinesNormalized = opts.normalizeNewlines && nl.crlf + nl.cr > 0;
757
+
758
+ if (input.path !== STDIN && !opts.dryRun) {
759
+ try {
760
+ writeFileSync(resolve(cwd, input.path), cleaned, 'utf8');
761
+ } catch (e) {
762
+ warn(`${label}: ${e.code || e.message}`);
763
+ errors++;
764
+ return;
765
+ }
766
+ }
767
+ files.push({ path: label, spacesReplaced, removed, removedTotal, newlinesNormalized });
768
+ });
769
+
770
+ if (opts.format === 'json') {
771
+ report(JSON.stringify({ files, dryRun: opts.dryRun, filesScanned: scanned, filesChanged: files.length, skipped }, null, 2));
772
+ } else {
773
+ const verb = opts.dryRun ? 'would fix' : 'fixed';
774
+ for (const f of files) {
775
+ const parts = [];
776
+ if (f.spacesReplaced > 0) parts.push(`replaced ${plural(f.spacesReplaced, 'look-alike space')}`);
777
+ if (f.removedTotal > 0) parts.push(`removed ${f.removedTotal} (${formatCounts(f.removed)})`);
778
+ if (f.newlinesNormalized) parts.push('normalized newlines');
779
+ report(`${verb} ${f.path}: ${parts.join('; ')}`);
780
+ }
781
+ report(files.length === 0
782
+ ? `Nothing to fix in ${scanned} file${scanned === 1 ? '' : 's'}.`
783
+ : `${opts.dryRun ? 'Would change' : 'Changed'} ${files.length} of ${scanned} file${scanned === 1 ? '' : 's'}.`);
784
+ }
785
+ return errors > 0 ? 2 : opts.dryRun && files.length > 0 ? 1 : 0;
786
+ }
787
+
788
+ main(process.argv.slice(2)).then(
789
+ (code) => { process.exitCode = code; },
790
+ (e) => {
791
+ process.stderr.write(`text-sanctifier: ${e && e.stack ? e.stack : e}\n`);
792
+ process.exitCode = 2;
793
+ }
794
+ );