rewritable 0.1.0 → 0.5.0

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.
@@ -0,0 +1,357 @@
1
+ // rwa-edit-dsl/1 compiler — turns a DSL plan into an apply_edits or
2
+ // replace_document envelope. Read alongside rwa-edit-dsl-spec.md.
3
+ //
4
+ // Compile-down semantics:
5
+ // - replace_document plan → { tool: 'replace_document', envelope }
6
+ // - any other plan → { tool: 'apply_edits', envelope: { version, edits } }
7
+ //
8
+ // Multi-op plans apply sequentially against an evolving "shadow" doc — each
9
+ // op's anchor is resolved against the doc as it would look after preceding
10
+ // ops landed. The shadow is internal to compilation; the emitted edits are
11
+ // applied sequentially by the runtime in the same order, so every emitted
12
+ // `find` matches in turn.
13
+
14
+ const SUPPORTED_VERSION = 'rwa-edit-dsl/1';
15
+
16
+ class DslCompileError extends Error {
17
+ constructor(code, message, op) {
18
+ super(message);
19
+ this.code = code;
20
+ this.op = op;
21
+ }
22
+ }
23
+
24
+ function makeError(code, message, op) {
25
+ return new DslCompileError(code, message, op);
26
+ }
27
+
28
+ /**
29
+ * Compile a DSL plan against a doc. Returns:
30
+ * { tool: 'apply_edits' | 'replace_document', envelope: <rwa-edit/1 envelope> }
31
+ *
32
+ * Throws DslCompileError on any spec violation.
33
+ */
34
+ export function compileDslPlan(plan, doc) {
35
+ if (!plan || typeof plan !== 'object') {
36
+ throw makeError('op_malformed', 'plan must be an object');
37
+ }
38
+ if (plan.version !== SUPPORTED_VERSION) {
39
+ throw makeError('version_unsupported', `expected ${SUPPORTED_VERSION}, got ${plan.version}`);
40
+ }
41
+ if (!Array.isArray(plan.ops) || plan.ops.length === 0) {
42
+ throw makeError('op_malformed', 'plan.ops must be a non-empty array');
43
+ }
44
+
45
+ // replace_document is a sole-op escape hatch.
46
+ const hasReplaceDoc = plan.ops.some(op => op?.op === 'replace_document');
47
+ if (hasReplaceDoc) {
48
+ if (plan.ops.length !== 1) {
49
+ throw makeError('op_malformed', 'replace_document must be the sole op in a plan');
50
+ }
51
+ const op = plan.ops[0];
52
+ if (typeof op.doc !== 'string' || typeof op.reason !== 'string') {
53
+ throw makeError('op_malformed', 'replace_document requires doc and reason fields');
54
+ }
55
+ return {
56
+ tool: 'replace_document',
57
+ envelope: { version: 'rwa-edit/1', doc: op.doc, reason: op.reason },
58
+ };
59
+ }
60
+
61
+ // Otherwise: compile each op against an evolving shadow.
62
+ let shadow = doc;
63
+ const edits = [];
64
+ for (const op of plan.ops) {
65
+ const newEdits = compileOp(op, shadow);
66
+ for (const e of newEdits) {
67
+ validateEditApplies(shadow, e, op);
68
+ edits.push(e);
69
+ shadow = applyEditToShadow(shadow, e);
70
+ }
71
+ }
72
+ return {
73
+ tool: 'apply_edits',
74
+ envelope: { version: 'rwa-edit/1', edits },
75
+ };
76
+ }
77
+
78
+ function compileOp(op, doc) {
79
+ if (!op || typeof op !== 'object' || typeof op.op !== 'string') {
80
+ throw makeError('op_malformed', 'each op must be an object with a string `op` field');
81
+ }
82
+ switch (op.op) {
83
+ case 'replace': return compileReplace(op, doc);
84
+ case 'insert': return compileInsert(op, doc);
85
+ case 'delete': return compileDelete(op, doc);
86
+ case 'set_attr': return compileSetAttr(op, doc);
87
+ default: throw makeError('op_unknown', `unknown op: ${op.op}`, op);
88
+ }
89
+ }
90
+
91
+ // ---------- replace ----------
92
+
93
+ function compileReplace(op, doc) {
94
+ const { find, replace, region, all } = op;
95
+ if (typeof find !== 'string' || typeof replace !== 'string') {
96
+ throw makeError('op_malformed', 'replace requires `find` and `replace` strings', op);
97
+ }
98
+
99
+ let windowStart = 0;
100
+ let windowEnd = doc.length;
101
+ if (typeof region === 'string') {
102
+ const matches = allOccurrences(doc, region);
103
+ if (matches.length === 0) throw makeError('region_not_found', `region not found: ${preview(region)}`, op);
104
+ if (matches.length > 1) throw makeError('region_not_unique', `region matches ${matches.length} times`, op);
105
+ windowStart = matches[0];
106
+ windowEnd = matches[0] + region.length;
107
+ }
108
+
109
+ const window = doc.slice(windowStart, windowEnd);
110
+ const localOccs = allOccurrences(window, find);
111
+ if (localOccs.length === 0) {
112
+ throw makeError(all ? 'all_with_zero_matches' : 'op_malformed', `find has zero matches in search window: ${preview(find)}`, op);
113
+ }
114
+ if (!all && localOccs.length > 1) {
115
+ throw makeError('op_malformed', `find has ${localOccs.length} matches in search window but all=false: ${preview(find)}`, op);
116
+ }
117
+
118
+ // For all=false, single match in window. If find is also globally unique, emit raw.
119
+ // Otherwise contextualize using surrounding doc bytes.
120
+ if (!all) {
121
+ const globalOccs = allOccurrences(doc, find);
122
+ if (globalOccs.length === 1) {
123
+ return [{ find, replace }];
124
+ }
125
+ // Disambiguate with surrounding context drawn from the window.
126
+ const absoluteStart = windowStart + localOccs[0];
127
+ return [contextualizeEdit(doc, absoluteStart, find, replace)];
128
+ }
129
+
130
+ // all=true: emit one edit per local occurrence, contextualized.
131
+ return localOccs.map(localStart => {
132
+ const absoluteStart = windowStart + localStart;
133
+ return contextualizeEdit(doc, absoluteStart, find, replace);
134
+ });
135
+ }
136
+
137
+ // Extend find/replace bytes outward until find is uniquely locatable in doc.
138
+ // We extend backward by 1 char at a time then forward, alternating, until
139
+ // the candidate find appears exactly once in doc.
140
+ function contextualizeEdit(doc, absoluteStart, find, replace) {
141
+ const findEnd = absoluteStart + find.length;
142
+ let preLen = 0, postLen = 0;
143
+ // Bound: at most extend 200 chars in each direction. Most disambiguations
144
+ // need <20; 200 is a sanity cap.
145
+ const MAX = 200;
146
+ while (true) {
147
+ const ctxFind = doc.slice(absoluteStart - preLen, findEnd + postLen);
148
+ const ctxReplace = doc.slice(absoluteStart - preLen, absoluteStart) + replace + doc.slice(findEnd, findEnd + postLen);
149
+ const occs = allOccurrences(doc, ctxFind);
150
+ if (occs.length === 1) {
151
+ return { find: ctxFind, replace: ctxReplace };
152
+ }
153
+ if (preLen >= MAX && postLen >= MAX) {
154
+ throw makeError('op_malformed', `unable to disambiguate find within ${MAX} chars: ${preview(find)}`);
155
+ }
156
+ if (postLen <= preLen && findEnd + postLen < doc.length) postLen++;
157
+ else if (absoluteStart - preLen > 0) preLen++;
158
+ else postLen++;
159
+ }
160
+ }
161
+
162
+ // ---------- insert ----------
163
+
164
+ function compileInsert(op, doc) {
165
+ const { content, after, before } = op;
166
+ if (typeof content !== 'string') {
167
+ throw makeError('op_malformed', 'insert requires `content` string', op);
168
+ }
169
+ const positionalCount = (typeof after === 'string' ? 1 : 0) + (typeof before === 'string' ? 1 : 0);
170
+ if (positionalCount !== 1) {
171
+ throw makeError('op_malformed', 'insert requires exactly one of `after` or `before`', op);
172
+ }
173
+ const anchor = typeof after === 'string' ? after : before;
174
+ const occs = allOccurrences(doc, anchor);
175
+ if (occs.length === 0) throw makeError('op_malformed', `insert anchor not found: ${preview(anchor)}`, op);
176
+ if (occs.length > 1) throw makeError('op_malformed', `insert anchor not unique: ${preview(anchor)} (${occs.length} matches)`, op);
177
+ if (typeof after === 'string') {
178
+ return [{ find: anchor, replace: anchor + content }];
179
+ }
180
+ return [{ find: anchor, replace: content + anchor }];
181
+ }
182
+
183
+ // ---------- delete ----------
184
+
185
+ function compileDelete(op, doc) {
186
+ const { target } = op;
187
+ if (typeof target !== 'string') {
188
+ throw makeError('op_malformed', 'delete requires `target` string', op);
189
+ }
190
+ const occs = allOccurrences(doc, target);
191
+ if (occs.length === 0) throw makeError('op_malformed', `delete target not found: ${preview(target)}`, op);
192
+ if (occs.length > 1) throw makeError('op_malformed', `delete target not unique: ${preview(target)} (${occs.length} matches)`, op);
193
+ return [{ find: target, replace: '' }];
194
+ }
195
+
196
+ // ---------- set_attr ----------
197
+
198
+ function compileSetAttr(op, doc) {
199
+ const { anchor, attr, value } = op;
200
+ if (typeof anchor !== 'string' || typeof attr !== 'string' || typeof value !== 'string') {
201
+ throw makeError('op_malformed', 'set_attr requires anchor, attr, value strings', op);
202
+ }
203
+ if (!anchor.startsWith('<')) {
204
+ throw makeError('anchor_unparseable', 'set_attr.anchor must start with `<`', op);
205
+ }
206
+ if (anchor.endsWith('>')) {
207
+ throw makeError('anchor_unparseable', 'set_attr.anchor must end before `>`', op);
208
+ }
209
+ const occs = allOccurrences(doc, anchor);
210
+ if (occs.length === 0) throw makeError('op_malformed', `set_attr anchor not found: ${preview(anchor)}`, op);
211
+ if (occs.length > 1) throw makeError('op_malformed', `set_attr anchor not unique: ${preview(anchor)} (${occs.length} matches)`, op);
212
+ const start = occs[0];
213
+ const closeIdx = doc.indexOf('>', start + anchor.length);
214
+ if (closeIdx < 0) throw makeError('anchor_unparseable', 'no `>` found after set_attr anchor', op);
215
+ const fullTag = doc.slice(start, closeIdx + 1);
216
+
217
+ // Reject attribute values containing chars that can't survive serialization.
218
+ if (/[- -]/.test(value)) {
219
+ throw makeError('attr_value_unrepresentable', 'value contains control characters', op);
220
+ }
221
+ const escapedValue = value.replace(/&/g, '&amp;').replace(/"/g, '&quot;');
222
+
223
+ // Detect whether attr already appears in fullTag. We respect quote state to
224
+ // avoid matching attribute substrings inside another attribute's value.
225
+ const existingMatch = findAttrInTag(fullTag, attr);
226
+ let newTag;
227
+ if (existingMatch) {
228
+ const [attrStart, attrEnd] = existingMatch;
229
+ newTag = fullTag.slice(0, attrStart) + `${attr}="${escapedValue}"` + fullTag.slice(attrEnd);
230
+ } else {
231
+ newTag = fullTag.slice(0, -1) + ` ${attr}="${escapedValue}">`;
232
+ }
233
+ return [{ find: fullTag, replace: newTag }];
234
+ }
235
+
236
+ // Locate `attr` inside a parsed opening tag, returning [start, end) byte
237
+ // offsets within the tag of the attr's full `name="value"` substring (or
238
+ // `name='value'`, or `name=value`, or boolean `name`). Returns null if absent.
239
+ // Respects quote state to avoid false matches inside other attributes.
240
+ function findAttrInTag(tag, attrName) {
241
+ // Walk attribute by attribute. The tag starts with <tagname or </tagname.
242
+ // Skip past tagname.
243
+ const nameMatch = tag.match(/^<\/?([a-zA-Z][a-zA-Z0-9_-]*)/);
244
+ if (!nameMatch) return null;
245
+ let i = nameMatch[0].length;
246
+ while (i < tag.length - 1) {
247
+ // Skip whitespace
248
+ while (i < tag.length && /\s/.test(tag[i])) i++;
249
+ if (i >= tag.length || tag[i] === '>' || tag[i] === '/') break;
250
+ const attrStart = i;
251
+ // Read attribute name
252
+ let nameEnd = i;
253
+ while (nameEnd < tag.length && !/[\s=>/]/.test(tag[nameEnd])) nameEnd++;
254
+ const name = tag.slice(attrStart, nameEnd);
255
+ i = nameEnd;
256
+ // Optional = followed by value
257
+ let attrEnd = nameEnd;
258
+ if (tag[i] === '=') {
259
+ i++;
260
+ if (tag[i] === '"') {
261
+ const close = tag.indexOf('"', i + 1);
262
+ if (close < 0) return null;
263
+ attrEnd = close + 1;
264
+ i = attrEnd;
265
+ } else if (tag[i] === "'") {
266
+ const close = tag.indexOf("'", i + 1);
267
+ if (close < 0) return null;
268
+ attrEnd = close + 1;
269
+ i = attrEnd;
270
+ } else {
271
+ // Unquoted value
272
+ while (i < tag.length && !/[\s>]/.test(tag[i])) i++;
273
+ attrEnd = i;
274
+ }
275
+ } else {
276
+ attrEnd = nameEnd;
277
+ }
278
+ if (name === attrName) return [attrStart, attrEnd];
279
+ }
280
+ return null;
281
+ }
282
+
283
+ // ---------- shared helpers ----------
284
+
285
+ function allOccurrences(haystack, needle) {
286
+ const out = [];
287
+ if (needle.length === 0) return out;
288
+ let from = 0;
289
+ while (true) {
290
+ const idx = haystack.indexOf(needle, from);
291
+ if (idx < 0) break;
292
+ out.push(idx);
293
+ from = idx + 1; // overlapping matches allowed
294
+ }
295
+ return out;
296
+ }
297
+
298
+ function applyEditToShadow(doc, edit) {
299
+ const idx = doc.indexOf(edit.find);
300
+ if (idx < 0) {
301
+ throw makeError('op_malformed', `compiler shadow drift: emitted edit no longer matches: ${preview(edit.find)}`);
302
+ }
303
+ const next = doc.indexOf(edit.find, idx + 1);
304
+ if (next >= 0) {
305
+ throw makeError('op_malformed', `compiler shadow drift: emitted edit ambiguous (${allOccurrences(doc, edit.find).length} matches): ${preview(edit.find)}`);
306
+ }
307
+ return doc.slice(0, idx) + edit.replace + doc.slice(idx + edit.find.length);
308
+ }
309
+
310
+ function validateEditApplies(doc, edit, op) {
311
+ if (typeof edit.find !== 'string' || typeof edit.replace !== 'string') {
312
+ throw makeError('op_malformed', 'compiler bug: emitted non-string find/replace', op);
313
+ }
314
+ if (edit.find.length === 0) {
315
+ throw makeError('op_malformed', 'compiler bug: emitted empty find', op);
316
+ }
317
+ }
318
+
319
+ function preview(s) {
320
+ const trimmed = s.length > 60 ? s.slice(0, 57) + '...' : s;
321
+ return JSON.stringify(trimmed);
322
+ }
323
+
324
+ /**
325
+ * Apply an envelope (the compileDslPlan output, OR a model's apply_edits/replace_document)
326
+ * to a doc. Used by the fidelity-dsl runner's comparator to check round-trip
327
+ * equivalence between the DSL-compiled envelope and the scenario stub envelope.
328
+ *
329
+ * Mirrors the runtime's apply path: each find must match exactly once in turn.
330
+ *
331
+ * @param {string} doc — the input doc (LF-canonical)
332
+ * @param {{ tool: string, envelope: object }} env — { tool, envelope } pair
333
+ * @returns {string} the post-apply doc
334
+ */
335
+ export function applyEnvelopeToDoc(doc, env) {
336
+ if (env.tool === 'replace_document') {
337
+ return env.envelope.doc;
338
+ }
339
+ if (env.tool !== 'apply_edits') {
340
+ throw new Error(`applyEnvelopeToDoc: unknown tool "${env.tool}"`);
341
+ }
342
+ let result = doc;
343
+ for (const e of env.envelope.edits) {
344
+ const idx = result.indexOf(e.find);
345
+ if (idx < 0) {
346
+ throw new Error(`apply: find not found: ${preview(e.find)}`);
347
+ }
348
+ const next = result.indexOf(e.find, idx + 1);
349
+ if (next >= 0) {
350
+ throw new Error(`apply: find not unique: ${preview(e.find)}`);
351
+ }
352
+ result = result.slice(0, idx) + e.replace + result.slice(idx + e.find.length);
353
+ }
354
+ return result;
355
+ }
356
+
357
+ export { DslCompileError };
package/src/edit.mjs ADDED
@@ -0,0 +1,300 @@
1
+ // Plan-path entry for `rwa edit`. Composes the three foundation modules
2
+ // (dsl-compiler, apply-edits, seed splice helpers) into a single function
3
+ // that takes a target .html and a tool-envelope, applies the edit
4
+ // deterministically, and atomically writes the file back.
5
+ //
6
+ // Error surface (load-bearing — Task 5's --json output keys on these):
7
+ // exitCode 2 / subcode: 'not_found', 'read_error', 'not_a_rewritable'
8
+ // exitCode 3 / subcode: 'not_an_object', 'unknown_shape',
9
+ // 'ambiguous_envelope', 'missing_version',
10
+ // 'version_mismatch', 'missing_reason',
11
+ // 'malformed_envelope', 'frozen_zone_violation',
12
+ // plus DslCompileError.code or RwaEditError.code
13
+ // from the underlying modules.
14
+
15
+ import { readFile } from 'node:fs/promises';
16
+ import { atomicWrite } from './atomic-write.mjs';
17
+ import {
18
+ applyEdits, RwaEditError, dataRwaFrozenSnapshot, FAILURE_HINTS,
19
+ virtualizeImages, expandImages, assertNoNewAssetTokens, mapEnvelopeImages, MAX_DOC_EXPANDED,
20
+ extractFrozenZones3, lockedRangesIn, markerZoneRangesIn,
21
+ } from './apply-edits.mjs';
22
+ import { compileDslPlan } from './dsl-compiler.mjs';
23
+ import { extractInlineDoc, replaceInlineDoc } from './seed.mjs';
24
+
25
+ export class CliError extends Error {
26
+ constructor(exitCode, subcode, details = {}) {
27
+ super(subcode);
28
+ this.exitCode = exitCode;
29
+ this.subcode = subcode;
30
+ this.details = details;
31
+ // Self-documenting failures: attach a one-line, code-keyed recovery hint so
32
+ // `rwa edit --json` consumers (agents, scripts) get actionable guidance, not
33
+ // just a code. Mirrors the seed's failureToToolResult. Additive and keyed on
34
+ // a limited table, so subcodes without a hint (e.g. doc.mjs read errors) are
35
+ // untouched.
36
+ if (FAILURE_HINTS[subcode] && this.details.hint == null) this.details.hint = FAILURE_HINTS[subcode];
37
+ }
38
+ }
39
+
40
+ // Inspect the envelope's discriminator set and assert version invariants.
41
+ // Returns the canonical tool name on success.
42
+ // Frozen-zone preservation for wholesale-replacement paths (replace_document and
43
+ // the DSL escape op) — the equivalent of the guards applyEdits runs on the
44
+ // find/replace path. MARKER-form zones (all three fence forms) must survive
45
+ // byte-identically by name (mirror of seed replaceDocument's extractFrozenZones/
46
+ // frozenZonesIntact check); the set of ATTRIBUTE-form data-rwa-frozen elements
47
+ // must be unchanged (snapshot equality, mirror of seed dataRwaFrozenSnapshot).
48
+ // Without this the escape hatch would let an agent drift a frozen self-
49
+ // description declaration that apply_edits protects.
50
+ function assertFrozenPreserved(currentDoc, newDoc) {
51
+ // Class-lock coverage (rwa-lens/1 spec §7; seed replaceDocument class_lock_uncovered).
52
+ // A bare .rwa-locked block in the CURRENT doc cannot survive a wholesale rewrite —
53
+ // the wrapper can be reshaped, attribute-mutated, or dropped. Locks are only safe
54
+ // under replace_document if their source range is entirely contained within a
55
+ // marker-form frozen zone (markers wrap or equal the lock — NOT the inverse).
56
+ // Precondition on the current doc: if any lock is uncovered, NO replace_document
57
+ // is allowed, regardless of the new doc. markerZoneRangesIn is 3-fence-form, and
58
+ // the byte-preservation scan below is too (extractFrozenZones3) — so a lock the
59
+ // coverage check accepts as covered by a /* */ or // zone is a zone the
60
+ // preservation check actually protects. The two agree on the fence-form axis.
61
+ const lockRanges = lockedRangesIn(currentDoc);
62
+ if (lockRanges.length) {
63
+ const markerRanges = markerZoneRangesIn(currentDoc);
64
+ for (const [ls, le] of lockRanges) {
65
+ const covered = markerRanges.some(([ms, me]) => ms <= ls && le <= me);
66
+ if (!covered) throw new CliError(3, 'class_lock_uncovered', { lockRange: [ls, le] });
67
+ }
68
+ }
69
+ // Marker-form frozen zones — all three fence forms, with unterminated AND
70
+ // duplicate detection (faithful mirror of the seed's extractFrozenZones +
71
+ // frozenZonesIntact). One scan feeds byte-preservation, add-rejection, the
72
+ // half-open-fence check, and the shadow-duplicate check, so a /* */ or // zone
73
+ // can't be silently dropped, minted, half-opened, or duplicated via the escape
74
+ // hatch — and a duplicate-name pair can't smuggle a tampered copy past a
75
+ // last-wins Map. The CLI surfaces frozen_zone_violation (its replace-path
76
+ // convention) where the seed throws frozen_zone_corrupted.
77
+ const oldZones = extractFrozenZones3(currentDoc);
78
+ const newZones = extractFrozenZones3(newDoc);
79
+ const orphan = newZones.find(z => z.error === 'unterminated');
80
+ if (orphan) {
81
+ throw new CliError(3, 'frozen_zone_violation', {
82
+ zone: orphan.name,
83
+ reason: 'replace_document must not leave an unterminated frozen-zone marker',
84
+ });
85
+ }
86
+ const dup = newZones.find(z => z.error === 'duplicate') || oldZones.find(z => z.error === 'duplicate');
87
+ if (dup) {
88
+ throw new CliError(3, 'frozen_zone_violation', {
89
+ zone: dup.name,
90
+ reason: 'duplicate frozen-zone name (a tampered shadow copy could hide behind a last-wins match)',
91
+ });
92
+ }
93
+ const oldByName = new Map(oldZones.map(z => [z.name, z.inner]));
94
+ const newByName = new Map(newZones.map(z => [z.name, z.inner]));
95
+ // Preserve byte-identically by name (the seed compares inner content; marker
96
+ // text is fixed grammar, the name is the key).
97
+ for (const [name, inner] of oldByName) {
98
+ if (!newByName.has(name) || newByName.get(name) !== inner) {
99
+ throw new CliError(3, 'frozen_zone_violation', {
100
+ zone: name,
101
+ reason: 'replace_document must preserve frozen zones byte-identically',
102
+ });
103
+ }
104
+ }
105
+ // …and must not ADD a new marker-form zone (mint an author-invariant). The
106
+ // attribute-form add/remove is caught by the dataRwaFrozenSnapshot check below.
107
+ for (const name of newByName.keys()) {
108
+ if (!oldByName.has(name)) {
109
+ throw new CliError(3, 'frozen_zone_violation', {
110
+ zone: name,
111
+ reason: 'replace_document must not add a new frozen zone',
112
+ });
113
+ }
114
+ }
115
+ const a = dataRwaFrozenSnapshot(currentDoc);
116
+ const b = dataRwaFrozenSnapshot(newDoc);
117
+ if (a.length !== b.length || a.some((x, i) => x !== b[i])) {
118
+ throw new CliError(3, 'frozen_zone_violation', {
119
+ form: 'attribute',
120
+ reason: 'replace_document must preserve data-rwa-frozen elements byte-identically',
121
+ });
122
+ }
123
+ // Reserved HTML id: the escape hatch must not inject id="rwa-doc-mount" (it
124
+ // would shadow/hijack the runtime mount). Parser-free mirror of the seed's
125
+ // findReservedIdViolation (querySelector('#rwa-doc-mount')).
126
+ if (/\bid\s*=\s*["']?rwa-doc-mount(?=["'\s/>]|$)/i.test(newDoc)) {
127
+ throw new CliError(3, 'reserved_id_used', { id: 'rwa-doc-mount' });
128
+ }
129
+ // #5 opt-in (rwa-id-strict): the escape hatch must not lose an existing
130
+ // data-rwa-id when the container declares <meta name="rwa-id-strict">.
131
+ if (/<meta\s+name\s*=\s*["']?rwa-id-strict\b/i.test(currentDoc)) {
132
+ const ids = (s) => new Set([...s.matchAll(/\sdata-rwa-id\s*=\s*(?:"([^"]*)"|'([^']*)')/g)].map((m) => (m[1] != null ? m[1] : m[2])));
133
+ const after = ids(newDoc);
134
+ for (const id of ids(currentDoc)) if (!after.has(id)) throw new CliError(3, 'rwa_id_stripped', { id });
135
+ }
136
+ }
137
+
138
+ // String.prototype.isWellFormed (Node 22+) — false for an unpaired UTF-16
139
+ // surrogate. Mirror of the seed's isWellFormed lone-surrogate guard.
140
+ const isWellFormedStr = (s) => typeof s !== 'string' || typeof s.isWellFormed !== 'function' || s.isWellFormed();
141
+
142
+ function validateEnvelope(env) {
143
+ if (typeof env !== 'object' || env === null || Array.isArray(env)) {
144
+ throw new CliError(3, 'not_an_object');
145
+ }
146
+ const hasEdits = 'edits' in env;
147
+ const hasOps = 'ops' in env;
148
+ const hasDoc = 'doc' in env;
149
+ const count = (hasEdits ? 1 : 0) + (hasOps ? 1 : 0) + (hasDoc ? 1 : 0);
150
+ if (count === 0) throw new CliError(3, 'unknown_shape');
151
+ if (count > 1) throw new CliError(3, 'ambiguous_envelope');
152
+ if (typeof env.version !== 'string' || env.version.length === 0) {
153
+ throw new CliError(3, 'missing_version');
154
+ }
155
+ if (hasEdits && env.version !== 'rwa-edit/1') {
156
+ throw new CliError(3, 'version_mismatch', { expected: 'rwa-edit/1', got: env.version });
157
+ }
158
+ if (hasOps && env.version !== 'rwa-edit-dsl/1') {
159
+ throw new CliError(3, 'version_mismatch', { expected: 'rwa-edit-dsl/1', got: env.version });
160
+ }
161
+ if (hasDoc && env.version !== 'rwa-edit/1') {
162
+ throw new CliError(3, 'version_mismatch', { expected: 'rwa-edit/1', got: env.version });
163
+ }
164
+ // `'doc' in env` is true even when env.doc is undefined — without this type
165
+ // check `replaceInlineDoc(fileText, undefined)` would silently write an
166
+ // empty body (canonLF(undefined) → ''). Use `malformed_envelope` to match
167
+ // the bootstrap's replaceDocument shape-check (seeds/rewritable.html
168
+ // §replaceDocument, line ~2913).
169
+ if (hasDoc && typeof env.doc !== 'string') {
170
+ throw new CliError(3, 'malformed_envelope', { reason: 'doc must be a string' });
171
+ }
172
+ if (hasDoc && (typeof env.reason !== 'string' || env.reason.length === 0)) {
173
+ throw new CliError(3, 'missing_reason');
174
+ }
175
+ // Lone-surrogate guard (mirror seed isWellFormed): an unpaired UTF-16 surrogate
176
+ // in doc/reason corrupts the durable file on encode.
177
+ if (hasDoc && (!isWellFormedStr(env.doc) || !isWellFormedStr(env.reason))) {
178
+ throw new CliError(3, 'malformed_envelope', { reason: 'lone_surrogate' });
179
+ }
180
+ return hasEdits ? 'apply_edits' : hasOps ? 'apply_dsl_plan' : 'replace_document';
181
+ }
182
+
183
+ /**
184
+ * Apply a tool-envelope to a rewritable .html on disk.
185
+ *
186
+ * @param {string} filePath — absolute or relative path to the target .html
187
+ * @param {object} envelope — apply_edits / apply_dsl_plan / replace_document envelope
188
+ * @param {object} [opts]
189
+ * @param {boolean} [opts.virtualImages] — the envelope speaks rwa-asset token
190
+ * form (rwa-edit-spec.md §19): the agent saw the VIRTUAL doc, so apply on the
191
+ * virtual form and expand tokens back before the file write. Hash-keyed
192
+ * tokens make the map re-derivable from the doc bytes — no map threading.
193
+ * Raw paths (piped envelope / --plan) leave this unset: real bytes, plus the
194
+ * fail-loud guard against introducing a NEW token with no bytes behind it.
195
+ * @returns {Promise<{exitCode: 0}>}
196
+ * @throws {CliError} on any validation, compile, or apply failure
197
+ */
198
+ export async function applyPlan(filePath, envelope, opts = {}) {
199
+ // 1. Read the file. Surfacing not_found before envelope validation matches
200
+ // the user's mental model: file errors first, then plan errors.
201
+ let fileText;
202
+ try {
203
+ fileText = await readFile(filePath, 'utf8');
204
+ } catch (e) {
205
+ if (e && e.code === 'ENOENT') throw new CliError(2, 'not_found', { path: filePath });
206
+ // EACCES, EISDIR, EMFILE, etc. — "not_found" would mislead the user.
207
+ throw new CliError(2, 'read_error', {
208
+ path: filePath,
209
+ errno: e && e.code,
210
+ message: e && e.message,
211
+ });
212
+ }
213
+
214
+ // 2. Extract INLINE_DOC body. A plain-text or non-rewritable target throws.
215
+ let currentDoc;
216
+ try {
217
+ currentDoc = extractInlineDoc(fileText);
218
+ } catch (_e) {
219
+ throw new CliError(2, 'not_a_rewritable', { path: filePath });
220
+ }
221
+
222
+ // 3. Validate envelope shape + version.
223
+ const shape = validateEnvelope(envelope);
224
+
225
+ // images-v1 (rwa-edit-spec.md §19) — two virtualization modes:
226
+ // • opts.virtualImages: the envelope is ALREADY token-form (agent/CLI path).
227
+ // Virtualize the stored doc so token anchors match, apply, expand.
228
+ // • opts.virtualizeEnvelope: the envelope is EXPANDED (real data: URIs) —
229
+ // the hosted /modify relay. Seed a map from the stored doc, then tokenize
230
+ // the incoming envelope into the SAME map (registering new image bytes),
231
+ // so the apply runs on the token form (caps = text budget) and expansion
232
+ // resolves both existing and new images.
233
+ // Either way all guards below (frozen zones, snapshots) run virtual-vs-virtual.
234
+ const vimg = (opts.virtualImages || opts.virtualizeEnvelope) ? virtualizeImages(currentDoc) : null;
235
+ if (opts.virtualizeEnvelope) envelope = mapEnvelopeImages(envelope, vimg.assets);
236
+ const workDoc = vimg ? vimg.doc : currentDoc;
237
+
238
+ // 4. Compute the new doc per shape.
239
+ let newDoc;
240
+ if (shape === 'replace_document') {
241
+ newDoc = envelope.doc;
242
+ assertFrozenPreserved(workDoc, newDoc);
243
+ } else if (shape === 'apply_dsl_plan') {
244
+ let compiled;
245
+ try {
246
+ compiled = compileDslPlan(envelope, workDoc);
247
+ } catch (e) {
248
+ // Pass e.op through: DslCompileError carries the offending DSL op, which
249
+ // --json consumers need to point at the failing step (was dropped).
250
+ throw new CliError(3, e.code || 'dsl_compile_error', { message: e.message, op: e.op });
251
+ }
252
+ if (compiled.tool === 'replace_document') {
253
+ newDoc = compiled.envelope.doc;
254
+ assertFrozenPreserved(workDoc, newDoc); // the DSL escape op must not bypass frozen zones either
255
+ } else {
256
+ try {
257
+ newDoc = applyEdits(workDoc, compiled.envelope.edits);
258
+ } catch (e) {
259
+ if (e instanceof RwaEditError) {
260
+ throw new CliError(3, e.code, { editIndex: e.editIndex, ...e.context });
261
+ }
262
+ throw e;
263
+ }
264
+ }
265
+ } else {
266
+ try {
267
+ newDoc = applyEdits(workDoc, envelope.edits);
268
+ } catch (e) {
269
+ if (e instanceof RwaEditError) {
270
+ throw new CliError(3, e.code, { editIndex: e.editIndex, ...e.context });
271
+ }
272
+ throw e;
273
+ }
274
+ }
275
+
276
+ // images-v1: expand token-form output to real bytes (an invented token
277
+ // rejects here, before anything is written); raw paths get the fail-loud
278
+ // guard against minting a NEW token with no bytes behind it.
279
+ try {
280
+ if (vimg) newDoc = expandImages(newDoc, vimg.assets, vimg.orphans);
281
+ else assertNoNewAssetTokens(currentDoc, newDoc);
282
+ } catch (e) {
283
+ if (e instanceof RwaEditError) throw new CliError(3, e.code, { ...e.context });
284
+ throw e;
285
+ }
286
+
287
+ // Expanded-size guard (image paths only): MAX_DOC measured the VIRTUAL form,
288
+ // so cap the REAL doc here — the DoS bound that the per-edit byte cap no
289
+ // longer provides once image bytes are tokenized. Mirrors the GUI's 10 MB
290
+ // container budget; authoritative server-side on the hosted /modify path.
291
+ if (vimg && newDoc.length > MAX_DOC_EXPANDED) {
292
+ throw new CliError(3, 'target_size_exceeded', { expanded: true, length: newDoc.length, cap: MAX_DOC_EXPANDED });
293
+ }
294
+
295
+ // 5. Splice the new doc back into the bootstrap and write atomically (temp +
296
+ // fsync + rename(2)); the temp is removed on any failure. See ./atomic-write.mjs.
297
+ const newFileText = replaceInlineDoc(fileText, newDoc);
298
+ await atomicWrite(filePath, newFileText);
299
+ return { exitCode: 0 };
300
+ }