agent-sanitizer 2.25.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/types/index.d.mts CHANGED
@@ -10,7 +10,8 @@
10
10
  * its own removal.
11
11
  *
12
12
  * `found` names the categories neutralized; `warnings` carries the
13
- * operator-facing notices. `cleaned` is always a string, and a change only
13
+ * operator-facing notices and `notes` the quiet tier (see `./severity.mjs`).
14
+ * `cleaned` is always a string, and a change only
14
15
  * ever carries a warning (no silent suppression). `options` is optional and
15
16
  * tolerates an explicit `null`/`undefined` (treated the same as omitted) —
16
17
  * only a genuinely malformed `text` (not a string) throws, deliberately: a
@@ -20,7 +21,7 @@
20
21
  *
21
22
  * The layer bodies live in `./output.mjs`; this is a facade over them, not a
22
23
  * second implementation (see the module doc). It narrows `sanitizeText`'s result
23
- * to the three fields this entry has always promised — `modified`/`sgrNote`
24
+ * to the four fields this entry promises — `modified`/`sgrNote`
24
25
  * describe the tool-output pipeline's banner, and `reveal` is produced only by
25
26
  * options this facade does not expose. `html` selects Layers 2 AND 3 together
26
27
  * here, which is the surface this entry has always had; `sanitizeText` takes
@@ -28,7 +29,7 @@
28
29
  * detection without Layer 2's splice.
29
30
  * @param {string} text
30
31
  * @param {{ html?: boolean } | null} [options]
31
- * @returns {Promise<{ cleaned: string, found: string[], warnings: string[] }>}
32
+ * @returns {Promise<{ cleaned: string, found: string[], warnings: string[], notes: string[] }>}
32
33
  */
