@oh-my-pi/hashline 15.5.4

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/src/patcher.ts ADDED
@@ -0,0 +1,360 @@
1
+ /**
2
+ * High-level patch orchestrator. Reads each section's target file via the
3
+ * configured {@link Filesystem}, strips BOM and normalizes line endings,
4
+ * validates the section file hash (with optional {@link Recovery}), applies
5
+ * the edits, and writes the result back through the same {@link Filesystem}.
6
+ *
7
+ * Two layers:
8
+ *
9
+ * - {@link Patcher.apply} — high-level, all-or-nothing. Preflights every
10
+ * section in memory before any write hits disk, then commits in order.
11
+ * - {@link Patcher.prepare} / {@link Patcher.commit} — granular primitives
12
+ * for callers that need per-section control (e.g. batched LSP flush,
13
+ * custom interleaving). `prepare` performs all the read-side work,
14
+ * validates the section file hash (with recovery), and applies the
15
+ * edits in memory. `commit` writes the prepared result and records a
16
+ * fresh snapshot.
17
+ *
18
+ * Because `prepare` already runs the full apply, a multi-section batch is
19
+ * naturally all-or-nothing: by the time any `commit` runs, every section
20
+ * has been validated.
21
+ *
22
+ * The patcher itself is stateless across calls; reuse one instance per
23
+ * filesystem configuration.
24
+ */
25
+ import { applyEdits } from "./apply";
26
+ import { computeFileHash, formatHashlineHeader, HL_FILE_HASH_SEP, HL_FILE_PREFIX } from "./format";
27
+ import type { Filesystem, WriteResult } from "./fs";
28
+ import { isNotFound } from "./fs";
29
+ import type { Patch, PatchSection } from "./input";
30
+ import { MismatchError } from "./mismatch";
31
+ import { detectLineEnding, type LineEnding, normalizeToLF, restoreLineEndings, stripBom } from "./normalize";
32
+ import { Recovery, type RecoveryResult } from "./recovery";
33
+ import type { SnapshotStore } from "./snapshots";
34
+ import type { ApplyOptions, ApplyResult, Edit } from "./types";
35
+
36
+ export interface PatcherOptions {
37
+ /** Storage backend used for all reads and writes. */
38
+ fs: Filesystem;
39
+ /**
40
+ * Optional snapshot store that enables stale-hash recovery. When set, a
41
+ * section with a stale hash tries a 3-way merge against a cached
42
+ * snapshot before the apply fails with {@link MismatchError}.
43
+ */
44
+ snapshots?: SnapshotStore;
45
+ /**
46
+ * Optional default {@link ApplyOptions} forwarded to every section.
47
+ * Per-call overrides win on a key-by-key basis.
48
+ */
49
+ applyOptions?: ApplyOptions;
50
+ }
51
+
52
+ /** Per-section result returned by {@link Patcher.apply} / {@link Patcher.commit}. */
53
+ export interface PatchSectionResult {
54
+ /** Section path (as authored, after cwd-resolution at parse time). */
55
+ path: string;
56
+ /** Filesystem-canonical key for this section (e.g. absolute path). */
57
+ canonicalPath: string;
58
+ /** `"noop"` when the apply produced no change; otherwise `"create"` / `"update"`. */
59
+ op: "create" | "update" | "noop";
60
+ /** Pre-edit text (LF-normalized, BOM-stripped). */
61
+ before: string;
62
+ /** Post-edit text (LF-normalized, BOM-stripped). For `"noop"` equals `before`. */
63
+ after: string;
64
+ /** Same text as `after` but with the original BOM and line ending restored. */
65
+ persisted: string;
66
+ /** Final text that the {@link Filesystem} actually wrote (may differ if the FS transformed it). */
67
+ written: string;
68
+ /** 4-hex hash of `after`. Use to anchor follow-up edits. */
69
+ fileHash: string;
70
+ /** Hashline section header (`¶path#hash`) of the post-edit content. */
71
+ header: string;
72
+ /** 1-indexed first changed line in `after`, or `undefined` for noops. */
73
+ firstChangedLine?: number;
74
+ /** Warnings collected by the parser, applier, and (optionally) recovery. */
75
+ warnings: string[];
76
+ }
77
+
78
+ export interface PatcherApplyResult {
79
+ sections: PatchSectionResult[];
80
+ }
81
+
82
+ /**
83
+ * Opaque token returned by {@link Patcher.prepare}. Carries the section, the
84
+ * raw file content read off disk, and the in-memory apply result.
85
+ * {@link Patcher.commit} just writes the {@link PreparedSection.applyResult}.
86
+ */
87
+ export class PreparedSection {
88
+ /** @internal */
89
+ constructor(
90
+ readonly section: PatchSection,
91
+ readonly canonicalPath: string,
92
+ readonly exists: boolean,
93
+ readonly rawContent: string,
94
+ readonly bom: string,
95
+ readonly lineEnding: LineEnding,
96
+ readonly normalized: string,
97
+ readonly applyResult: ApplyResult,
98
+ readonly parseWarnings: readonly string[],
99
+ ) {}
100
+
101
+ /** Convenience: returns true when the apply produced no change. */
102
+ get isNoop(): boolean {
103
+ return this.applyResult.text === this.normalized;
104
+ }
105
+ }
106
+
107
+ function hasAnchorScopedEdit(edits: readonly Edit[]): boolean {
108
+ return edits.some(edit => {
109
+ if (edit.kind === "delete") return true;
110
+ return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor";
111
+ });
112
+ }
113
+
114
+ function assertSectionHashAllowed(sectionPath: string, fileHash: string | undefined, edits: readonly Edit[]): void {
115
+ if (fileHash !== undefined || !hasAnchorScopedEdit(edits)) return;
116
+ throw new Error(
117
+ `Missing hashline file hash for anchored edit to ${sectionPath}; use \`${HL_FILE_PREFIX}${sectionPath}${HL_FILE_HASH_SEP}hash\` from your latest read.`,
118
+ );
119
+ }
120
+
121
+ function recoveryToApplyResult(result: RecoveryResult): ApplyResult {
122
+ return {
123
+ text: result.text,
124
+ firstChangedLine: result.firstChangedLine,
125
+ warnings: result.warnings,
126
+ };
127
+ }
128
+
129
+ function mergeWarnings(...sources: ReadonlyArray<readonly string[] | undefined>): string[] {
130
+ const out: string[] = [];
131
+ for (const source of sources) {
132
+ if (!source) continue;
133
+ for (const warning of source) out.push(warning);
134
+ }
135
+ return out;
136
+ }
137
+
138
+ function assertUniqueCanonicalPaths(prepared: readonly PreparedSection[]): void {
139
+ const seen = new Map<string, string>();
140
+ for (const entry of prepared) {
141
+ const previous = seen.get(entry.canonicalPath);
142
+ if (previous !== undefined) {
143
+ throw new Error(
144
+ `Multiple hashline sections resolve to the same file (${previous} and ${entry.section.path}). Merge their ops under one header before applying.`,
145
+ );
146
+ }
147
+ seen.set(entry.canonicalPath, entry.section.path);
148
+ }
149
+ }
150
+
151
+ /**
152
+ * High-level patcher. Wires a {@link Filesystem} and an optional
153
+ * {@link SnapshotStore} together with the parsing + applying core.
154
+ *
155
+ * Construct once per FS configuration; reuse across patches.
156
+ */
157
+ export class Patcher {
158
+ readonly fs: Filesystem;
159
+ readonly snapshots: SnapshotStore | undefined;
160
+ readonly recovery: Recovery | undefined;
161
+ readonly applyOptions: ApplyOptions;
162
+
163
+ constructor(options: PatcherOptions) {
164
+ this.fs = options.fs;
165
+ this.snapshots = options.snapshots;
166
+ this.recovery = options.snapshots ? new Recovery(options.snapshots) : undefined;
167
+ this.applyOptions = options.applyOptions ?? {};
168
+ }
169
+
170
+ /**
171
+ * Apply every section in `patch`. `prepare` runs the full apply for each
172
+ * section in memory before any write hits the filesystem, so a
173
+ * multi-section batch is naturally all-or-nothing. Returns one
174
+ * {@link PatchSectionResult} per section in the original patch order.
175
+ */
176
+ async apply(patch: Patch, options: ApplyOptions = {}): Promise<PatcherApplyResult> {
177
+ const merged: ApplyOptions = { ...this.applyOptions, ...options };
178
+
179
+ // Single-section fast path.
180
+ if (patch.sections.length === 1) {
181
+ const prepared = await this.prepare(patch.sections[0], merged);
182
+ return { sections: [await this.commit(prepared)] };
183
+ }
184
+
185
+ // Prepare every section first so any failure (stale hash, missing
186
+ // file, parse error, in-memory no-op) surfaces before any write.
187
+ const prepared: PreparedSection[] = [];
188
+ for (const section of patch.sections) prepared.push(await this.prepare(section, merged));
189
+ assertUniqueCanonicalPaths(prepared);
190
+ for (const entry of prepared) {
191
+ if (entry.isNoop) {
192
+ throw new Error(`Edits to ${entry.section.path} resulted in no changes being made.`);
193
+ }
194
+ }
195
+
196
+ const results: PatchSectionResult[] = [];
197
+ for (const entry of prepared) results.push(await this.commit(entry));
198
+ return { sections: results };
199
+ }
200
+
201
+ /**
202
+ * Run the preflight pass only: read, parse, validate, apply-in-memory.
203
+ * No writes hit the filesystem. Use for CI checks and dry runs.
204
+ */
205
+ async preflight(patch: Patch, options: ApplyOptions = {}): Promise<void> {
206
+ const merged: ApplyOptions = { ...this.applyOptions, ...options };
207
+ const prepared: PreparedSection[] = [];
208
+ for (const section of patch.sections) prepared.push(await this.prepare(section, merged));
209
+ assertUniqueCanonicalPaths(prepared);
210
+ for (const entry of prepared) {
211
+ if (entry.isNoop) {
212
+ throw new Error(`Edits to ${entry.section.path} resulted in no changes being made.`);
213
+ }
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Read a section's target file, parse the section, validate the file
219
+ * hash (with recovery), and apply the edits in memory. Returns a
220
+ * {@link PreparedSection} which can be fed to {@link commit} to land
221
+ * the result on the filesystem.
222
+ *
223
+ * Throws on parse error, missing-file-for-anchored-edit, or unrecovered
224
+ * hash mismatch ({@link MismatchError}).
225
+ */
226
+ async prepare(section: PatchSection, options: ApplyOptions = {}): Promise<PreparedSection> {
227
+ const applyOptions: ApplyOptions = { ...this.applyOptions, ...options };
228
+ const { edits, warnings: parseWarnings } = section.parse();
229
+ assertSectionHashAllowed(section.path, section.fileHash, edits);
230
+
231
+ const canonicalPath = this.fs.canonicalPath(section.path);
232
+ await this.fs.preflightWrite(section.path);
233
+ const { exists, rawContent } = await this.#tryRead(section.path);
234
+ if (!exists && hasAnchorScopedEdit(edits)) {
235
+ throw new Error(`File not found: ${section.path}`);
236
+ }
237
+
238
+ const { bom, text } = stripBom(rawContent);
239
+ const lineEnding = detectLineEnding(text);
240
+ const normalized = normalizeToLF(text);
241
+
242
+ const applyResult = this.#applyWithRecovery({
243
+ section,
244
+ canonicalPath,
245
+ exists,
246
+ normalized,
247
+ edits,
248
+ applyOptions,
249
+ });
250
+
251
+ return new PreparedSection(
252
+ section,
253
+ canonicalPath,
254
+ exists,
255
+ rawContent,
256
+ bom,
257
+ lineEnding,
258
+ normalized,
259
+ applyResult,
260
+ parseWarnings,
261
+ );
262
+ }
263
+
264
+ /**
265
+ * Commit a previously {@link prepare}d section to the filesystem.
266
+ * Restores line endings and BOM, writes via the {@link Filesystem}, and
267
+ * records a fresh snapshot in the {@link SnapshotStore} (when
268
+ * configured) keyed by the filesystem-canonical path.
269
+ */
270
+ async commit(prepared: PreparedSection): Promise<PatchSectionResult> {
271
+ const { section, normalized, bom, lineEnding, parseWarnings, exists, applyResult, canonicalPath } = prepared;
272
+ const after = applyResult.text;
273
+ const warnings = mergeWarnings(parseWarnings, applyResult.warnings);
274
+
275
+ if (after === normalized) {
276
+ const hash = computeFileHash(normalized);
277
+ return {
278
+ path: section.path,
279
+ canonicalPath,
280
+ op: "noop",
281
+ before: normalized,
282
+ after: normalized,
283
+ persisted: prepared.rawContent,
284
+ written: prepared.rawContent,
285
+ fileHash: hash,
286
+ header: formatHashlineHeader(section.path, hash),
287
+ warnings,
288
+ };
289
+ }
290
+
291
+ const persisted = bom + restoreLineEndings(after, lineEnding);
292
+ const write: WriteResult = await this.fs.writeText(section.path, persisted);
293
+ const fileHash = computeFileHash(after);
294
+ const op = exists ? "update" : "create";
295
+
296
+ if (this.snapshots) {
297
+ this.snapshots.recordContiguous(canonicalPath, 1, after.split("\n"), {
298
+ fullText: after,
299
+ fileHash,
300
+ });
301
+ }
302
+
303
+ return {
304
+ path: section.path,
305
+ canonicalPath,
306
+ op,
307
+ before: normalized,
308
+ after,
309
+ persisted,
310
+ written: write.text,
311
+ fileHash,
312
+ header: formatHashlineHeader(section.path, fileHash),
313
+ firstChangedLine: applyResult.firstChangedLine,
314
+ warnings,
315
+ };
316
+ }
317
+
318
+ async #tryRead(path: string): Promise<{ exists: boolean; rawContent: string }> {
319
+ try {
320
+ const content = await this.fs.readText(path);
321
+ return { exists: true, rawContent: content };
322
+ } catch (error) {
323
+ if (isNotFound(error)) return { exists: false, rawContent: "" };
324
+ throw error;
325
+ }
326
+ }
327
+
328
+ #applyWithRecovery(args: {
329
+ section: PatchSection;
330
+ canonicalPath: string;
331
+ exists: boolean;
332
+ normalized: string;
333
+ edits: readonly Edit[];
334
+ applyOptions: ApplyOptions;
335
+ }): ApplyResult {
336
+ const { section, canonicalPath, exists, normalized, edits, applyOptions } = args;
337
+ const expected = exists ? section.fileHash : undefined;
338
+ if (expected === undefined) return applyEdits(normalized, [...edits], applyOptions);
339
+
340
+ const currentHash = computeFileHash(normalized);
341
+ if (currentHash === expected) return applyEdits(normalized, [...edits], applyOptions);
342
+
343
+ const recovered = this.recovery?.tryRecover({
344
+ path: canonicalPath,
345
+ currentText: normalized,
346
+ fileHash: expected,
347
+ edits,
348
+ options: applyOptions,
349
+ });
350
+ if (recovered) return recoveryToApplyResult(recovered);
351
+
352
+ throw new MismatchError({
353
+ path: section.path,
354
+ expectedFileHash: expected,
355
+ actualFileHash: currentHash,
356
+ fileLines: normalized.split("\n"),
357
+ anchorLines: section.collectAnchorLines(),
358
+ });
359
+ }
360
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * When a hashline payload is authored against `read`/`search` output, each
3
+ * line is prefixed with either a hashline-mode line number (`123:`) or, for
4
+ * diff-style echoes, a leading `+`. These helpers detect that and recover
5
+ * the raw text. Two strip modes are exposed:
6
+ *
7
+ * - {@link stripNewLinePrefixes} — opportunistic: strips when the input
8
+ * clearly carries hashline or diff prefixes, leaves it alone otherwise.
9
+ * - {@link stripHashlinePrefixes} — strict: only strips when every non-empty
10
+ * content line is hashline-prefixed.
11
+ *
12
+ * These run *before* the tokenizer; they exist because hashline mode is the
13
+ * common case for echoed file content, and erroneously echoed prefixes will
14
+ * otherwise turn every content line into a (malformed) op.
15
+ */
16
+
17
+ const HL_PREFIX_RE = /^\s*(?:>>>|>>)?\s*(?:[+*-]\s*)?\d+:/;
18
+ const HL_PREFIX_PLUS_RE = /^\s*(?:>>>|>>)?\s*\+\s*\d+:/;
19
+ const HL_HEADER_RE = /^\s*¶\S+#[0-9a-f]{4}\s*$/;
20
+ const DIFF_PLUS_RE = /^[+](?![+])/;
21
+ const READ_TRUNCATION_NOTICE_RE = /^\[(?:Showing lines \d+-\d+ of \d+|\d+ more lines? in (?:file|\S+))\b.*\bUse :L?\d+/;
22
+
23
+ function stripLeadingHashlinePrefixes(line: string): string {
24
+ let result = line;
25
+ let previous: string;
26
+ do {
27
+ previous = result;
28
+ result = result.replace(HL_PREFIX_RE, "");
29
+ } while (result !== previous);
30
+ return result;
31
+ }
32
+
33
+ interface LinePrefixStats {
34
+ nonEmpty: number;
35
+ headerCount: number;
36
+ hashPrefixCount: number;
37
+ diffPlusHashPrefixCount: number;
38
+ diffPlusCount: number;
39
+ truncationNoticeCount: number;
40
+ }
41
+
42
+ function collectLinePrefixStats(lines: string[]): LinePrefixStats {
43
+ const stats: LinePrefixStats = {
44
+ nonEmpty: 0,
45
+ headerCount: 0,
46
+ hashPrefixCount: 0,
47
+ diffPlusHashPrefixCount: 0,
48
+ diffPlusCount: 0,
49
+ truncationNoticeCount: 0,
50
+ };
51
+
52
+ for (const line of lines) {
53
+ if (line.length === 0) continue;
54
+ if (READ_TRUNCATION_NOTICE_RE.test(line)) {
55
+ stats.truncationNoticeCount++;
56
+ continue;
57
+ }
58
+ if (HL_HEADER_RE.test(line)) {
59
+ stats.nonEmpty++;
60
+ stats.headerCount++;
61
+ continue;
62
+ }
63
+ stats.nonEmpty++;
64
+ if (HL_PREFIX_RE.test(line)) stats.hashPrefixCount++;
65
+ if (HL_PREFIX_PLUS_RE.test(line)) stats.diffPlusHashPrefixCount++;
66
+ if (DIFF_PLUS_RE.test(line)) stats.diffPlusCount++;
67
+ }
68
+ return stats;
69
+ }
70
+
71
+ /**
72
+ * Strip whichever prefix scheme the lines appear to be carrying:
73
+ * - hashline line-number prefixes (`123:`) when every content line has one
74
+ * - leading `+` (diff style) when at least half the lines have one
75
+ * - mixed `+<n>:` form when present
76
+ *
77
+ * Returns the lines untouched if no scheme is recognized.
78
+ */
79
+ export function stripNewLinePrefixes(lines: string[]): string[] {
80
+ const stats = collectLinePrefixStats(lines);
81
+ if (stats.nonEmpty === 0) return lines;
82
+
83
+ const contentLineCount = stats.nonEmpty - stats.headerCount;
84
+ const stripHash = contentLineCount > 0 && stats.hashPrefixCount === contentLineCount;
85
+ const stripPlus =
86
+ !stripHash &&
87
+ stats.diffPlusHashPrefixCount === 0 &&
88
+ stats.diffPlusCount > 0 &&
89
+ stats.diffPlusCount >= stats.nonEmpty * 0.5;
90
+
91
+ if (!stripHash && !stripPlus && stats.diffPlusHashPrefixCount === 0) return lines;
92
+
93
+ return lines
94
+ .filter(line => !READ_TRUNCATION_NOTICE_RE.test(line) && !(stripHash && HL_HEADER_RE.test(line)))
95
+ .map(line => {
96
+ if (stripHash) return stripLeadingHashlinePrefixes(line);
97
+ if (stripPlus) return line.replace(DIFF_PLUS_RE, "");
98
+ if (stats.diffPlusHashPrefixCount > 0 && HL_PREFIX_PLUS_RE.test(line)) {
99
+ return line.replace(HL_PREFIX_RE, "");
100
+ }
101
+ return line;
102
+ });
103
+ }
104
+
105
+ /**
106
+ * Strict variant: strip hashline prefixes only when every content line is
107
+ * hashline-prefixed. Returns the lines unchanged otherwise.
108
+ */
109
+ export function stripHashlinePrefixes(lines: string[]): string[] {
110
+ const stats = collectLinePrefixStats(lines);
111
+ if (stats.nonEmpty === 0) return lines;
112
+ const contentLineCount = stats.nonEmpty - stats.headerCount;
113
+ if (contentLineCount === 0 || stats.hashPrefixCount !== contentLineCount) return lines;
114
+ return lines
115
+ .filter(line => !READ_TRUNCATION_NOTICE_RE.test(line) && !HL_HEADER_RE.test(line))
116
+ .map(line => stripLeadingHashlinePrefixes(line));
117
+ }
118
+
119
+ /**
120
+ * Normalize line payloads by stripping read/search line prefixes. `null` /
121
+ * `undefined` yield `[]`; a single multiline string is split on `\n`.
122
+ */
123
+ export function hashlineParseText(edit: string[] | string | null | undefined): string[] {
124
+ if (edit == null) return [];
125
+ if (typeof edit === "string") {
126
+ const trimmed = edit.endsWith("\n") ? edit.slice(0, -1) : edit;
127
+ edit = trimmed.replaceAll("\r", "").split("\n");
128
+ }
129
+ return stripNewLinePrefixes(edit);
130
+ }
package/src/prompt.md ADDED
@@ -0,0 +1,83 @@
1
+ Your patch language is a compact, line-anchored edit format.
2
+
3
+ <payload>
4
+ Patch payload is a series of hunks: `¶PATH#HASH` header followed by any number of operations. `HASH` should be copied as is from read/search. Missing? Re-`read`.
5
+ - No context rows, no gutters.
6
+ - NEVER restate unchanged lines "for context".
7
+ - Op lines carry NO payload. Every payload line lives on its own row and MUST start with `+`; that delimiter is stripped.
8
+ - Payload indentation is literal.
9
+ </payload>
10
+
11
+ <ops>
12
+ LINE↑ insert before (or BOF↑)
13
+ LINE↓ insert after (or EOF↓)
14
+ A-B: replace A..B (or A: == A..A)
15
+ A-B! delete A..B (or A! == A..A)
16
+ +PAYLOAD payload line for the preceding op
17
+ </ops>
18
+
19
+ <rules>
20
+ - **Payload is only what's NEW.** `:` replaces inside; `↑`/`↓` add at anchor. NEVER repeat anchor lines or neighbors.
21
+ - **Use `+` for a blank payload line; use `++text` to write a line starting with `+text`.**
22
+ - **Inserts add ONLY the rows you list.** The file's existing newlines around the anchor stay. NEVER tack a trailing `+` blank "for spacing" — it writes a literal blank line into the file, doubling whatever is already there.
23
+ - **A bare `LINE↑`/`LINE↓` with no payload still inserts ONE blank line.** Not a no-op. Omit the op if you want nothing there.
24
+ - **Go small.** Add → `↑`/`↓`; replace → `:`; delete → `!`.
25
+ - **Line numbers are frozen references to what you have seen.** Later ops in the same hunk still use original line numbers; they do NOT shift as earlier ops apply.
26
+ </rules>
27
+
28
+ <common-failures>
29
+ - **NEVER replay past your range.** Stop before B+1; extend B if needed.
30
+ - **Read lines look like replace ops.** `84:content` = "make line 84 content" — and inline content is rejected. Don't echo read-style rows.
31
+ - **NEVER fabricate file hashes.** Missing? Re-`read`.
32
+ </common-failures>
33
+
34
+ <example>
35
+ ```a.ts#1a2b
36
+ 1:const X = "a";
37
+ 2:
38
+ 3:export function f() { return X; }
39
+ 4:f();
40
+ ```
41
+
42
+ # replace one line, insert after, delete
43
+ ```
44
+ ¶a.ts#1a2b
45
+ 1:
46
+ +const X = "b";
47
+ +export const Y = X;
48
+ 1↓
49
+ +const Z = Y;
50
+ 4!
51
+ ```
52
+ </example>
53
+
54
+ <anti-pattern>
55
+ # WRONG — inline payload after the sigil is rejected
56
+ 1:const X = "b";
57
+ 1↓const Z = Y;
58
+ 1-2:const X = "b";
59
+ +export const Y = X;
60
+ # WRONG — INSERT used to change a line (old line survives)
61
+ 1↓
62
+ +const X = "b";
63
+ # WRONG — echoing read-style lines as context before the real op
64
+ 1:const X = "a";
65
+ 1-2:
66
+ +const X = "b";
67
+ +export const Y = X;
68
+ # WRONG — trailing `+` blank writes a literal empty line; the new blank lands right next to the orig blank at line 2, doubling it
69
+ 1↓
70
+ +const Y = X;
71
+ +
72
+ # WRONG — `2↓` still anchors at PRE-EDIT line 2 (frozen), NOT at the line just inserted by `1↓`. Both inserts land at their own anchors, giving three consecutive blanks (new from `1↓`, orig blank line 2, new from `2↓`).
73
+ 1↓
74
+ 2↓
75
+ </anti-pattern>
76
+
77
+ <critical>
78
+ - One op per range, ever.
79
+ - Pick op precisely. Update: `:`, add: `↑`/`↓`, remove: `!`.
80
+ - Payload always lives on its own `+`-prefixed line — never inline with the op.
81
+ - Payload is only what's NEW; never repeat anchor lines or neighbors.
82
+ - Anchor exactly; don't anchor neighbors.
83
+ </critical>