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.
- package/README.md +263 -5
- package/bin/rwa.mjs +1033 -6
- package/package.json +7 -4
- package/seeds/rewritable.html +6989 -156
- package/src/agent-loop.mjs +155 -0
- package/src/apply-edits.mjs +664 -0
- package/src/atomic-write.mjs +38 -0
- package/src/backend.mjs +43 -0
- package/src/clone-extract.mjs +249 -0
- package/src/clone.mjs +161 -0
- package/src/commands.mjs +207 -11
- package/src/create.mjs +256 -0
- package/src/doc.mjs +69 -0
- package/src/dsl-compiler.mjs +357 -0
- package/src/edit.mjs +300 -0
- package/src/fetch-page.mjs +346 -0
- package/src/host.mjs +126 -0
- package/src/identity.mjs +257 -0
- package/src/import-claude.mjs +360 -0
- package/src/import-vision.mjs +156 -0
- package/src/import.mjs +357 -8
- package/src/ls.mjs +105 -0
- package/src/publish-site.mjs +85 -0
- package/src/publish.mjs +98 -0
- package/src/seed-extract.mjs +40 -0
- package/src/seed.mjs +1399 -6
- package/src/self-contained.mjs +115 -0
- package/src/skill-manifest.mjs +227 -0
- package/src/skin.mjs +350 -0
- package/src/skins.mjs +274 -0
- package/src/template.mjs +109 -0
|
@@ -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, '&').replace(/"/g, '"');
|
|
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
|
+
}
|