agent-sanitizer 2.26.0 → 2.26.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.26.0",
3
+ "version": "2.26.1",
4
4
  "description": "Defend an agent against hidden-content injection: strip payload-capable invisible Unicode and ANSI, splice out human-invisible HTML, and flag data-exfil URLs in untrusted text before any model sees it.",
5
5
  "type": "module",
6
6
  "repository": {
package/src/rehydrate.mjs CHANGED
@@ -63,6 +63,7 @@ import {
63
63
  resolveSpan,
64
64
  rehydrateNewString,
65
65
  makeFileView,
66
+ toUtf16View,
66
67
  pairDiskSpans,
67
68
  } from "./view-map.mjs";
68
69
 
@@ -148,7 +149,7 @@ function exposureDeny(count) {
148
149
  * @param {{file_path: string, old_string: string, new_string: string, replace_all?: boolean}} ti
149
150
  * @param {string} content disk bytes
150
151
  * @param {string} cleaned Layer-1 view of `content`
151
- * @param {import("./view-map.mjs").FileView} view
152
+ * @param {import("./view-map.mjs").FileView<"utf16">} view
152
153
  * @param {{start: number, deleted: string}[]} deletions
153
154
  * @param {RehydrateIo} io
154
155
  * @param {boolean} hinted the input itself carries placeholders
@@ -398,7 +399,7 @@ function foreignPlaceholders(out, hint, viewText, secretSpans) {
398
399
 
399
400
  /**
400
401
  * @param {{file_path: string, content: string}} ti
401
- * @param {import("./view-map.mjs").FileView} view
402
+ * @param {import("./view-map.mjs").FileView<"utf16">} view
402
403
  * @param {RehydrateIo} io
403
404
  * @param {string} hint placeholder prefix
404
405
  */
@@ -672,12 +673,16 @@ export async function rehydrateRedacted(
672
673
  };
673
674
  }
674
675
  // The redactor emits code-point offsets; the offset machinery below works in
675
- // UTF-16. makeFileView normalizes once, into a fresh frozen carrier, so an
676
+ // UTF-16. Convert once, here, into a fresh frozen UTF-16-space carrier so an
676
677
  // astral char before a placeholder can't mis-anchor the edit (a no-op for
677
- // BMP-only files) AND the redactor's own object is never written through —
678
- // a redactor that memoizes its map result would otherwise hand back an
679
- // already-converted object and get converted twice. See makeFileView.
680
- const view = makeFileView(mapped.text, mapped.pairs);
678
+ // BMP-only files) AND the redactor's own object is never written through — a
679
+ // redactor that memoizes its map result would otherwise hand back an
680
+ // already-converted object and get converted twice, so the same input would
681
+ // yield a different verdict on the second call. The space brand is what makes
682
+ // that second conversion throw rather than silently shift; see toUtf16View.
683
+ const view = toUtf16View(
684
+ makeFileView(mapped.text, mapped.pairs, "codePoint"),
685
+ );
681
686
  // View identical to disk: any placeholders in an Edit's old_string are
682
687
  // literal text, so there is nothing to re-anchor. `cleaned === content` also
683
688
  // rules out a lone-surrogate-only divergence (view.pairs/deletions alone
package/src/view-map.mjs CHANGED
@@ -12,9 +12,19 @@
12
12
  * placeholder (`pairs` from the injected redactor’s map mode)
13
13
  *
14
14
  * The view is carried by {@link makeFileView}, the ONLY constructor the
15
- * consumers of this module may use: it owns the code-point UTF-16 offset
16
- * conversion and brands the result, so the conversion happens exactly once per
17
- * view and every function below can assert it happened.
15
+ * consumers of this module may use: it brands the result, and the brand carries
16
+ * the coordinate SPACE the pair offsets live in `"codePoint"` as the injected
17
+ * redactor emits them, `"utf16"` as every function below indexes by. Conversion
18
+ * is a separate, one-way door ({@link toUtf16View}) that accepts only a
19
+ * `"codePoint"` view and returns a new `"utf16"` one, so converting twice throws
20
+ * instead of shifting every astral-preceded offset a second time. Each consumer
21
+ * asserts the space it needs, so a wrongly-spaced view fails at the boundary
22
+ * rather than resolving onto the wrong bytes.
23
+ *
24
+ * The space is part of the value rather than a convention in a comment because
25
+ * the two spaces are otherwise indistinguishable — bare numbers in a bare
26
+ * object — which is exactly why the original double-conversion bug was silently
27
+ * accepted.
18
28
  */
19
29
 
20
30
  /**
@@ -28,57 +38,123 @@ const FILE_VIEW = Symbol("agent-sanitizer:file-view");
28
38
 
29
39
  /**
30
40
  * @typedef {{ placeholder: string, original: string, start: number }} RedactionPair
31
- * @typedef {{ text: string, pairs: readonly RedactionPair[] }} FileView
32
- * A branded, frozen carrier from {@link makeFileView}. `pairs` are in UTF-16
33
- * offsets, sorted and non-overlapping both enforced at construction.
41
+ * @typedef {"codePoint" | "utf16"} OffsetSpace
42
+ * Units a {@link FileView}'s pair offsets are expressed in. `codePoint` is
43
+ * what the redactor's map mode emits (Python indexes strings by code point);
44
+ * `utf16` is what every function in this module indexes by (JS
45
+ * `indexOf`/`slice`/`.length` count UTF-16 code units).
46
+ */
47
+
48
+ /**
49
+ * A branded, frozen carrier from {@link makeFileView}, tagged with the space its
50
+ * `pairs` offsets live in. Its own JSDoc block: a `@template` applies to every
51
+ * typedef in the comment it sits in, so sharing one with the two above would
52
+ * make `RedactionPair` and `OffsetSpace` generic too.
53
+ * @template {OffsetSpace} S
54
+ * @typedef {{ readonly space: S, readonly text: string,
55
+ * readonly pairs: readonly RedactionPair[] }} FileView
34
56
  */
35
57
 
36
58
  /**
37
- * Build the branded file view from a redactor's map-mode result.
59
+ * Wrap a redactor's map-mode result in a frozen, branded view tagged with the
60
+ * space its offsets are in.
38
61
  *
39
62
  * The redactor's own object is never touched. It used to be: the caller did
40
63
  * `view.pairs = pairsToUtf16(view.text, view.pairs)`, an in-place mutation of a
41
64
  * value returned from an INJECTED seam. A redactor that memoizes its map result
42
65
  * (a reasonable thing for a caller to build) hands back the same object on the
43
- * second identical call, which then gets converted a SECOND time — every
66
+ * second identical call, which then got converted a SECOND time — every
44
67
  * placeholder preceded by an astral character shifts again and the same input
45
- * yields a different verdict. Converting into a fresh frozen carrier removes
46
- * that: the conversion is part of construction, the redactor's value is left
47
- * alone, and every consumer asserts the brand rather than accepting a
48
- * hand-assembled `{text, pairs}` whose offsets may or may not be converted.
68
+ * yields a different verdict.
49
69
  *
50
- * It does NOT make double conversion impossible `makeFileView(v.text,
51
- * v.pairs)` on an existing view would convert again. Nothing does that, and a
52
- * guard would have to reject legitimately-frozen caller input to catch it, so
53
- * the defence here is that there is exactly one construction site and it takes
54
- * the redactor's result directly.
70
+ * Construction no longer converts. It brands and records the space, and
71
+ * {@link toUtf16View} does the conversion behind a check that the input is
72
+ * still in code-point space so `toUtf16View(alreadyConverted)` throws where
73
+ * `makeFileView(v.text, v.pairs)` on an existing view used to silently convert
74
+ * a second time. That was the one door this carrier left open.
55
75
  *
56
- * The frozen `pairs` array is likewise a copy `pairsToUtf16` returns its
57
- * argument unchanged for the empty case, and freezing the redactor's array
58
- * would reach back into the seam's memoized value.
76
+ * Both the array AND each pair object are copied before freezing, so nothing
77
+ * here reaches back into the seam's (possibly memoized) value, and a caller that
78
+ * later mutates its own pairs cannot change what this view resolves.
79
+ * @template {OffsetSpace} S
59
80
  * @param {string} text redacted view text
60
- * @param {RedactionPair[]} pairs redactor pairs, in CODE-POINT offsets
61
- * @returns {FileView}
81
+ * @param {readonly RedactionPair[]} pairs redactor pairs, offsets in `space`
82
+ * @param {S} space
83
+ * @returns {FileView<S>}
62
84
  */
63
- export function makeFileView(text, pairs) {
85
+ export function makeFileView(text, pairs, space) {
86
+ assertPairsOrdered(text, pairs, space);
64
87
  return Object.freeze({
65
88
  [FILE_VIEW]: true,
89
+ space,
66
90
  text,
67
- pairs: Object.freeze([...pairsToUtf16(text, pairs)]),
91
+ pairs: Object.freeze(pairs.map((pair) => Object.freeze({ ...pair }))),
68
92
  });
69
93
  }
70
94
 
71
95
  /**
72
- * Throw unless `view` came from {@link makeFileView}. Every offset function
73
- * here reads `view.pairs` as UTF-16 offsets; a hand-rolled `{text, pairs}` whose
74
- * pairs are still in code-point space mis-anchors an edit onto the wrong bytes
75
- * whenever an astral character precedes a placeholder — silently, and only for
96
+ * Offsets counted the way `space` counts them: UTF-16 code units, or code
97
+ * points as the Python redactor emits them.
98
+ * @param {string} text
99
+ * @param {OffsetSpace} space
100
+ * @returns {number}
101
+ */
102
+ function unitLength(text, space) {
103
+ return space === "utf16" ? text.length : Array.from(text).length;
104
+ }
105
+
106
+ /**
107
+ * Throw unless `pairs` is in range, sorted by `start` and non-overlapping, with
108
+ * every offset read in `space`.
109
+ *
110
+ * Every consumer here — `mapViewOffset`'s `else break`, `pairDiskSpans`'s
111
+ * sequential walk — assumes exactly this. An out-of-range start indexes past
112
+ * the text and silently yields `undefined`, which then poisons every downstream
113
+ * offset comparison (`undefined < n` is always false); an out-of-order or
114
+ * overlapping pair stops the scan early and mis-maps an offset onto the wrong
115
+ * bytes. Enforced at CONSTRUCTION so no view can exist in that state, rather
116
+ * than at each read.
117
+ * @param {string} text the view text the offsets index into
118
+ * @param {readonly RedactionPair[]} pairs
119
+ * @param {OffsetSpace} space
120
+ * @returns {void}
121
+ */
122
+ function assertPairsOrdered(text, pairs, space) {
123
+ // The no-secrets rehydration is the common case, and the loop below has
124
+ // nothing to check there. Return before `unitLength`, which for "codePoint"
125
+ // materializes a code-point array over the whole file on every Edit/Write.
126
+ if (pairs.length === 0) return;
127
+ const total = unitLength(text, space);
128
+ // The previous pair's placeholder end. `start < prevEnd` catches an
129
+ // out-of-order start and an overlap in one comparison.
130
+ let prevEnd = 0;
131
+ for (const pair of pairs) {
132
+ if (!Number.isInteger(pair.start) || pair.start < 0 || pair.start > total)
133
+ throw new Error(
134
+ `redaction pair start ${pair.start} is out of range [0, ${total}]`,
135
+ );
136
+ if (pair.start < prevEnd)
137
+ throw new Error(
138
+ `redaction pairs must be sorted and non-overlapping: pair start ${pair.start} precedes previous pair end ${prevEnd}`,
139
+ );
140
+ prevEnd = pair.start + unitLength(pair.placeholder, space);
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Throw unless `view` came from {@link makeFileView} AND carries `space`.
146
+ *
147
+ * Two failures, one gate. A hand-rolled `{text, pairs}` has offsets that may or
148
+ * may not have been converted; a real view in the WRONG space has offsets that
149
+ * definitely have not. Either mis-anchors an edit onto the wrong bytes whenever
150
+ * an astral character precedes a placeholder — silently, and only for
76
151
  * emoji-bearing files. Fail loudly at the boundary instead.
77
152
  * @param {unknown} view
153
+ * @param {OffsetSpace} space the space the calling function indexes by
78
154
  * @param {string} fn name of the calling function, for the error
79
155
  * @returns {void}
80
156
  */
81
- function assertFileView(view, fn) {
157
+ function assertFileView(view, space, fn) {
82
158
  if (
83
159
  view === null ||
84
160
  typeof view !== "object" ||
@@ -88,6 +164,11 @@ function assertFileView(view, fn) {
88
164
  `${fn} requires a view built by makeFileView(); got a raw object whose ` +
89
165
  `pair offsets have not been normalized to UTF-16`,
90
166
  );
167
+ const actual = /** @type {FileView<OffsetSpace>} */ (view).space;
168
+ if (actual !== space)
169
+ throw new Error(
170
+ `${fn} requires a view with ${space} pair offsets, got ${actual}`,
171
+ );
91
172
  }
92
173
 
93
174
  /**
@@ -195,53 +276,52 @@ function diskOffset(deletions, cleanedOffset, isEnd) {
195
276
  * placeholder mis-anchors the edit onto the wrong bytes.
196
277
  *
197
278
  * Exactly once, though: applying it to its own output shifts every
198
- * astral-preceded placeholder a second time. Prefer {@link makeFileView}, which
199
- * runs it as part of construction and hands back a branded carrier the rest of
200
- * this module accepts; this stays exported (it is public API on the
201
- * `./view-map` subpath) for callers doing their own offset bookkeeping, who own
202
- * the once-only discipline themselves.
279
+ * astral-preceded placeholder a second time, and bare arrays of numbers give
280
+ * nothing to check that against. Prefer {@link toUtf16View}, which runs this
281
+ * behind a space check so the second application throws instead. This stays
282
+ * exported it is public API on the `./view-map` subpath, and removing it
283
+ * would be a breaking change the release workflow cannot express (it caps
284
+ * automated bumps at minor) — for callers doing their own offset bookkeeping,
285
+ * who own the once-only discipline themselves.
203
286
  * @param {string} text the redacted view text the offsets index into
204
- * @param {{placeholder: string, original: string, start: number}[]} pairs
205
- * @returns {{placeholder: string, original: string, start: number}[]}
287
+ * @param {RedactionPair[]} pairs
288
+ * @returns {RedactionPair[]}
206
289
  */
207
290
  export function pairsToUtf16(text, pairs) {
208
291
  if (pairs.length === 0) return pairs;
292
+ assertPairsOrdered(text, pairs, "codePoint");
209
293
  const codePoints = Array.from(text);
210
294
  // prefix[i] = UTF-16 length of the first i code points of `text`.
211
295
  const prefix = new Array(codePoints.length + 1);
212
296
  prefix[0] = 0;
213
297
  for (let i = 0; i < codePoints.length; i++)
214
298
  prefix[i + 1] = prefix[i] + codePoints[i].length;
215
- // Code-point end of the previous pair's placeholder span. mapViewOffset's
216
- // `else break` (and pairDiskSpans) assume pairs are sorted by start and never
217
- // overlap; an out-of-order or overlapping pair would make the scan stop early
218
- // and mis-map an offset onto the wrong bytes. Enforce the contract here.
219
- let prevEnd = 0;
220
- return pairs.map((pair) => {
221
- // A redactor offset outside [0, codePoints.length] indexes `prefix` out of
222
- // range and would silently yield `start: undefined`, which then poisons
223
- // every downstream offset comparison (undefined < n is always false) and
224
- // mis-anchors or corrupts the edit. Fail loudly instead — an out-of-range
225
- // pair means the injected redactor's map contract was violated.
226
- if (
227
- !Number.isInteger(pair.start) ||
228
- pair.start < 0 ||
229
- pair.start > codePoints.length
230
- )
231
- throw new Error(
232
- `redaction pair start ${pair.start} is out of range [0, ${codePoints.length}]`,
233
- );
234
- // Sorted + non-overlapping: `start` must be monotonically non-decreasing and
235
- // each pair's placeholder span must end at or before the next pair's start.
236
- // `prevEnd` already encodes the previous end, so `start < prevEnd` catches
237
- // both an out-of-order start and an overlap in one comparison. Fail closed.
238
- if (pair.start < prevEnd)
239
- throw new Error(
240
- `redaction pairs must be sorted and non-overlapping: pair start ${pair.start} precedes previous pair end ${prevEnd}`,
241
- );
242
- prevEnd = pair.start + Array.from(pair.placeholder).length;
243
- return { ...pair, start: prefix[pair.start] };
244
- });
299
+ return pairs.map((pair) => ({ ...pair, start: prefix[pair.start] }));
300
+ }
301
+
302
+ /**
303
+ * The same view with its pair offsets re-expressed in UTF-16 code units — a NEW
304
+ * frozen carrier; `view` is untouched.
305
+ *
306
+ * The one-way door. Only a `"codePoint"` view is accepted, so converting an
307
+ * already-converted view throws rather than shifting every astral-preceded
308
+ * offset a second time — which either mis-anchors the edit or rejects it as
309
+ * cutting a placeholder, both from an input that was fine the first time it was
310
+ * seen. Offset range/sort/overlap validity is not this door's job — every
311
+ * carrier is checked at construction (see {@link assertPairsOrdered}), in
312
+ * whichever space it declares.
313
+ * @param {FileView<"codePoint">} view
314
+ * @returns {FileView<"utf16">}
315
+ */
316
+ export function toUtf16View(view) {
317
+ assertFileView(view, "codePoint", "toUtf16View");
318
+ // Copied because `view.pairs` is readonly and `pairsToUtf16` keeps the
319
+ // published mutable signature; makeFileView copies again on the way in.
320
+ return makeFileView(
321
+ view.text,
322
+ pairsToUtf16(view.text, [...view.pairs]),
323
+ "utf16",
324
+ );
245
325
  }
246
326
 
247
327
  /**
@@ -275,7 +355,7 @@ function mapViewOffset(pairs, offset) {
275
355
  * and a mis-attributed run would mis-anchor the edit.
276
356
  * @param {string} content disk file content
277
357
  * @param {string} cleaned Layer-1 view of `content`
278
- * @param {FileView} view
358
+ * @param {FileView<"utf16">} view
279
359
  * @param {{start: number, deleted: string}[]} deletions
280
360
  * @param {number} viewStart
281
361
  * @param {number} viewEnd
@@ -288,7 +368,7 @@ export function resolveSpan(
288
368
  viewStart,
289
369
  viewEnd,
290
370
  ) {
291
- assertFileView(view, "resolveSpan");
371
+ assertFileView(view, "utf16", "resolveSpan");
292
372
  const cleanedStart = mapViewOffset(view.pairs, viewStart);
293
373
  const cleanedEnd = mapViewOffset(view.pairs, viewEnd);
294
374
  if (cleanedStart === null || cleanedEnd === null) return null;
@@ -378,15 +458,15 @@ export function spliceOrdered(text, matches, replacementFor) {
378
458
  * was never part of the secret); interior runs are included. Callers use these
379
459
  * to detect an edit whose on-disk footprint intrudes into bytes the model was
380
460
  * never shown.
381
- * @param {FileView} view
461
+ * @param {FileView<"utf16">} view
382
462
  * @param {{start: number, deleted: string}[]} deletions
383
463
  * @returns {{start: number, end: number}[]}
384
464
  */
385
465
  export function pairDiskSpans(view, deletions) {
386
- assertFileView(view, "pairDiskSpans");
466
+ assertFileView(view, "utf16", "pairDiskSpans");
387
467
  return view.pairs.map((pair) => {
388
468
  // pair.start is a placeholder boundary, and makeFileView rejected any pair
389
- // set that is out of order or overlapping (see pairsToUtf16), so it is never
469
+ // set out of order or overlapping (see assertPairsOrdered), so it is never
390
470
  // strictly interior to another placeholder: mapViewOffset always resolves.
391
471
  // The throw is kept anyway, and is NOT dead weight — it is the difference
392
472
  // between crashing and corrupting. `null + pair.original.length` is a
@@ -395,9 +475,9 @@ export function pairDiskSpans(view, deletions) {
395
475
  // i.e. an edit footprint pointing at the wrong bytes.
396
476
  const cleanedStart = mapViewOffset(view.pairs, pair.start);
397
477
  /* c8 ignore start -- unreachable through makeFileView, which rejects the
398
- overlapping pair set that is the only way to produce null here (see the
399
- constructor test in test/view-map.test.mjs); kept as a fail-loud guard
400
- against a future regression in that ordering check. `ignore next N` does
478
+ overlapping pair set that is the only way to produce null here (see
479
+ assertPairsOrdered and the constructor test in test/view-map.test.mjs);
480
+ kept as a fail-loud guard against a future regression in that check. `ignore next N` does
401
481
  NOT suppress the branch here — only the statement — so the range form is
402
482
  required to keep the src branch floor at 100%. */
403
483
  if (cleanedStart === null)
@@ -1,37 +1,48 @@
1
1
  /**
2
2
  * @typedef {{ placeholder: string, original: string, start: number }} RedactionPair
3
- * @typedef {{ text: string, pairs: readonly RedactionPair[] }} FileView
4
- * A branded, frozen carrier from {@link makeFileView}. `pairs` are in UTF-16
5
- * offsets, sorted and non-overlapping both enforced at construction.
3
+ * @typedef {"codePoint" | "utf16"} OffsetSpace
4
+ * Units a {@link FileView}'s pair offsets are expressed in. `codePoint` is
5
+ * what the redactor's map mode emits (Python indexes strings by code point);
6
+ * `utf16` is what every function in this module indexes by (JS
7
+ * `indexOf`/`slice`/`.length` count UTF-16 code units).
6
8
  */
7
9
  /**
8
- * Build the branded file view from a redactor's map-mode result.
10
+ * A branded, frozen carrier from {@link makeFileView}, tagged with the space its
11
+ * `pairs` offsets live in. Its own JSDoc block: a `@template` applies to every
12
+ * typedef in the comment it sits in, so sharing one with the two above would
13
+ * make `RedactionPair` and `OffsetSpace` generic too.
14
+ * @template {OffsetSpace} S
15
+ * @typedef {{ readonly space: S, readonly text: string,
16
+ * readonly pairs: readonly RedactionPair[] }} FileView
17
+ */
18
+ /**
19
+ * Wrap a redactor's map-mode result in a frozen, branded view tagged with the
20
+ * space its offsets are in.
9
21
  *
10
22
  * The redactor's own object is never touched. It used to be: the caller did
11
23
  * `view.pairs = pairsToUtf16(view.text, view.pairs)`, an in-place mutation of a
12
24
  * value returned from an INJECTED seam. A redactor that memoizes its map result
13
25
  * (a reasonable thing for a caller to build) hands back the same object on the
14
- * second identical call, which then gets converted a SECOND time — every
26
+ * second identical call, which then got converted a SECOND time — every
15
27
  * placeholder preceded by an astral character shifts again and the same input
16
- * yields a different verdict. Converting into a fresh frozen carrier removes
17
- * that: the conversion is part of construction, the redactor's value is left
18
- * alone, and every consumer asserts the brand rather than accepting a
19
- * hand-assembled `{text, pairs}` whose offsets may or may not be converted.
28
+ * yields a different verdict.
20
29
  *
21
- * It does NOT make double conversion impossible `makeFileView(v.text,
22
- * v.pairs)` on an existing view would convert again. Nothing does that, and a
23
- * guard would have to reject legitimately-frozen caller input to catch it, so
24
- * the defence here is that there is exactly one construction site and it takes
25
- * the redactor's result directly.
30
+ * Construction no longer converts. It brands and records the space, and
31
+ * {@link toUtf16View} does the conversion behind a check that the input is
32
+ * still in code-point space so `toUtf16View(alreadyConverted)` throws where
33
+ * `makeFileView(v.text, v.pairs)` on an existing view used to silently convert
34
+ * a second time. That was the one door this carrier left open.
26
35
  *
27
- * The frozen `pairs` array is likewise a copy `pairsToUtf16` returns its
28
- * argument unchanged for the empty case, and freezing the redactor's array
29
- * would reach back into the seam's memoized value.
36
+ * Both the array AND each pair object are copied before freezing, so nothing
37
+ * here reaches back into the seam's (possibly memoized) value, and a caller that
38
+ * later mutates its own pairs cannot change what this view resolves.
39
+ * @template {OffsetSpace} S
30
40
  * @param {string} text redacted view text
31
- * @param {RedactionPair[]} pairs redactor pairs, in CODE-POINT offsets
32
- * @returns {FileView}
41
+ * @param {readonly RedactionPair[]} pairs redactor pairs, offsets in `space`
42
+ * @param {S} space
43
+ * @returns {FileView<S>}
33
44
  */
34
- export function makeFileView(text: string, pairs: RedactionPair[]): FileView;
45
+ export function makeFileView<S extends OffsetSpace>(text: string, pairs: readonly RedactionPair[], space: S): FileView<S>;
35
46
  /**
36
47
  * Non-overlapping occurrence indices of `needle` in `haystack`.
37
48
  * @param {string} haystack
@@ -77,24 +88,33 @@ export function alignDeletions(content: string, cleaned: string): {
77
88
  * placeholder mis-anchors the edit onto the wrong bytes.
78
89
  *
79
90
  * Exactly once, though: applying it to its own output shifts every
80
- * astral-preceded placeholder a second time. Prefer {@link makeFileView}, which
81
- * runs it as part of construction and hands back a branded carrier the rest of
82
- * this module accepts; this stays exported (it is public API on the
83
- * `./view-map` subpath) for callers doing their own offset bookkeeping, who own
84
- * the once-only discipline themselves.
91
+ * astral-preceded placeholder a second time, and bare arrays of numbers give
92
+ * nothing to check that against. Prefer {@link toUtf16View}, which runs this
93
+ * behind a space check so the second application throws instead. This stays
94
+ * exported it is public API on the `./view-map` subpath, and removing it
95
+ * would be a breaking change the release workflow cannot express (it caps
96
+ * automated bumps at minor) — for callers doing their own offset bookkeeping,
97
+ * who own the once-only discipline themselves.
85
98
  * @param {string} text the redacted view text the offsets index into
86
- * @param {{placeholder: string, original: string, start: number}[]} pairs
87
- * @returns {{placeholder: string, original: string, start: number}[]}
99
+ * @param {RedactionPair[]} pairs
100
+ * @returns {RedactionPair[]}
88
101
  */
89
- export function pairsToUtf16(text: string, pairs: {
90
- placeholder: string;
91
- original: string;
92
- start: number;
93
- }[]): {
94
- placeholder: string;
95
- original: string;
96
- start: number;
97
- }[];
102
+ export function pairsToUtf16(text: string, pairs: RedactionPair[]): RedactionPair[];
103
+ /**
104
+ * The same view with its pair offsets re-expressed in UTF-16 code units — a NEW
105
+ * frozen carrier; `view` is untouched.
106
+ *
107
+ * The one-way door. Only a `"codePoint"` view is accepted, so converting an
108
+ * already-converted view throws rather than shifting every astral-preceded
109
+ * offset a second time — which either mis-anchors the edit or rejects it as
110
+ * cutting a placeholder, both from an input that was fine the first time it was
111
+ * seen. Offset range/sort/overlap validity is not this door's job — every
112
+ * carrier is checked at construction (see {@link assertPairsOrdered}), in
113
+ * whichever space it declares.
114
+ * @param {FileView<"codePoint">} view
115
+ * @returns {FileView<"utf16">}
116
+ */
117
+ export function toUtf16View(view: FileView<"codePoint">): FileView<"utf16">;
98
118
  /**
99
119
  * Resolve view span [viewStart, viewEnd) to its on-disk text and the redaction
100
120
  * pairs it wholly contains, mapping across placeholder expansion (view →
@@ -108,12 +128,12 @@ export function pairsToUtf16(text: string, pairs: {
108
128
  * and a mis-attributed run would mis-anchor the edit.
109
129
  * @param {string} content disk file content
110
130
  * @param {string} cleaned Layer-1 view of `content`
111
- * @param {FileView} view
131
+ * @param {FileView<"utf16">} view
112
132
  * @param {{start: number, deleted: string}[]} deletions
113
133
  * @param {number} viewStart
114
134
  * @param {number} viewEnd
115
135
  */
116
- export function resolveSpan(content: string, cleaned: string, view: FileView, deletions: {
136
+ export function resolveSpan(content: string, cleaned: string, view: FileView<"utf16">, deletions: {
117
137
  start: number;
118
138
  deleted: string;
119
139
  }[], viewStart: number, viewEnd: number): {
@@ -185,11 +205,11 @@ export function spliceOrdered(text: string, matches: {
185
205
  * was never part of the secret); interior runs are included. Callers use these
186
206
  * to detect an edit whose on-disk footprint intrudes into bytes the model was
187
207
  * never shown.
188
- * @param {FileView} view
208
+ * @param {FileView<"utf16">} view
189
209
  * @param {{start: number, deleted: string}[]} deletions
190
210
  * @returns {{start: number, end: number}[]}
191
211
  */
192
- export function pairDiskSpans(view: FileView, deletions: {
212
+ export function pairDiskSpans(view: FileView<"utf16">, deletions: {
193
213
  start: number;
194
214
  deleted: string;
195
215
  }[]): {
@@ -221,10 +241,20 @@ export type RedactionPair = {
221
241
  start: number;
222
242
  };
223
243
  /**
224
- * A branded, frozen carrier from {@link makeFileView}. `pairs` are in UTF-16
225
- * offsets, sorted and non-overlapping both enforced at construction.
244
+ * Units a {@link FileView}'s pair offsets are expressed in. `codePoint` is
245
+ * what the redactor's map mode emits (Python indexes strings by code point);
246
+ * `utf16` is what every function in this module indexes by (JS
247
+ * `indexOf`/`slice`/`.length` count UTF-16 code units).
226
248
  */
227
- export type FileView = {
228
- text: string;
229
- pairs: readonly RedactionPair[];
249
+ export type OffsetSpace = "codePoint" | "utf16";
250
+ /**
251
+ * A branded, frozen carrier from {@link makeFileView}, tagged with the space its
252
+ * `pairs` offsets live in. Its own JSDoc block: a `@template` applies to every
253
+ * typedef in the comment it sits in, so sharing one with the two above would
254
+ * make `RedactionPair` and `OffsetSpace` generic too.
255
+ */
256
+ export type FileView<S extends OffsetSpace> = {
257
+ readonly space: S;
258
+ readonly text: string;
259
+ readonly pairs: readonly RedactionPair[];
230
260
  };