33
34
  export function sanitize(text: string, options?: {
34
35
  html?: boolean;
@@ -36,6 +37,7 @@ export function sanitize(text: string, options?: {
36
37
  cleaned: string;
37
38
  found: string[];
38
39
  warnings: string[];
40
+ notes: string[];
39
41
  }>;
40
42
  export { applyLayer1, isBenignAnsi, isBenignAnsiKinds, stripAnsiFully, LONE_SURROGATE_RE } from "./layer1.mjs";
41
43
  export { stripInvisible, stripInvisibleWithReport, isSgrOnly, STRIP, SGR_RE, CHECKS, CATEGORY, CATEGORY_LABELS, LINGUISTIC_SCRIPTS, VS, BLANK_NON_CF, LONG_RUN_RE, LONG_RUN_THRESHOLD, SCATTERED_THRESHOLD } from "./invisible.mjs";
@@ -40,23 +40,32 @@ export function scanText(content: string): Array<{
40
40
  * cannot be resolved (a dangling symlink or unreadable entry inside the
41
41
  * tree), is SKIPPED, so one bad symlink never aborts scanning the rest of the
42
42
  * project.
43
+ *
44
+ * `exclude` prunes the WALK, which is where a wide glob's cost actually is —
45
+ * a pattern that merely fails to match a bulk directory still pays to read it.
46
+ * It is composed with, never replaces, the unconditional `node_modules` prune:
47
+ * a caller narrowing the scan must not be able to widen it into a dependency
48
+ * tree. Pass {@link excludeFromContextScan} to take Claude Code's own scope.
43
49
  * @param {string[]} globs
44
- * @param {{ cwd?: string }} [options]
50
+ * @param {{ cwd?: string, exclude?: (entry: string) => boolean }} [options]
45
51
  * @returns {string[]}
46
52
  */
47
- export function findInstructionFiles(globs: string[], { cwd }?: {
53
+ export function findInstructionFiles(globs: string[], { cwd, exclude }?: {
48
54
  cwd?: string;
55
+ exclude?: (entry: string) => boolean;
49
56
  }): string[];
50
57
  /**
51
58
  * Scan every instruction file matched by `globs` and return only those with
52
59
  * findings, each path reported relative to `cwd`. Unreadable/missing files are
53
60
  * skipped. Pure scan — no mutation; pair with {@link cleanFile} to strip.
54
61
  * @param {string[]} globs
55
- * @param {{ cwd?: string }} [options]
62
+ * @param {{ cwd?: string, exclude?: (entry: string) => boolean }} [options]
63
+ * `exclude` is forwarded to {@link findInstructionFiles}
56
64
  * @returns {Array<{ file: string, findings: ReturnType<typeof scanText> }>}
57
65
  */
58
- export function scanInstructionFiles(globs: string[], { cwd }?: {
66
+ export function scanInstructionFiles(globs: string[], { cwd, exclude }?: {
59
67
  cwd?: string;
68
+ exclude?: (entry: string) => boolean;
60
69
  }): Array<{
61
70
  file: string;
62
71
  findings: ReturnType<typeof scanText>;
@@ -135,3 +144,4 @@ export function atomicReplaceFile(absPath: string, data: string, mode: number, t
135
144
  * @returns {boolean}
136
145
  */
137
146
  export function cleanFile(absPath: string, lstat?: (path: string) => import("node:fs").Stats): boolean;
147
+ export { CLAUDE_CONTEXT_SUBDIRS, CLAUDE_INSTRUCTION_GLOBS, excludeFromContextScan } from "./claude-context.mjs";
@@ -46,6 +46,72 @@ export function countPayloadInvisible(text: string): number;
46
46
  * @returns {string}
47
47
  */
48
48
  export function payloadInvisibleView(text: string): string;
49
+ /**
50
+ * The first payload-invisible LONG RUN in `text`, or null when there is none.
51
+ *
52
+ * THE definition of "this text carries a hidden run", shared by every consumer
53
+ * that has an opinion about one: the strip's `[LONG RUN — possible injection
54
+ * payload]` marker, the prompt gate's block decision, and the tool-output
55
+ * severity tier. They used to spell it twice, and differently — the marker
56
+ * probed the PAYLOAD view while the prompt gate probed the raw text, so a
57
+ * legitimate ten-emoji flag sequence (carve-out-preserved, never stripped) was
58
+ * quietly enough to BLOCK a prompt while the strip that saw the same text
59
+ * declined to even flag it. Masking the preserved invisibles is the right half
60
+ * of that disagreement: a run the carve-out keeps is rendering work, not a
61
+ * channel, and the joiners it does NOT keep are counted as payload anyway (see
62
+ * {@link countEffectiveInvisible}).
63
+ *
64
+ * Because the view replaces only PRESERVED invisibles (and visible characters)
65
+ * with spaces, a match consists solely of payload code points and is therefore
66
+ * byte-identical to the corresponding span of `text` — so a caller may report
67
+ * the sample verbatim.
68
+ * @param {string} text
69
+ * @returns {string | null}
70
+ */
71
+ export function payloadLongRunSample(text: string): string | null;
72
+ /**
73
+ * How many invisible code points in `text` the strip layer treats as PAYLOAD:
74
+ * the ones {@link countPayloadInvisible} counts, plus the joiners that sit in a
75
+ * genuine linguistic context but exceed the carve-out's preservation budget.
76
+ *
77
+ * The surplus term closes the preserved-joiner covert channel (O3):
78
+ * `countPayloadInvisible` excludes every ZWNJ/ZWJ doing real rendering work, so
79
+ * an attacker who alternates `letter joiner letter joiner …` — every joiner
80
+ * legitimately between two cursive letters — counts as ZERO there. The strip
81
+ * layer already refuses that (it preserves joiners only up to
82
+ * TOTAL_PRESERVED_JOINER_BUDGET / CONSECUTIVE_JOINER_CAP and strips the rest),
83
+ * so the surplus is read back OFF the strip — the SSOT — rather than by
84
+ * re-deriving the budget here, which is what would drift.
85
+ *
86
+ * A leading BOM is preserved by the strip but counted by
87
+ * {@link countPayloadInvisible}, so the difference can go slightly negative;
88
+ * hence the clamp.
89
+ * @param {string} text ANSI-stripped text (an escape sequence can hide invisibles)
90
+ * @returns {number}
91
+ */
92
+ export function countEffectiveInvisible(text: string): number;
93
+ /**
94
+ * True when the invisible characters in `text` are INCIDENTAL: no hidden run,
95
+ * and too few of them in total to carry an instruction.
96
+ *
97
+ * This is a severity line, not a strip line — the bytes are removed either way
98
+ * (see ../src/severity.mjs). It exists because a single soft hyphen in a
99
+ * pasted paragraph, or one variation selector a font demanded, raised the exact
100
+ * `WARNING: Tool output sanitized` an encoded payload does, and a warning that
101
+ * fires on a stray character in ordinary prose is one operators learn to skip.
102
+ *
103
+ * The bar is {@link LONG_RUN_THRESHOLD} — the count this module already calls
104
+ * "payload length" — applied to the WHOLE text rather than to one run, so it is
105
+ * strictly stronger than the run probe: fewer than ten payload-invisible code
106
+ * points, however they are distributed, cannot spell a smuggled instruction (ten
107
+ * tag characters are ten ASCII letters). Deliberately NOT the far looser
108
+ * {@link SCATTERED_THRESHOLD} of 30, which is the prompt gate's BLOCK bar: 29
109
+ * tag characters is a short sentence, and staying quiet about a short sentence
110
+ * hidden in a tool result is not a trade worth making.
111
+ * @param {string} text ANSI-stripped text, invisible runs intact
112
+ * @returns {boolean}
113
+ */
114
+ export function isIncidentalInvisible(text: string): boolean;
49
115
  /**
50
116
  * Strip payload-capable invisible chars and report which categories were
51
117
  * removed. A single leading U+FEFF (BOM) is preserved as a legitimate marker;
@@ -99,3 +99,15 @@ export function applyLayer1(text: string): {
99
99
  ansiKinds: string[];
100
100
  };
101
101
  export const LONE_SURROGATE_RE: RegExp;
102
+ /**
103
+ * What a reader is told when the ONLY thing a strip removed was inert ANSI (see
104
+ * {@link isBenignAnsiKinds}).
105
+ *
106
+ * It lives here, beside the predicate that decides it, because every entry point
107
+ * that can reach that verdict must say the same thing: the tool-output pipeline,
108
+ * the prompt gate's pass-with-note, and any host wiring its own. The wording is
109
+ * deliberately not `describeStripped`'s — "Stripped: ANSI escapes" names a
110
+ * category that reads like an attack, when the honest report is "these were
111
+ * colour codes, and here is how to look at the raw bytes".
112
+ */
113
+ export const INERT_ANSI_NOTE: string;
@@ -50,18 +50,26 @@ export function deleteVerbatimSpans(text: string, spans: string[]): {
50
50
  * post-mutation invariants and forget the rest, and every string in the returned
51
51
  * object has traversed Layer 4.
52
52
  *
53
- * `found` is the machine-readable twin of `warnings` — the {@link CATEGORY}
54
- * codes for what Layers 1-3 neutralized or flagged, in the order the layers ran.
55
- * Layers 4 and 5 have no category codes (their findings are the injected seam's
56
- * own vocabulary), so they contribute warnings only.
53
+ * Findings come back SPLIT BY SEVERITY (see ./severity.mjs): `warnings` holds
54
+ * everything injection-shaped — the banner a caller must show — and `notes`
55
+ * holds what happened but is not alarming. `warnings` therefore keeps exactly
56
+ * the meaning it always had, and a caller that ignores `notes` is no louder
57
+ * than before, just quieter about incidental bytes.
58
+ *
59
+ * `found` is the machine-readable twin, and the severity split does NOT reach
60
+ * it — the {@link CATEGORY} codes for what Layers 1-3 neutralized or flagged,
61
+ * in the order the layers ran, whichever tier described them. Layers 4 and 5
62
+ * have no category codes (their findings are the injected seam's own
63
+ * vocabulary), so they contribute findings only.
57
64
  * @param {string} text
58
65
  * @param {SanitizeTextOptions} [options]
59
- * @returns {Promise<{ cleaned: string, found: string[], warnings: string[], modified: boolean, sgrNote: boolean, reveal?: string }>}
66
+ * @returns {Promise<{ cleaned: string, found: string[], warnings: string[], notes: string[], modified: boolean, sgrNote: boolean, reveal?: string }>}
60
67
  */
61
68
  export function sanitizeText(text: string, options?: SanitizeTextOptions): Promise<{
62
69
  cleaned: string;
63
70
  found: string[];
64
71
  warnings: string[];
72
+ notes: string[];
65
73
  modified: boolean;
66
74
  sgrNote: boolean;
67
75
  reveal?: string;
@@ -82,8 +90,10 @@ export function isWalkableContainer(value: any): boolean;
82
90
  /**
83
91
  * Sanitize every string leaf of a tool-output value, preserving its shape (a
84
92
  * structured tool output whose shape changes would be ignored by a harness,
85
- * leaking the raw value). Non-string leaves pass through; `warnings`
86
- * accumulates across leaves. `sgrNote` is the OR across leaves.
93
+ * leaking the raw value). Non-string leaves pass through; `warnings` and
94
+ * `notes` accumulate across leaves, split by severity (see ./severity.mjs).
95
+ * `sgrNote` is the OR across leaves — true when SOME leaf was note-only — so a
96
+ * caller can still pick the quiet banner when no leaf raised a warning.
87
97
  *
88
98
  * Fails CLOSED on two hostile shapes that would otherwise throw a `RangeError`
89
99
  * as an unhandled async rejection (a DoS that leaves the output un-sanitized):
@@ -99,9 +109,12 @@ export function isWalkableContainer(value: any): boolean;
99
109
  * @param {SanitizeTextOptions} options
100
110
  * @param {string[]} warnings
101
111
  * @param {string[]} [reveals]
112
+ * @param {string[]} [notes] the NOTE-severity counterpart of `warnings`;
113
+ * appended last so an existing positional caller keeps working (it simply
114
+ * discards the notes, which is exactly as loud as before the split)
102
115
  * @returns {Promise<{ value: any, modified: boolean, sgrNote: boolean }>}
103
116
  */
104
- export function sanitizeValue(value: any, options: SanitizeTextOptions, warnings: string[], reveals?: string[]): Promise<{
117
+ export function sanitizeValue(value: any, options: SanitizeTextOptions, warnings: string[], reveals?: string[], notes?: string[]): Promise<{
105
118
  value: any;
106
119
  modified: boolean;
107
120
  sgrNote: boolean;
@@ -199,16 +212,18 @@ export type Layer5Result = {
199
212
  };
200
213
  /**
201
214
  * The running state of one {@link sanitizeText} call. Layers read `text` and
202
- * mutate it ONLY through {@link applyMutation}. `found` is the machine-readable
203
- * twin of `warnings`: the {@link CATEGORY} codes for whatever Layers 1-3
204
- * neutralized or flagged.
215
+ * mutate it ONLY through {@link applyMutation}. Findings carry their own
216
+ * severity (see ./severity.mjs) and are split into `warnings`/`notes` at the
217
+ * single exit, so no layer can push into the wrong list. `found` is the
218
+ * machine-readable twin, unaffected by the split: the {@link CATEGORY} codes
219
+ * for whatever Layers 1-3 neutralized or flagged.
205
220
  */
206
221
  export type PipelineState = {
207
222
  text: string;
208
223
  found: string[];
209
- warnings: string[];
224
+ findings: import("./severity.mjs").Finding[];
210
225
  modified: boolean;
211
- sgrNote: boolean;
226
+ unreportedChange: boolean;
212
227
  };
213
228
  export type SanitizeTextOptions = {
214
229
  html?: boolean;
@@ -218,4 +233,4 @@ export type SanitizeTextOptions = {
218
233
  sgrCarveOut?: boolean;
219
234
  };
220
235
  import { needsMarkdownPipeline } from "./gates.mjs";
221
- export { describeRemoved, describeWarned } from "./warnings.mjs";
236
+ export { describeExfil, describeRemoved, describeWarned } from "./warnings.mjs";
@@ -0,0 +1,83 @@
1
+ /**
2
+ * @typedef {{ severity: string, message: string }} Finding
3
+ * A single reportable outcome and how loudly to report it.
4
+ */
5
+ /**
6
+ * A WARNING-severity finding: injection-shaped, worth the banner.
7
+ * @param {string} message
8
+ * @returns {Finding}
9
+ */
10
+ export function warning(message: string): Finding;
11
+ /**
12
+ * A NOTE-severity finding: reported, not alarming.
13
+ * @param {string} message
14
+ * @returns {Finding}
15
+ */
16
+ export function note(message: string): Finding;
17
+ /**
18
+ * A finding at `severity` — the constructor for a caller that has already
19
+ * computed the tier as a boolean and would otherwise write the ternary itself.
20
+ * @param {boolean} isWarning
21
+ * @param {string} message
22
+ * @returns {Finding}
23
+ */
24
+ export function finding(isWarning: boolean, message: string): Finding;
25
+ /**
26
+ * The messages of every WARNING-severity finding, in order.
27
+ * @param {readonly Finding[]} findings
28
+ * @returns {string[]}
29
+ */
30
+ export function warningMessages(findings: readonly Finding[]): string[];
31
+ /**
32
+ * The messages of every NOTE-severity finding, in order.
33
+ * @param {readonly Finding[]} findings
34
+ * @returns {string[]}
35
+ */
36
+ export function noteMessages(findings: readonly Finding[]): string[];
37
+ /**
38
+ * The one place a finding's LOUDNESS is decided, and the vocabulary every layer
39
+ * reports in.
40
+ *
41
+ * Every layer of this pipeline used to have exactly one volume. A cursor-spoofing
42
+ * ANSI payload and a single stray escape byte in a README produced the same
43
+ * `WARNING: Tool output sanitized` banner; so did one soft hyphen, and so did
44
+ * the `<script>` tag that every fetched web page carries. That is the failure
45
+ * mode a detector dies of: a banner that fires on every ordinary page teaches its
46
+ * reader to skip the banner, and then the one that mattered scrolls past too.
47
+ *
48
+ * So a finding carries a SEVERITY, and the two tiers mean specific things:
49
+ *
50
+ * WARNING — this text is injection-shaped. Something was hidden from a human
51
+ * reader, something was removed that a payload would have used, or a
52
+ * secret was redacted. Worth interrupting the reader for.
53
+ * NOTE — this happened, and here is how to look at it, but nothing about it
54
+ * is attack-shaped. Incidental bytes, or content that was PRESERVED
55
+ * and merely described.
56
+ *
57
+ * The tier never changes what the pipeline DOES: the same bytes are stripped,
58
+ * spliced and redacted either way, and a note is still reported. All that rides
59
+ * on it is which banner the operator sees, which is why a note is the right
60
+ * answer whenever the evidence is thin — an under-loud true finding is still
61
+ * delivered, while an over-loud false one costs the channel its credibility.
62
+ *
63
+ * Mechanism only, deliberately: WHICH findings qualify is each layer's own
64
+ * judgement, made where that layer's evidence lives (see isBenignAnsiKinds in
65
+ * ./layer1.mjs, isIncidentalInvisible in ./invisible.mjs, and the exfil/HTML
66
+ * tiers in ./output.mjs and ./index.mjs). This module owns the enum, the constructors and the
67
+ * queries so nobody spells `severity === "warning"` by hand.
68
+ */
69
+ /**
70
+ * The closed severity vocabulary. Stable, machine-readable values: branch on
71
+ * these, not on the prose.
72
+ */
73
+ export const SEVERITY: Readonly<{
74
+ NOTE: "note";
75
+ WARNING: "warning";
76
+ }>;
77
+ /**
78
+ * A single reportable outcome and how loudly to report it.
79
+ */
80
+ export type Finding = {
81
+ severity: string;
82
+ message: string;
83
+ };
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The one directory no instruction-file walk ever descends into. Its own
3
+ * function so the name is spelled once, and so the two predicates that need it
4
+ * (a plain glob walk, and {@link excludeFromContextScan}) cannot disagree.
5
+ * @param {string} entry a bare entry name or a path relative to the scan root
6
+ * @returns {boolean}
7
+ */
8
+ export function excludeNodeModules(entry: string): boolean;
9
+ /**
10
+ * Entries a context scan must not descend into or return: `node_modules`, and
11
+ * every child of a `.claude` directory that is not whitelisted context.
12
+ *
13
+ * The globs alone would already refuse to MATCH those files, but a glob walker
14
+ * calls this on directories as it walks and prunes the ones it rejects — which
15
+ * is where the cost actually is. Without the prune, a `.claude/worktrees/`
16
+ * holding a few repo checkouts is walked in full on every session start (and,
17
+ * because a doubled-star segment does cross into a dot directory when the
18
+ * pattern names one, a `.claude` NESTED inside a worktree was matched and
19
+ * scanned as if it were this session's context).
20
+ *
21
+ * A walker calls this with both bare names and root-relative paths, so it must
22
+ * answer for either; a bare name carries no `.claude` context and is judged only
23
+ * against `node_modules`.
24
+ * @param {string} entry a bare entry name or a path relative to the scan root
25
+ * @returns {boolean}
26
+ */
27
+ export function excludeFromContextScan(entry: string): boolean;
28
+ /**
29
+ * WHICH files an agent loads as model context, as data: the glob set and the
30
+ * walk-pruning predicate that together define "everything Claude Code reads as
31
+ * instructions, and nothing else".
32
+ *
33
+ * This is the SINGLE SOURCE for that scope. It used to live inside
34
+ * `claude-hooks/scan-invisible-chars.mjs`, which meant the SessionStart hook
35
+ * knew the answer and nobody else did: `src/instructions.mjs` takes
36
+ * caller-supplied globs by design (no agent's convention is baked into the
37
+ * engine), so the CLI, the Python port and every downstream fork spelled their
38
+ * own approximation of this list — and an approximation that drifts either
39
+ * scans bulk data that can never reach the model (the 30-second session start
40
+ * this whitelist exists to fix) or MISSES a context directory entirely, which
41
+ * is a silent hole in the one scan standing between a poisoned instruction file
42
+ * and a session that loads it.
43
+ *
44
+ * It is a standalone, dependency-free DATA module (like ./cf-charset.mjs) for
45
+ * two reasons: `src/instructions.mjs` re-exports it as the library's public
46
+ * door, and the hook imports it RELATIVELY — deliberately not through the
47
+ * `agent-sanitizer` specifier the plugin bundle pins to a published engine.
48
+ * This scope is hook POLICY, not engine behavior: it must ship and move with the
49
+ * hook that walks it, or a plugin built against an older pin would prune the
50
+ * wrong directories while believing it had scanned everything.
51
+ */
52
+ /**
53
+ * The `.claude/` subdirectories whose markdown Claude Code loads as model
54
+ * context. This is a WHITELIST, and that is the point: `.claude/` is also where
55
+ * tooling parks bulk data that is never loaded as context — `worktrees/`
56
+ * (entire checked-out copies of the repo), plus caches, transcripts and
57
+ * snapshots — and globbing `.claude/**` swept all of it in. On a repo with a few
58
+ * populated worktrees that is thousands of files READ at every session start:
59
+ * one report put it at 30 seconds of blocked startup, paid for scanning files
60
+ * that cannot reach the model.
61
+ *
62
+ * A whitelist, not a `worktrees` denylist, because the failure modes are not
63
+ * symmetric: an unlisted context directory costs a scan nobody asked for anyway
64
+ * (the PostToolUse sanitizer still cleans those bytes when a tool reads them),
65
+ * while an unlisted BULK directory silently costs every future session its
66
+ * startup. Add an entry here when Claude Code starts loading a new `.claude/`
67
+ * subdirectory as context.
68
+ */
69
+ export const CLAUDE_CONTEXT_SUBDIRS: readonly string[];
70
+ /**
71
+ * Every glob whose matches Claude Code loads as model context: the
72
+ * per-directory instruction files (CLAUDE.md, CLAUDE.local.md, AGENTS.md) and
73
+ * the whitelisted `.claude/` markdown. Claude Code loads these on entry to their
74
+ * containing directory — a load path that bypasses the PostToolUse sanitizer —
75
+ * so a payload planted in e.g. `packages/foo/CLAUDE.md` reaches the model
76
+ * uncleaned unless something scans it here.
77
+ *
78
+ * `**` does not descend into dot directories, so NESTED `.claude/` trees need
79
+ * their own doubled-star-prefixed patterns: without them a directory-scoped
80
+ * skill at `packages/foo/.claude/skills/x/SKILL.md` — model context by the same
81
+ * load path — is never matched. That same rule is why the root `.claude` needs
82
+ * no separate entry: a leading doubled star matches zero segments, so the
83
+ * nested patterns cover the root tree too.
84
+ *
85
+ * Pair with {@link excludeFromContextScan}: the patterns alone already refuse to
86
+ * MATCH a bulk directory, but only pruning the WALK avoids paying to read it.
87
+ */
88
+ export const CLAUDE_INSTRUCTION_GLOBS: readonly string[];
@@ -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
  };