/** * @typedef {{ placeholder: string, original: string, start: number }} RedactionPair * @typedef {"codePoint" | "utf16"} OffsetSpace * Units a {@link FileView}'s pair offsets are expressed in. `codePoint` is * what the redactor's map mode emits (Python indexes strings by code point); * `utf16` is what every function in this module indexes by (JS * `indexOf`/`slice`/`.length` count UTF-16 code units). */ /** * A branded, frozen carrier from {@link makeFileView}, tagged with the space its * `pairs` offsets live in. Its own JSDoc block: a `@template` applies to every * typedef in the comment it sits in, so sharing one with the two above would * make `RedactionPair` and `OffsetSpace` generic too. * @template {OffsetSpace} S * @typedef {{ readonly space: S, readonly text: string, * readonly pairs: readonly RedactionPair[] }} FileView */ /** * Wrap a redactor's map-mode result in a frozen, branded view tagged with the * space its offsets are in. * * The redactor's own object is never touched. A caller doing * `view.pairs = pairsToUtf16(view.text, view.pairs)` would mutate in place a * value returned from an INJECTED seam. A redactor that memoizes its map result * (a reasonable thing for a caller to build) hands back the same object on the * second identical call, which would then get converted a SECOND time — every * placeholder preceded by an astral character shifts again and the same input * yields a different verdict. * * Construction no longer converts. It brands and records the space, and * {@link toUtf16View} does the conversion behind a check that the input is * still in code-point space — so `toUtf16View(alreadyConverted)` throws where * `makeFileView(v.text, v.pairs)` on an existing view used to silently convert * a second time. That was the one door this carrier left open. * * Both the array AND each pair object are copied before freezing, so nothing * here reaches back into the seam's (possibly memoized) value, and a caller that * later mutates its own pairs cannot change what this view resolves. * @template {OffsetSpace} S * @param {string} text redacted view text * @param {readonly RedactionPair[]} pairs redactor pairs, offsets in `space` * @param {S} space * @returns {FileView} */ export function makeFileView(text: string, pairs: readonly RedactionPair[], space: S): FileView; /** * Non-overlapping occurrence indices of `needle` in `haystack`. * @param {string} haystack * @param {string} needle * @returns {number[]} */ export function occurrences(haystack: string, needle: string): number[]; /** * Count of ALL matches of `needle` in `haystack`, including self-overlapping * ones (stepping by 1, not by the needle length). `occurrences` deliberately * steps by the needle length so it never reports overlapping spans — correct * for splicing, but it undercounts a self-overlapping needle (e.g. "aa" in * "aaa" is one non-overlapping match yet two overlapping ones). Ambiguity * gating must use THIS count: an old_string that overlaps itself has more than * one anchor a human (or the real Edit tool) could mean, so it is ambiguous even * when `occurrences` reports a single non-overlapping match. * @param {string} haystack * @param {string} needle * @returns {number} */ export function overlapAwareCount(haystack: string, needle: string): number; /** * The character runs Layer 1 deleted, located by greedy subsequence alignment * (stripping only deletes, so `cleaned` is always a subsequence of `content`). * Throws if the subsequence property does not hold — the caller fails closed. * @param {string} content disk bytes * @param {string} cleaned Layer-1 view of the same bytes * @returns {{start: number, deleted: string}[]} */ export function alignDeletions(content: string, cleaned: string): { start: number; deleted: string; }[]; /** * Re-express each pair's `start` from a Unicode code-point offset — what the * redactor's map mode emits (Python indexes strings by code point) — to a * UTF-16 code-unit offset into `text`, the basis every other function here uses * (JS `indexOf`/`slice`/`.length` count UTF-16 units). The two are identical for * BMP-only text and diverge only when an astral character (e.g. an emoji) * precedes a placeholder, where the code-point offset undercounts by one per * astral char. `pair.start` is compared against UTF-16 view offsets throughout, * so this conversion MUST run once at ingestion or an astral-preceded * placeholder mis-anchors the edit onto the wrong bytes. * * Exactly once, though: applying it to its own output shifts every * astral-preceded placeholder a second time, and bare arrays of numbers give * nothing to check that against. Prefer {@link toUtf16View}, which runs this * behind a space check so the second application throws instead. This stays * exported — it is public API on the `./view-map` subpath, and removing it * would be a breaking change the release workflow cannot express (it caps * automated bumps at minor) — for callers doing their own offset bookkeeping, * who own the once-only discipline themselves. * @param {string} text the redacted view text the offsets index into * @param {RedactionPair[]} pairs * @returns {RedactionPair[]} */ export function pairsToUtf16(text: string, pairs: RedactionPair[]): RedactionPair[]; /** * The same view with its pair offsets re-expressed in UTF-16 code units — a NEW * frozen carrier; `view` is untouched. * * The one-way door. Only a `"codePoint"` view is accepted, so converting an * already-converted view throws rather than shifting every astral-preceded * offset a second time — which either mis-anchors the edit or rejects it as * cutting a placeholder, both from an input that was fine the first time it was * seen. Offset range/sort/overlap validity is not this door's job — every * carrier is checked at construction (see {@link assertPairsOrdered}), in * whichever space it declares. * @param {FileView<"codePoint">} view * @returns {FileView<"utf16">} */ export function toUtf16View(view: FileView<"codePoint">): FileView<"utf16">; /** * Resolve view span [viewStart, viewEnd) to its on-disk text and the redaction * pairs it wholly contains, mapping across placeholder expansion (view → * cleaned) and stripped invisible runs (cleaned → disk). Null when a boundary * cuts through a placeholder. `invisibleBytes` counts stripped characters * inside the span (replaced along with it); runs at the boundaries stay * outside and are preserved. `cleanedText` is the span's Layer-1 view — the * caller MUST verify that re-cleaning `diskText` reproduces it before acting: * greedy alignment is ambiguous when a deleted run's edge character equals the * adjacent kept character (an ANSI sequence ending in `m` before a kept `m`), * and a mis-attributed run would mis-anchor the edit. * @param {string} content disk file content * @param {string} cleaned Layer-1 view of `content` * @param {FileView<"utf16">} view * @param {{start: number, deleted: string}[]} deletions * @param {number} viewStart * @param {number} viewEnd */ export function resolveSpan(content: string, cleaned: string, view: FileView<"utf16">, deletions: { start: number; deleted: string; }[], viewStart: number, viewEnd: number): { diskText: string; cleanedText: string; invisibleBytes: number; pairs: RedactionPair[]; } | null; /** * All occurrences of any needle in `text`, ordered by position. Every index is * computed against the ORIGINAL `text`, so the caller can splice them in one * pass ({@link spliceOrdered}). Redaction placeholder texts never * substring-overlap one another (each ends in "]" right after its * distinguishing label), so for that caller the sorted matches are also * non-overlapping; needles from an untrusted source (a Layer-5 filter's * removeSpans) can overlap, which spliceOrdered resolves first-match-wins. * Distinct needles matching at the SAME index keep `needles` order (Array#sort * is stable), so first-match-wins is deterministic. * @param {string} text * @param {string[]} needles * @returns {{text: string, index: number}[]} */ export function orderedMatches(text: string, needles: string[]): { text: string; index: number; }[]; /** * Replace every match in `matches` with `replacementFor(match, i)` in a SINGLE * ordered pass over `text`. THE splice primitive for this codebase — the sole * sound way to substitute several needles at once. * * A chained `text.split(needle).join(value)` per needle is unsound in both * directions, which is why no caller may hand-roll one: * - substitution: an inserted value whose bytes contain a LATER needle is * re-matched by the next split and corrupted (or partially exposed); * - deletion: an earlier deletion joins the bytes on either side of it and * can CREATE a later needle's match, deleting text that needle never * matched in the input ("PRE-XX-POST" minus "-XX-" yields "PREPOST"). * Because every index in `matches` is measured against the original `text`, * this pass only ever touches bytes the caller actually matched. * * Overlapping matches are resolved first-match-wins: a match starting before * the previous one ended is skipped, never spliced at a shifted offset. * `i` is the match's index in `matches` (stable across skips) so a caller * pairing matches positionally with its own array stays aligned. * @param {string} text * @param {{text: string, index: number}[]} matches ordered by index, indices into `text` * @param {(match: {text: string, index: number}, i: number) => string} replacementFor * @returns {{text: string, spans: {start: number, end: number}[]}} spliced text * and the [start, end) range each replacement occupies in it */ export function spliceOrdered(text: string, matches: { text: string; index: number; }[], replacementFor: (match: { text: string; index: number; }, i: number) => string): { text: string; spans: { start: number; end: number; }[]; }; /** * Anchor a whole-file Write's content against the sanitized view it was * composed from: the longest common prefix and suffix are the regions the * model left unchanged, so their on-disk bytes (stripped runs, redacted * secrets, lone surrogates included) can be restored position-exact — no * search, no anchor ambiguity. Returns view-space `{prefixEnd, suffixStart}`; * because the prefix and suffix are common substrings, the same lengths index * `content` (prefix `[0, prefixEnd)`, suffix `[content.length - (view.text.length * - suffixStart))`). * * Both boundaries are snapped OUT of hazards, always shrinking the restored * region (the fail-open direction — a smaller restore only loses stripped * characters, never corrupts): * - a boundary strictly inside a placeholder moves to the placeholder's * edge, so `resolveSpan` (which returns null on a placeholder-cutting * boundary) always resolves; * - a boundary splitting a surrogate pair moves off it. The view never * carries lone surrogates (they were normalized to U+FFFD), so a high * surrogate at `prefixEnd - 1` is always a genuine pair's first half; and * since the prefix/suffix are common substrings, checking the view covers * the content side too. * The prefix is computed first and the suffix capped so they never overlap * (prefix wins — deterministic). * @param {string} content incoming Write content (view space) * @param {FileView<"utf16">} view sanitized view of the target file * @returns {{prefixEnd: number, suffixStart: number}} */ export function anchorSpans(content: string, view: FileView<"utf16">): { prefixEnd: number; suffixStart: number; }; /** * On-disk [start, end) span of every redaction pair, mapped from its view * offset through placeholder expansion (view → cleaned) and stripped invisible * runs (cleaned → disk). A run abutting the secret stays outside its span (it * was never part of the secret); interior runs are included. Callers use these * to detect an edit whose on-disk footprint intrudes into bytes the model was * never shown. * @param {FileView<"utf16">} view * @param {{start: number, deleted: string}[]} deletions * @returns {{start: number, end: number}[]} */ export function pairDiskSpans(view: FileView<"utf16">, deletions: { start: number; deleted: string; }[]): { start: number; end: number; }[]; /** * Defect in a redactor map relative to the Layer-1-cleaned text it claims to * describe, or null when the map is sound. Two proofs, both required before * any splice may trust the map: every pair's placeholder must actually occupy * `view.text` at its stated offset, and splicing each pair's original back * over its placeholder must reproduce `cleaned` byte-for-byte. The redactor is * an INJECTED engine with a real defect rate, and the offsets it emits anchor * edits onto disk bytes — a map failing either proof would splice at the wrong * position and corrupt the file, so the caller must treat it as unmappable * rather than act on it. Defect messages name placeholders and offsets only, * never an original (secret) byte. * Indexes `view.text` by `pair.start` directly, so it requires the UTF-16 * carrier — the same one every splice consumes. * @param {string} cleaned Layer-1-cleaned file text the map was derived from * @param {FileView<"utf16">} view * @returns {string | null} */ export function viewMapDefect(cleaned: string, view: FileView<"utf16">): string | null; /** * Substitute the placeholders in a model-authored new_string with the secrets * they stand for. Resolution, strictest first: if the new placeholder * sequence equals the matched span's, map 1:1 by position; otherwise each * placeholder text must name a single distinct secret within the span. A * placeholder naming a secret outside the span, or one whose text also * appears literally in the matched file text, is unresolvable → deny. * @param {string} oldS matched old_string (≡ the view span text) * @param {string} newS model-authored replacement * @param {readonly RedactionPair[]} spanPairs * @param {readonly RedactionPair[]} filePairs * @returns {{text: string, secrets: string[]} | {deny: string}} */ export function rehydrateNewString(oldS: string, newS: string, spanPairs: readonly RedactionPair[], filePairs: readonly RedactionPair[]): { text: string; secrets: string[]; } | { deny: string; }; export type RedactionPair = { placeholder: string; original: string; start: number; }; /** * Units a {@link FileView}'s pair offsets are expressed in. `codePoint` is * what the redactor's map mode emits (Python indexes strings by code point); * `utf16` is what every function in this module indexes by (JS * `indexOf`/`slice`/`.length` count UTF-16 code units). */ export type OffsetSpace = "codePoint" | "utf16"; /** * A branded, frozen carrier from {@link makeFileView}, tagged with the space its * `pairs` offsets live in. Its own JSDoc block: a `@template` applies to every * typedef in the comment it sits in, so sharing one with the two above would * make `RedactionPair` and `OffsetSpace` generic too. */ export type FileView = { readonly space: S; readonly text: string; readonly pairs: readonly RedactionPair[]; };