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/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): colors, bold, reset. The grammar is closed:
126
- // params are [0-9;:]* and the final byte is `m`, so a match can only restyle
127
- // text, never reposition the cursor, erase, or smuggle an OSC string. `:` is
128
- // included alongside `;` because ITU T.416 colon-separated SGR sub-parameters
129
- // (truecolor `ESC[38:2:255:0:0m`, as emitted by tmux/kitty/mintty) are pure
130
- // display-only SGR too — excluding them left a benign colon-form sequence
131
- // misread as non-SGR. A SGR sequence has TWO encodings: the 7-bit `ESC [ … m`
132
- // and the 8-bit C1 form where a single U+009B (CSI) replaces `ESC [` — spelled
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 removed only cosmetic styling,
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 !CONTROL_INTRODUCER_RE.test(text.replace(SGR_RE, ""));
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). The ranges are the Unicode ideograph blocks: Unified,
352
- // Extensions A–I, and the two Compatibility Ideograph blocks.
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 CJK_IDEOGRAPH_RANGES = [
356
- [0x3400, 0x4dbf], // CJK Unified Ideographs Extension A
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 `cp` is a CJK ideograph (the only base an ideographic variation
371
- * selector legitimately follows). @param {number} cp @returns {boolean} */
372
- function isCjkIdeograph(cp) {
373
- for (const [start, end] of CJK_IDEOGRAPH_RANGES)
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
- const BRAHMIC_CONSONANT_RANGES = [
385
- [0x0915, 0x0939], // Devanagari KA–HA
386
- [0x0958, 0x095f], // Devanagari additional consonants
387
- [0x0995, 0x09b9], // Bengali
388
- [0x09dc, 0x09df], // Bengali additional consonants
389
- [0x0a15, 0x0a39], // Gurmukhi
390
- [0x0a59, 0x0a5e], // Gurmukhi additional consonants
391
- [0x0a95, 0x0ab9], // Gujarati
392
- [0x0b15, 0x0b39], // Oriya
393
- [0x0b5c, 0x0b5f], // Oriya additional consonants
394
- [0x0b95, 0x0bb9], // Tamil
395
- [0x0c15, 0x0c39], // Telugu
396
- [0x0c58, 0x0c5a], // Telugu additional consonants
397
- [0x0c95, 0x0cb9], // Kannada
398
- [0x0d15, 0x0d3a], // Malayalam
399
- [0x0d9a, 0x0dc6], // Sinhala
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 ? /** @type {number} */ (ch.codePointAt(0)) : -1;
431
- return cp >= 0x2801 && cp <= 0x28ff;
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. `tagSpanLen[i]` is the length of a
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)[], tagSpanLen: number[], payloadInvis: number, visibleLen: number }}
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, tagSpanLen, payloadInvis, visibleLen };
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
- const prev = cps[i - 1];
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
- * code points, preserving a joiner/selector/tag/blank-filler only where its
798
- * `kind` is set AND the text stays under the scatter floor AND neither the
799
- * per-cluster (CONSECUTIVE_JOINER_CAP) nor the document-wide
800
- * (TOTAL_PRESERVED_JOINER_BUDGET) preserve limit is hit — otherwise it is
801
- * stripped like any other payload byte. A tag (subregional-flag) sequence is
802
- * preserved-or-stripped ATOMICALLY: preserving only part of it would leave a
803
- * malformed run the next pass strips, breaking idempotence. `found` reports only
804
- * categories actually removed, so a preserved char never makes the caller claim
805
- * a strip that did not happen, and a stuffed channel surfaces as its category
806
- * once it overruns the budget.
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, tagSpanLen, payloadInvis, visibleLen } =
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 i = 0;
852
- while (i < cps.length) {
853
- const code = codes[i];
854
- if (code === null) {
855
- // A visible char following another visible char is a real word/segment
856
- // boundary, not a join — the joined cluster (if any) ended here.
857
- if (prevVisible) {
858
- joinerRun = 0;
859
- selectorRun = 0;
860
- }
861
- prevVisible = true;
862
- out += cps[i]; // ordinary visible character
863
- i++;
864
- continue;
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
- // A tag (subregional-flag) sequence: atomic preserve-or-strip on the whole
867
- // run so a budget cut can't leave a malformed partial run (idempotence).
868
- if (kind[i] === "tag") {
869
- const len = tagSpanLen[i];
870
- const fits = allowCarveOut && preservedTotal + len <= maxPreserved;
871
- for (let k = 0; k < len; k++) {
872
- if (fits) out += cps[i + k];
873
- else foundCodes.add(codes[i + k]);
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
- if (fits) preservedTotal += len;
876
- prevVisible = false; // the sequence keeps the cluster open
877
- i += len;
878
- continue;
911
+ seenVisible = codes[k] === null;
879
912
  }
880
- const joiner = kind[i] === "joiner";
881
- const selector = kind[i] === "ivs" || kind[i] === "stdvs";
882
- if (
913
+ const fits =
883
914
  allowCarveOut &&
884
- kind[i] !== null &&
885
- preservedTotal < maxPreserved &&
886
- (!joiner || joinerRun < CONSECUTIVE_JOINER_CAP) &&
887
- (!selector || selectorRun < CONSECUTIVE_SELECTOR_CAP)
888
- ) {
889
- if (joiner) joinerRun++;
890
- if (selector) selectorRun++;
891
- preservedTotal++;
892
- prevVisible = false; // a joiner/selector keeps the cluster open
893
- out += cps[i];
894
- i++;
895
- continue;
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
- foundCodes.add(code);
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,