agent-sanitizer 2.19.2 → 2.19.4
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 +5 -0
- package/THREAT-MODEL.md +24 -8
- package/claude-hooks/lib/hook-fault.mjs +224 -0
- package/claude-hooks/lib/layer-pipeline.mjs +147 -0
- package/claude-hooks/plugin-hooks.mjs +45 -9
- package/claude-hooks/pretooluse-sanitize.mjs +116 -41
- package/claude-hooks/sanitize-output.mjs +66 -19
- package/claude-hooks/sanitize-user-prompt.mjs +31 -23
- package/claude-hooks/scan-invisible-chars.mjs +235 -47
- package/package.json +1 -1
- package/src/ansi.mjs +207 -0
- package/src/confusables.mjs +6 -2
- package/src/html.mjs +130 -48
- package/src/invisible.mjs +202 -159
- package/src/layer1.mjs +101 -116
- package/src/output.mjs +28 -11
- package/src/prompt.mjs +9 -6
- package/src/rehydrate.mjs +13 -20
- package/src/view-map.mjs +59 -22
- package/types/ansi.d.mts +66 -0
- package/types/claude-hooks/lib/hook-fault.d.mts +104 -0
- package/types/claude-hooks/lib/layer-pipeline.d.mts +113 -0
- package/types/claude-hooks/pretooluse-sanitize.d.mts +16 -0
- package/types/claude-hooks/scan-invisible-chars.d.mts +74 -14
- package/types/confusables.d.mts +6 -2
- package/types/invisible.d.mts +11 -2
- package/types/layer1.d.mts +19 -10
- package/types/output.d.mts +15 -2
- package/types/view-map.d.mts +47 -3
package/src/invisible.mjs
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { joiningType, isVirama } from "./joining-type.mjs";
|
|
13
13
|
import { isStandardizedVariant } from "./standardized-variants.mjs";
|
|
14
14
|
import { CF_CODEPOINTS } from "./cf-charset.mjs";
|
|
15
|
+
import { scanAnsi, TOKEN_KIND } from "./ansi.mjs";
|
|
15
16
|
|
|
16
17
|
// Unicode's Variation_Selector property, whole: the FE00 run, the Mongolian
|
|
17
18
|
// free variation selectors, and the astral E0100 supplement. The Mongolian four
|
|
@@ -122,48 +123,32 @@ export const STRIP = new RegExp(
|
|
|
122
123
|
REGEX_FLAGS,
|
|
123
124
|
);
|
|
124
125
|
|
|
125
|
-
// SGR (Select Graphic Rendition)
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
// display-only
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
// here as the `\x9b` escape (never a raw literal byte in source: an
|
|
134
|
-
// undetectable-by-eye invisible byte in a regex literal is a correctness
|
|
135
|
-
// landmine for the next person who touches this line without a hex dump).
|
|
136
|
-
// Both encodings must be recognized — otherwise a C1-introduced
|
|
137
|
-
// `U+009B 31m … 0m` is pure color yet is misread as a non-SGR payload (or,
|
|
138
|
-
// worse, mistaken for SGR-only when its introducer was a C1 CSI that
|
|
139
|
-
// isSgrOnly's ESC-only test never saw). Text is "SGR-only" when removing
|
|
140
|
-
// these leaves no ANSI control introducer at all — a lone or partial escape is
|
|
141
|
-
// therefore not SGR-only.
|
|
142
|
-
// eslint-disable-next-line no-control-regex -- matching ESC-led sequences is the point
|
|
143
|
-
export const SGR_RE = /(?:\x1b\[|\x9b)[0-9;:]*m/g;
|
|
144
|
-
|
|
145
|
-
// The raw ANSI control introducers isSgrOnly must treat as NON-SGR after SGR
|
|
146
|
-
// removal: 7-bit ESC (U+001B) and the entire 8-bit C1 control block
|
|
147
|
-
// (U+0080–U+009F) — CSI (U+009B), the DCS/SOS/OSC/PM/APC string introducers, and
|
|
148
|
-
// ST. isSgrOnly is honest only if it tests for ALL of them — a C1 cursor-move or
|
|
149
|
-
// erase (`U+009B 2J`) leaves a U+009B, a C1-OSC string (`U+009D … BEL`) leaves a
|
|
150
|
-
// U+009D, and a C1-DCS/APC payload (`U+0090 … ST`) leaves its introducer, after
|
|
151
|
-
// SGR removal; each must read as NOT SGR-only, exactly as their 7-bit `ESC[2J` /
|
|
152
|
-
// `ESC]…` / `ESC P…` twins do. Omitting any would let a residual C1 introducer
|
|
153
|
-
// be misread as SGR-only.
|
|
154
|
-
// eslint-disable-next-line no-control-regex -- the raw introducers are what we test for
|
|
155
|
-
const CONTROL_INTRODUCER_RE = /[\x1b\u0080-\u009f]/;
|
|
126
|
+
// SGR (Select Graphic Rendition) colour matching. Re-exported from ./ansi.mjs —
|
|
127
|
+
// the ONE ANSI grammar, shared with the Layer-1 stripper, which cannot import
|
|
128
|
+
// this module (layer1.mjs imports invisible.mjs, not the other way round). The
|
|
129
|
+
// two used to be separate regexes with DIFFERENT parameter rules, and the looser
|
|
130
|
+
// copy lived here: `ESC[12345m` read as SGR-only (so the operator got a
|
|
131
|
+
// "display-only colour" note) while the stripper could not match it and spliced
|
|
132
|
+
// a visible `[12345m` into the model's view.
|
|
133
|
+
export { SGR_RE } from "./ansi.mjs";
|
|
156
134
|
|
|
157
135
|
/**
|
|
158
136
|
* True when every ANSI control introducer in `text` belongs to a display-only
|
|
159
|
-
* SGR color sequence (so stripping the ANSI
|
|
137
|
+
* SGR color sequence (so stripping the ANSI removes only cosmetic styling,
|
|
160
138
|
* nothing that could move the cursor, erase, or carry a payload). Recognizes
|
|
161
139
|
* both the 7-bit `ESC[…m` and 8-bit C1 (`U+009B…m`) SGR encodings.
|
|
140
|
+
*
|
|
141
|
+
* Answered by the STRIPPER'S OWN tokenizer: scanAnsi emits one token per raw
|
|
142
|
+
* introducer — 7-bit ESC and the whole C1 block, so a C1 cursor-move
|
|
143
|
+
* (`U+009B 2J`), a C1-OSC string (`U+009D … BEL`), a C1-DCS/APC payload and a
|
|
144
|
+
* lone or partial escape each yield a non-SGR token — and the predicate is
|
|
145
|
+
* "every token is SGR". Because the same scan decides what Layer 1 splices,
|
|
146
|
+
* this can no longer report "colour only" for bytes the stripper leaves behind.
|
|
162
147
|
* @param {string} text
|
|
163
148
|
* @returns {boolean}
|
|
164
149
|
*/
|
|
165
150
|
export function isSgrOnly(text) {
|
|
166
|
-
return
|
|
151
|
+
return scanAnsi(text).every((token) => token.kind === TOKEN_KIND.SGR);
|
|
167
152
|
}
|
|
168
153
|
|
|
169
154
|
export const LONG_RUN_THRESHOLD = 10;
|
|
@@ -348,31 +333,35 @@ const MAX_TAG_SPEC_CHARS = 6;
|
|
|
348
333
|
// An ideographic variation sequence is a CJK ideograph followed by a selector in
|
|
349
334
|
// U+E0100–U+E01EF. Preserved ONLY when the immediately preceding code point is a
|
|
350
335
|
// CJK ideograph — the registry-faithful structural gate (IVS apply to ideographs
|
|
351
|
-
// and nothing else).
|
|
352
|
-
//
|
|
336
|
+
// and nothing else).
|
|
337
|
+
//
|
|
338
|
+
// Derived from Unicode's own data rather than hand-transcribed block spans: the
|
|
339
|
+
// Unified_Ideograph property IS the set of unified ideographs (URO + every
|
|
340
|
+
// extension), versioned with the runtime's ICU, so a new extension arrives with
|
|
341
|
+
// the Node upgrade instead of waiting for someone to notice. A hand-written
|
|
342
|
+
// table went stale exactly this way — it stopped at Extension H and so denied
|
|
343
|
+
// the 4,298 Extension J ideographs (U+323B0–U+33479) a legitimate IVS base.
|
|
344
|
+
//
|
|
345
|
+
// Script=Han is deliberately NOT used: it also covers radicals (U+2E80–U+2EF3),
|
|
346
|
+
// Kangxi radicals, U+3005/U+3007 and the ideographic-description characters,
|
|
347
|
+
// none of which is an IVS base. Script_Extensions=Han is wider still (it reaches
|
|
348
|
+
// U+00B7 MIDDLE DOT and the CJK punctuation shared with Kana), so it would let a
|
|
349
|
+
// full stop anchor a variation selector.
|
|
350
|
+
//
|
|
351
|
+
// The two Compatibility Ideograph BLOCKS stay literal: they are not
|
|
352
|
+
// Unified_Ideograph, and JS RegExp exposes no \p{Block=…}. Block boundaries are
|
|
353
|
+
// immutable by Unicode's stability policy, so a literal span cannot drift; the
|
|
354
|
+
// contract test in test/invisible-unicode-tables.test.mjs pins that every
|
|
355
|
+
// ASSIGNED code point inside them is a Script=Han letter.
|
|
353
356
|
const IVS_MIN = 0xe0100;
|
|
354
357
|
const IVS_MAX = 0xe01ef;
|
|
355
|
-
const
|
|
356
|
-
[
|
|
357
|
-
[0x4e00, 0x9fff], // CJK Unified Ideographs
|
|
358
|
-
[0xf900, 0xfaff], // CJK Compatibility Ideographs
|
|
359
|
-
[0x20000, 0x2a6df], // Extension B
|
|
360
|
-
[0x2a700, 0x2b73f], // Extension C
|
|
361
|
-
[0x2b740, 0x2b81f], // Extension D
|
|
362
|
-
[0x2b820, 0x2ceaf], // Extension E
|
|
363
|
-
[0x2ceb0, 0x2ebef], // Extension F
|
|
364
|
-
[0x2ebf0, 0x2ee5f], // Extension I
|
|
365
|
-
[0x2f800, 0x2fa1f], // CJK Compatibility Ideographs Supplement
|
|
366
|
-
[0x30000, 0x3134f], // Extension G
|
|
367
|
-
[0x31350, 0x323af], // Extension H
|
|
368
|
-
];
|
|
358
|
+
const CJK_IDEOGRAPH_RE =
|
|
359
|
+
/[\p{Unified_Ideograph}\u{F900}-\u{FAFF}\u{2F800}-\u{2FA1F}]/u;
|
|
369
360
|
|
|
370
|
-
/** True when `
|
|
371
|
-
* selector legitimately follows). @param {
|
|
372
|
-
function isCjkIdeograph(
|
|
373
|
-
|
|
374
|
-
if (cp >= start && cp <= end) return true;
|
|
375
|
-
return false;
|
|
361
|
+
/** True when `ch` is a CJK ideograph (the only base an ideographic variation
|
|
362
|
+
* selector legitimately follows). @param {string} ch @returns {boolean} */
|
|
363
|
+
function isCjkIdeograph(ch) {
|
|
364
|
+
return CJK_IDEOGRAPH_RE.test(ch);
|
|
376
365
|
}
|
|
377
366
|
|
|
378
367
|
// Consonant (KA..HA and script-specific additional-consonant) ranges of the
|
|
@@ -381,28 +370,44 @@ function isCjkIdeograph(cp) {
|
|
|
381
370
|
// rendering and is a smuggling channel, so the Indic joiner is preserved only
|
|
382
371
|
// when its virama sits on one of these. Broad per-block spans — precision here
|
|
383
372
|
// only needs "a real Brahmic letter of this script", not an exact consonant set.
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
373
|
+
//
|
|
374
|
+
// UNLIKE the CJK and Hangul gates these CANNOT be derived from a property
|
|
375
|
+
// escape: the UCD property that names them is Indic_Syllabic_Category=Consonant,
|
|
376
|
+
// and JS RegExp exposes only General_Category, Script, Script_Extensions and the
|
|
377
|
+
// binary properties. \p{Script=Devanagari} is the wrong shape — it also holds the
|
|
378
|
+
// independent vowels (U+0904–U+0914), which a virama never attaches to, so
|
|
379
|
+
// switching to it would preserve joiners after a bare vowel + halant. The table
|
|
380
|
+
// therefore stays literal, and
|
|
381
|
+
// test/invisible-unicode-tables.test.mjs pins the strongest contract that IS
|
|
382
|
+
// derivable: every assigned code point in each span is a letter of the script
|
|
383
|
+
// the span names, and each span holds at least one such letter. Drift (a future Unicode
|
|
384
|
+
// filling a hole with a non-letter, or a typo'd span crossing into a neighbouring
|
|
385
|
+
// script's block) then fails CI instead of shipping.
|
|
386
|
+
// Keyed by script name so the contract test can check each span against the
|
|
387
|
+
// script it claims; exported for exactly that test.
|
|
388
|
+
/** @type {ReadonlyArray<readonly [string, number, number]>} */
|
|
389
|
+
export const BRAHMIC_CONSONANT_RANGES = [
|
|
390
|
+
["Devanagari", 0x0915, 0x0939], // KA–HA
|
|
391
|
+
["Devanagari", 0x0958, 0x095f], // additional consonants
|
|
392
|
+
["Bengali", 0x0995, 0x09b9],
|
|
393
|
+
["Bengali", 0x09dc, 0x09df], // additional consonants
|
|
394
|
+
["Gurmukhi", 0x0a15, 0x0a39],
|
|
395
|
+
["Gurmukhi", 0x0a59, 0x0a5e], // additional consonants
|
|
396
|
+
["Gujarati", 0x0a95, 0x0ab9],
|
|
397
|
+
["Oriya", 0x0b15, 0x0b39],
|
|
398
|
+
["Oriya", 0x0b5c, 0x0b5f], // additional consonants
|
|
399
|
+
["Tamil", 0x0b95, 0x0bb9],
|
|
400
|
+
["Telugu", 0x0c15, 0x0c39],
|
|
401
|
+
["Telugu", 0x0c58, 0x0c5a], // additional consonants
|
|
402
|
+
["Kannada", 0x0c95, 0x0cb9],
|
|
403
|
+
["Malayalam", 0x0d15, 0x0d3a],
|
|
404
|
+
["Sinhala", 0x0d9a, 0x0dc6],
|
|
400
405
|
];
|
|
401
406
|
|
|
402
407
|
/** True when `cp` is a Brahmic consonant — the only base a virama attaches to.
|
|
403
408
|
* @param {number} cp @returns {boolean} */
|
|
404
409
|
function isBrahmicConsonant(cp) {
|
|
405
|
-
for (const [start, end] of BRAHMIC_CONSONANT_RANGES)
|
|
410
|
+
for (const [, start, end] of BRAHMIC_CONSONANT_RANGES)
|
|
406
411
|
if (cp >= start && cp <= end) return true;
|
|
407
412
|
return false;
|
|
408
413
|
}
|
|
@@ -424,26 +429,30 @@ const HANGUL_FILLERS = new Set([0x115f, 0x1160, 0x3164, 0xffa0]);
|
|
|
424
429
|
// literal is invisible to the eye and a correctness landmine for the next editor.
|
|
425
430
|
const GATED_BLANK_RE = new RegExp("[\\u115F\\u1160\\u2800\\u3164\\uFFA0]", "u");
|
|
426
431
|
|
|
432
|
+
// Script=Braille, from the runtime's Unicode data (identical to
|
|
433
|
+
// Script_Extensions=Braille — no character is shared with another script).
|
|
434
|
+
const BRAILLE_RE = /\p{Script=Braille}/u;
|
|
435
|
+
|
|
427
436
|
/** A real (non-blank) Braille cell — the anchoring neighbour for a U+2800 blank.
|
|
428
437
|
* @param {string} ch @returns {boolean} */
|
|
429
438
|
function isBrailleCell(ch) {
|
|
430
|
-
const cp = ch ?
|
|
431
|
-
return cp
|
|
439
|
+
const cp = ch ? ch.codePointAt(0) : -1;
|
|
440
|
+
return cp !== BRAILLE_BLANK && BRAILLE_RE.test(ch);
|
|
432
441
|
}
|
|
433
442
|
|
|
443
|
+
// Script=Hangul, straight from the runtime's Unicode data. NOT
|
|
444
|
+
// Script_Extensions=Hangul: that set also holds the CJK punctuation Korean text
|
|
445
|
+
// shares with Chinese and Japanese (U+3001 IDEOGRAPHIC COMMA, U+30FB KATAKANA
|
|
446
|
+
// MIDDLE DOT, U+FF61–U+FF65 …), so a Japanese middle dot would anchor — and
|
|
447
|
+
// thereby preserve — a Hangul filler in text with no Hangul in it at all.
|
|
448
|
+
const HANGUL_RE = /\p{Script=Hangul}/u;
|
|
449
|
+
|
|
434
450
|
/** A Hangul jamo/syllable (NOT itself one of the fillers) — the anchoring
|
|
435
451
|
* neighbour for a Hangul filler. @param {string} ch @returns {boolean} */
|
|
436
452
|
function isHangul(ch) {
|
|
437
453
|
const cp = ch ? /** @type {number} */ (ch.codePointAt(0)) : -1;
|
|
438
454
|
if (HANGUL_FILLERS.has(cp)) return false; // a filler cannot anchor another filler
|
|
439
|
-
return (
|
|
440
|
-
(cp >= 0x1100 && cp <= 0x11ff) || // Hangul Jamo
|
|
441
|
-
(cp >= 0x3130 && cp <= 0x318f) || // Hangul Compatibility Jamo
|
|
442
|
-
(cp >= 0xa960 && cp <= 0xa97f) || // Jamo Extended-A
|
|
443
|
-
(cp >= 0xac00 && cp <= 0xd7a3) || // Hangul Syllables
|
|
444
|
-
(cp >= 0xd7b0 && cp <= 0xd7ff) || // Jamo Extended-B
|
|
445
|
-
(cp >= 0xffa1 && cp <= 0xffdc) // Halfwidth Jamo (FFA0 is the filler itself)
|
|
446
|
-
);
|
|
455
|
+
return HANGUL_RE.test(ch);
|
|
447
456
|
}
|
|
448
457
|
|
|
449
458
|
// Non-global single-char classifiers (CHECKS carry `g`, whose lastIndex is
|
|
@@ -598,12 +607,9 @@ function isEmojiPresentationSelector(cps, i) {
|
|
|
598
607
|
* visible) and its preserve `kind` ("joiner" | "emojivs" | "tag" | "stdvs" |
|
|
599
608
|
* "ivs" | "blank" | null). Everything invisible that is NOT preserve-eligible is
|
|
600
609
|
* payload; the scatter floor counts only that, so meaningful joiners/selectors
|
|
601
|
-
* never push honest prose over the threshold.
|
|
602
|
-
* tag sequence starting at `i` (0 elsewhere) so carveStrip can preserve-or-strip
|
|
603
|
-
* each flag as an atomic unit (a budget cut mid-sequence would leave a malformed
|
|
604
|
-
* partial run the next pass would strip — breaking idempotence).
|
|
610
|
+
* never push honest prose over the threshold.
|
|
605
611
|
* @param {string[]} cps
|
|
606
|
-
* @returns {{ codes: (string|null)[], kind: (string|null)[],
|
|
612
|
+
* @returns {{ codes: (string|null)[], kind: (string|null)[], payloadInvis: number, visibleLen: number }}
|
|
607
613
|
*/
|
|
608
614
|
function analyzeCarve(cps) {
|
|
609
615
|
const codes = cps.map(classify);
|
|
@@ -618,24 +624,13 @@ function analyzeCarve(cps) {
|
|
|
618
624
|
if (isPreservedBlankFiller(cps, i)) return "blank";
|
|
619
625
|
return null;
|
|
620
626
|
});
|
|
621
|
-
const tagSpanLen = new Array(cps.length).fill(0);
|
|
622
|
-
for (let i = 0; i < cps.length;) {
|
|
623
|
-
if (kind[i] !== "tag") {
|
|
624
|
-
i++;
|
|
625
|
-
continue;
|
|
626
|
-
}
|
|
627
|
-
let j = i;
|
|
628
|
-
while (j < cps.length && kind[j] === "tag") j++;
|
|
629
|
-
tagSpanLen[i] = j - i;
|
|
630
|
-
i = j;
|
|
631
|
-
}
|
|
632
627
|
let payloadInvis = 0;
|
|
633
628
|
let visibleLen = 0;
|
|
634
629
|
for (let i = 0; i < cps.length; i++) {
|
|
635
630
|
if (codes[i] === null) visibleLen++;
|
|
636
631
|
else if (kind[i] === null) payloadInvis++;
|
|
637
632
|
}
|
|
638
|
-
return { codes, kind,
|
|
633
|
+
return { codes, kind, payloadInvis, visibleLen };
|
|
639
634
|
}
|
|
640
635
|
|
|
641
636
|
/**
|
|
@@ -769,10 +764,7 @@ function isStandardizedVariationSelector(cps, i) {
|
|
|
769
764
|
function isIdeographicVariationSelector(cps, i) {
|
|
770
765
|
const cp = /** @type {number} */ (cps[i].codePointAt(0));
|
|
771
766
|
if (cp < IVS_MIN || cp > IVS_MAX) return false;
|
|
772
|
-
|
|
773
|
-
return prev
|
|
774
|
-
? isCjkIdeograph(/** @type {number} */ (prev.codePointAt(0)))
|
|
775
|
-
: false;
|
|
767
|
+
return isCjkIdeograph(cps[i - 1] ?? "");
|
|
776
768
|
}
|
|
777
769
|
|
|
778
770
|
/**
|
|
@@ -792,18 +784,55 @@ function isPreservedBlankFiller(cps, i) {
|
|
|
792
784
|
return false;
|
|
793
785
|
}
|
|
794
786
|
|
|
787
|
+
// The grapheme segmenter: the real UAX #29 implementation shipped with the
|
|
788
|
+
// runtime's ICU, not a hand-rolled approximation of cluster boundaries. It
|
|
789
|
+
// defines the unit the preserve budget is charged against (see carveStrip).
|
|
790
|
+
// Grapheme segmentation is locale-independent, so the locale is pinned to "en"
|
|
791
|
+
// only for determinism across hosts.
|
|
792
|
+
const GRAPHEME_SEGMENTER = new Intl.Segmenter("en", {
|
|
793
|
+
granularity: "grapheme",
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* The code-point index one past the end of each grapheme cluster of `body`, in
|
|
798
|
+
* order — the segmenter's UTF-16 boundaries restated in code-point space, which
|
|
799
|
+
* is the space `carveStrip`'s per-code-point arrays live in. ECMA-402 guarantees
|
|
800
|
+
* the segments partition the input, so the last entry is always the code-point
|
|
801
|
+
* length of `body`.
|
|
802
|
+
* @param {string} body
|
|
803
|
+
* @returns {number[]}
|
|
804
|
+
*/
|
|
805
|
+
function clusterEnds(body) {
|
|
806
|
+
const ends = [];
|
|
807
|
+
let end = 0;
|
|
808
|
+
for (const { segment } of GRAPHEME_SEGMENTER.segment(body)) {
|
|
809
|
+
end += Array.from(segment).length;
|
|
810
|
+
ends.push(end);
|
|
811
|
+
}
|
|
812
|
+
return ends;
|
|
813
|
+
}
|
|
814
|
+
|
|
795
815
|
/**
|
|
796
816
|
* Carve-out strip (an invisible the carve-out might preserve is present): walk
|
|
797
|
-
*
|
|
798
|
-
* `kind`
|
|
799
|
-
*
|
|
800
|
-
* (
|
|
801
|
-
*
|
|
802
|
-
*
|
|
803
|
-
*
|
|
804
|
-
*
|
|
805
|
-
*
|
|
806
|
-
*
|
|
817
|
+
* GRAPHEME CLUSTERS, preserving a cluster's joiners/selectors/tags/blank-fillers
|
|
818
|
+
* only where each has its `kind` set AND the text stays under the scatter floor
|
|
819
|
+
* AND the whole cluster fits inside the remaining per-run
|
|
820
|
+
* (CONSECUTIVE_JOINER_CAP / CONSECUTIVE_SELECTOR_CAP) and document-wide
|
|
821
|
+
* (TOTAL_PRESERVED_JOINER_BUDGET) preserve allowance — otherwise every
|
|
822
|
+
* preservable char in that cluster is stripped like any other payload byte.
|
|
823
|
+
*
|
|
824
|
+
* The budget is charged against the CLUSTER, not the code point, because the
|
|
825
|
+
* cluster is the indivisible unit: charging per code point let a limit fall due
|
|
826
|
+
* mid-cluster and carve one grapheme in half (👨👩👧👦 with the budget exhausted
|
|
827
|
+
* after its first ZWJ came out as 👨👩👧👦 — three glyphs where the author wrote
|
|
828
|
+
* one). All-or-nothing per cluster makes that impossible by construction rather
|
|
829
|
+
* than merely unlikely, and it subsumes the tag (subregional-flag) sequence's
|
|
830
|
+
* hand-rolled atomicity: a flag's tag chars are Grapheme_Cluster_Break=Extend,
|
|
831
|
+
* so they are already inside their base pictograph's cluster.
|
|
832
|
+
*
|
|
833
|
+
* `found` reports only categories actually removed, so a preserved char never
|
|
834
|
+
* makes the caller claim a strip that did not happen, and a stuffed channel
|
|
835
|
+
* surfaces as its category once it overruns the budget.
|
|
807
836
|
* @param {string} body
|
|
808
837
|
* @returns {{ cleaned: string, found: string[] }}
|
|
809
838
|
*/
|
|
@@ -812,8 +841,7 @@ function carveStrip(body) {
|
|
|
812
841
|
// Pass 1: classify + evaluate the gate once (see analyzeCarve). Only PAYLOAD
|
|
813
842
|
// invisibles count toward the scatter floor, so a meaningful-joiner-dense text
|
|
814
843
|
// (formal Persian, a long Devanagari conjunct run) stays under it.
|
|
815
|
-
const { codes, kind,
|
|
816
|
-
analyzeCarve(cps);
|
|
844
|
+
const { codes, kind, payloadInvis, visibleLen } = analyzeCarve(cps);
|
|
817
845
|
// SCATTERED_THRESHOLD is the floor on payload invisibles: past it the document
|
|
818
846
|
// is drowning in hidden bytes, so the carve-out is off and even a meaningful
|
|
819
847
|
// joiner is stripped (threshold-evasion catch — over-strip beats under).
|
|
@@ -848,55 +876,70 @@ function carveStrip(body) {
|
|
|
848
876
|
// char is stripped and its category reported.
|
|
849
877
|
let preservedTotal = 0;
|
|
850
878
|
let prevVisible = false;
|
|
851
|
-
let
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
879
|
+
let start = 0;
|
|
880
|
+
for (const end of clusterEnds(body)) {
|
|
881
|
+
// Charge the WHOLE cluster's preservables against every limit at once: if
|
|
882
|
+
// any one of them would fall due part-way through, none of the cluster is
|
|
883
|
+
// preserved. `need === 0` (the common case: a cluster with no preservable
|
|
884
|
+
// invisible) leaves every counter untouched.
|
|
885
|
+
let need = 0;
|
|
886
|
+
let joiners = 0;
|
|
887
|
+
let selectors = 0;
|
|
888
|
+
for (let k = start; k < end; k++) {
|
|
889
|
+
if (kind[k] === null) continue;
|
|
890
|
+
need++;
|
|
891
|
+
if (kind[k] === "joiner") joiners++;
|
|
892
|
+
if (kind[k] === "ivs" || kind[k] === "stdvs") selectors++;
|
|
865
893
|
}
|
|
866
|
-
//
|
|
867
|
-
//
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
894
|
+
// The run counters as they stand at the cluster's FIRST preservable char.
|
|
895
|
+
// A cluster can OPEN with a genuine gap — two visible code points in a row,
|
|
896
|
+
// e.g. the second of two adjacent emoji ZWJ sequences, or a letter and its
|
|
897
|
+
// harakat — which the emit loop resets on a few lines below. Judging the
|
|
898
|
+
// caps on the stale pre-reset count would strip joiners the cap never meant
|
|
899
|
+
// to catch: a false positive on legitimate joined text. This mirrors the
|
|
900
|
+
// emit loop's gap rule exactly (only a visible char after another visible
|
|
901
|
+
// char closes a run; any invisible, payload included, does not) and stops at
|
|
902
|
+
// the first preservable, since resets past it are the emit loop's business.
|
|
903
|
+
let runJoiner = joinerRun;
|
|
904
|
+
let runSelector = selectorRun;
|
|
905
|
+
let seenVisible = prevVisible;
|
|
906
|
+
for (let k = start; k < end && kind[k] === null; k++) {
|
|
907
|
+
if (codes[k] === null && seenVisible) {
|
|
908
|
+
runJoiner = 0;
|
|
909
|
+
runSelector = 0;
|
|
874
910
|
}
|
|
875
|
-
|
|
876
|
-
prevVisible = false; // the sequence keeps the cluster open
|
|
877
|
-
i += len;
|
|
878
|
-
continue;
|
|
911
|
+
seenVisible = codes[k] === null;
|
|
879
912
|
}
|
|
880
|
-
const
|
|
881
|
-
const selector = kind[i] === "ivs" || kind[i] === "stdvs";
|
|
882
|
-
if (
|
|
913
|
+
const fits =
|
|
883
914
|
allowCarveOut &&
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
if (
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
915
|
+
preservedTotal + need <= maxPreserved &&
|
|
916
|
+
runJoiner + joiners <= CONSECUTIVE_JOINER_CAP &&
|
|
917
|
+
runSelector + selectors <= CONSECUTIVE_SELECTOR_CAP;
|
|
918
|
+
for (let k = start; k < end; k++) {
|
|
919
|
+
const code = codes[k];
|
|
920
|
+
if (code === null) {
|
|
921
|
+
// A visible char following another visible char is a real word/segment
|
|
922
|
+
// boundary, not a join — the joined cluster (if any) ended here.
|
|
923
|
+
if (prevVisible) {
|
|
924
|
+
joinerRun = 0;
|
|
925
|
+
selectorRun = 0;
|
|
926
|
+
}
|
|
927
|
+
prevVisible = true;
|
|
928
|
+
out += cps[k]; // ordinary visible character
|
|
929
|
+
continue;
|
|
930
|
+
}
|
|
931
|
+
if (fits && kind[k] !== null) {
|
|
932
|
+
if (kind[k] === "joiner") joinerRun++;
|
|
933
|
+
if (kind[k] === "ivs" || kind[k] === "stdvs") selectorRun++;
|
|
934
|
+
preservedTotal++;
|
|
935
|
+
prevVisible = false; // a joiner/selector/tag keeps the cluster open
|
|
936
|
+
out += cps[k];
|
|
937
|
+
continue;
|
|
938
|
+
}
|
|
939
|
+
foundCodes.add(code);
|
|
940
|
+
prevVisible = false; // a stripped invisible neither opens nor closes a gap
|
|
896
941
|
}
|
|
897
|
-
|
|
898
|
-
prevVisible = false; // a stripped invisible neither opens nor closes a gap
|
|
899
|
-
i++;
|
|
942
|
+
start = end;
|
|
900
943
|
}
|
|
901
944
|
const found = CHECKS.filter(([code]) => foundCodes.has(code)).map(
|
|
902
945
|
([code]) => code,
|