/** * Pure offset/text machinery for mapping between a file's on-disk bytes and * the sanitized view the model reads (Layer 1 invisible/ANSI stripping, then * Layer 4 secret redaction). No I/O — consumed by `./rehydrate.mjs`, * which owns file access, the injected redactor, and policy. * * Coordinate spaces, disk → view: * disk — the file's real bytes * cleaned — disk minus the runs Layer 1 deleted (`alignDeletions` recovers * them; a run at `start` sits immediately before cleaned[start]) * view — cleaned with each secret replaced by its [REDACTED…] * placeholder (`pairs` from the injected redactor’s map mode) */ /** * 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. * @param {string} text the redacted view text the offsets index into * @param {{placeholder: string, original: string, start: number}[]} pairs * @returns {{placeholder: string, original: string, start: number}[]} */ export function pairsToUtf16(text: string, pairs: { placeholder: string; original: string; start: number; }[]): { placeholder: string; original: string; start: number; }[]; /** * 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 {{text: string, pairs: {placeholder: string, original: string, start: number}[]}} view * @param {{start: number, deleted: string}[]} deletions * @param {number} viewStart * @param {number} viewEnd */ export function resolveSpan(content: string, cleaned: string, view: { text: string; pairs: { placeholder: string; original: string; start: number; }[]; }, deletions: { start: number; deleted: string; }[], viewStart: number, viewEnd: number): { diskText: string; cleanedText: string; invisibleBytes: number; pairs: { placeholder: string; original: string; start: number; }[]; } | null; /** * All occurrences of any needle in `text`, ordered by position. Placeholder * texts never substring-overlap one another (each ends in "]" right after its * distinguishing label), so the sorted matches are non-overlapping. * @param {string} text * @param {string[]} needles * @returns {{text: string, index: number}[]} */ export function orderedMatches(text: string, needles: string[]): { text: string; index: 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 {{pairs: {placeholder: string, original: string, start: number}[]}} view * @param {{start: number, deleted: string}[]} deletions * @returns {{start: number, end: number}[]} */ export function pairDiskSpans(view: { pairs: { placeholder: string; original: string; start: number; }[]; }, deletions: { start: number; deleted: string; }[]): { start: number; end: number; }[]; /** * 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 {{placeholder: string, original: string, start: number}[]} spanPairs * @param {{placeholder: string, original: string, start: number}[]} filePairs * @returns {{text: string, secrets: string[]} | {deny: string}} */ export function rehydrateNewString(oldS: string, newS: string, spanPairs: { placeholder: string; original: string; start: number; }[], filePairs: { placeholder: string; original: string; start: number; }[]): { text: string; secrets: string[]; } | { deny: string; };