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.
- package/README.md +398 -349
- package/bin/text-sanctifier.js +794 -753
- package/dist/text-sanctifier.min.js +6 -15
- package/dist/types/index.d.ts +3 -1
- package/dist/types/reveal.d.ts +26 -0
- package/dist/types/sanctifyText.d.ts +35 -1
- package/package.json +4 -5
- package/src/categories.generated.js +223 -223
- package/src/index.d.ts +3 -1
- package/src/index.js +4 -0
- package/src/inspectText.js +343 -343
- package/src/reveal.js +40 -0
- package/src/sanctifyText.js +563 -506
package/bin/text-sanctifier.js
CHANGED
|
@@ -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
|
-
//
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
--
|
|
37
|
-
--
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
--
|
|
47
|
-
|
|
48
|
-
--
|
|
49
|
-
--
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
opts
|
|
134
|
-
continue;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
if (
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
throw new UsageError(
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
/**
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
* @param {number}
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
const
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
if (
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
const
|
|
710
|
-
let
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
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
|
+
);
|