dsh-hashline-edit 0.1.0 → 0.2.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.
Files changed (131) hide show
  1. package/README.md +72 -70
  2. package/cordis.patch.yml +1 -1
  3. package/lib/index.js +4494 -4813
  4. package/lib/types/dsh/contracts.d.ts +69 -6
  5. package/lib/types/dsh/fs.d.ts +38 -3
  6. package/lib/types/dsh/guidance.d.ts +5 -5
  7. package/lib/types/dsh/hide.d.ts +78 -0
  8. package/lib/types/dsh/host-compat.d.ts +7 -78
  9. package/lib/types/dsh/index.d.ts +17 -7
  10. package/lib/types/dsh/metrics/events.d.ts +8 -1
  11. package/lib/types/dsh/metrics/instrument.d.ts +20 -14
  12. package/lib/types/dsh/metrics/observer.d.ts +28 -0
  13. package/lib/types/dsh/sandbox.d.ts +2 -2
  14. package/lib/types/dsh/shell-hook.d.ts +17 -8
  15. package/lib/types/dsh/shell-view.d.ts +65 -3
  16. package/lib/types/dsh/tools/apply-patch.d.ts +38 -0
  17. package/lib/types/dsh/tools/context.d.ts +19 -1
  18. package/lib/types/dsh/tools/output.d.ts +32 -4
  19. package/lib/types/dsh/tools/read.d.ts +12 -15
  20. package/lib/types/dsh/tools/undo.d.ts +5 -0
  21. package/lib/types/dsh/write-hook.d.ts +8 -22
  22. package/lib/types/src/apply-patch/commit.d.ts +49 -0
  23. package/lib/types/src/apply-patch/grammar.d.ts +29 -0
  24. package/lib/types/src/apply-patch/lifecycle.d.ts +32 -0
  25. package/lib/types/src/apply-patch/plan.d.ts +50 -0
  26. package/lib/types/src/apply-patch/render.d.ts +51 -0
  27. package/lib/types/src/apply-patch/resolve.d.ts +129 -0
  28. package/lib/types/src/apply-patch/tool.d.ts +54 -0
  29. package/lib/types/src/apply-patch/types.d.ts +362 -0
  30. package/lib/types/src/bash-view-banner.d.ts +20 -0
  31. package/lib/types/src/domain-errors.d.ts +16 -2
  32. package/lib/types/src/edit-undo.d.ts +11 -2
  33. package/lib/types/src/file-content/detection.d.ts +17 -0
  34. package/lib/types/src/file-content/index.d.ts +2 -2
  35. package/lib/types/src/file-content/preview.d.ts +1 -1
  36. package/lib/types/src/fs-lifecycle.d.ts +44 -0
  37. package/lib/types/src/hashline/resolve.d.ts +6 -0
  38. package/lib/types/src/host-utils.d.ts +63 -0
  39. package/lib/types/src/mutation-engine/batch-span-gate.d.ts +6 -0
  40. package/lib/types/src/mutation-engine/edit-request.d.ts +34 -0
  41. package/lib/types/src/mutation-engine/pipeline.d.ts +18 -3
  42. package/lib/types/src/mutation-engine/types.d.ts +50 -58
  43. package/lib/types/src/read-expansion/grammar-manifest.d.ts +4 -2
  44. package/lib/types/src/read-expansion/java-decoration.d.ts +21 -0
  45. package/lib/types/src/read-expansion/span-expansion.d.ts +6 -2
  46. package/lib/types/src/read-expansion/span-kind.d.ts +3 -2
  47. package/lib/types/src/read-expansion/vue-decoration.d.ts +14 -0
  48. package/lib/types/src/served-session/serve-render.d.ts +9 -3
  49. package/lib/types/src/undo-store.d.ts +17 -1
  50. package/package.json +10 -22
  51. package/prompts/apply-patch-guidelines.md +10 -0
  52. package/prompts/apply-patch.md +1 -0
  53. package/prompts/read-guidelines.md +5 -5
  54. package/prompts/read.md +1 -1
  55. package/prompts/undo-last-edit-guidelines.md +2 -2
  56. package/prompts/undo-last-edit-snippet.md +1 -1
  57. package/prompts/undo-last-edit.md +1 -1
  58. package/src/apply-patch/commit.ts +190 -0
  59. package/src/apply-patch/grammar.ts +620 -0
  60. package/src/apply-patch/index.ts +17 -0
  61. package/src/apply-patch/lifecycle.ts +195 -0
  62. package/src/apply-patch/plan.ts +800 -0
  63. package/src/apply-patch/render.ts +137 -0
  64. package/src/apply-patch/resolve.ts +470 -0
  65. package/src/apply-patch/tool.ts +257 -0
  66. package/src/apply-patch/types.ts +387 -0
  67. package/src/cut-repair.ts +77 -11
  68. package/src/domain-errors.ts +58 -10
  69. package/src/edit-undo.ts +379 -25
  70. package/src/file-content/detection.ts +34 -0
  71. package/src/file-content/index.ts +2 -2
  72. package/src/file-content/preview.ts +1 -1
  73. package/src/file-content/served-header.ts +1 -1
  74. package/src/fs-lifecycle.ts +201 -0
  75. package/src/hash-store.ts +18 -1
  76. package/src/hashline/lease-resolve.ts +14 -8
  77. package/src/hashline/resolve.ts +30 -27
  78. package/src/host-utils.ts +224 -0
  79. package/src/mutation-engine/batch-span-gate.ts +17 -4
  80. package/src/mutation-engine/edit-request.ts +39 -0
  81. package/src/mutation-engine/pipeline.ts +535 -63
  82. package/src/mutation-engine/types.ts +49 -65
  83. package/src/mutation-queue.ts +1 -1
  84. package/src/prompts.ts +0 -28
  85. package/src/read-expansion/ast-provider.ts +4 -0
  86. package/src/read-expansion/grammar-manifest.ts +21 -2
  87. package/src/read-expansion/java-decoration.ts +34 -0
  88. package/src/read-expansion/span-expansion.ts +6 -2
  89. package/src/read-expansion/span-kind.ts +3 -1
  90. package/src/read-expansion/vue-decoration.ts +43 -0
  91. package/src/served-session/serve-render.ts +5 -5
  92. package/src/undo-store.ts +74 -5
  93. package/lib/types/dsh/encoding-bridge.d.ts +0 -25
  94. package/lib/types/dsh/encoding.d.ts +0 -144
  95. package/lib/types/dsh/tools/edit.d.ts +0 -10
  96. package/lib/types/src/edit-tool.d.ts +0 -41
  97. package/lib/types/src/mutation-engine/engine.d.ts +0 -31
  98. package/lib/types/src/payload-contract.d.ts +0 -124
  99. package/prompts/edit-guidelines.md +0 -10
  100. package/prompts/edit-snippet.md +0 -1
  101. package/prompts/edit.md +0 -1
  102. package/src/bash-classifier.ts +0 -1626
  103. package/src/bash-conversion/decide.ts +0 -812
  104. package/src/bash-display-transform.ts +0 -50
  105. package/src/bash-multifile-config.ts +0 -239
  106. package/src/bash-view-config.ts +0 -158
  107. package/src/edit-normalize.ts +0 -8
  108. package/src/edit-render.ts +0 -161
  109. package/src/edit-tool.ts +0 -116
  110. package/src/edit.ts +0 -136
  111. package/src/integrations/pi-lens/command.ts +0 -712
  112. package/src/integrations/pi-lens/config.ts +0 -148
  113. package/src/integrations/pi-lens/io-bridge-adapter.ts +0 -234
  114. package/src/integrations/pi-lens/io-bridge.ts +0 -112
  115. package/src/integrations/pi-lens/settings-overlay.ts +0 -272
  116. package/src/lifecycle-hooks/index.ts +0 -1992
  117. package/src/lifecycle-hooks/types.ts +0 -64
  118. package/src/mutated-files.ts +0 -75
  119. package/src/mutation-engine/engine.ts +0 -173
  120. package/src/mutation-engine/index.ts +0 -17
  121. package/src/payload-contract.ts +0 -637
  122. package/src/preview-controller.ts +0 -84
  123. package/src/read.ts +0 -865
  124. package/src/search-hook.ts +0 -41
  125. package/src/store-config.ts +0 -73
  126. package/src/store-janitor-warning.ts +0 -169
  127. package/src/store-janitor.ts +0 -539
  128. package/src/store-kernel/settings.ts +0 -233
  129. package/src/store.ts +0 -154
  130. package/src/tui-presenter.ts +0 -134
  131. package/src/write-hook.ts +0 -130
@@ -1,637 +0,0 @@
1
- import { Type } from "typebox";
2
- import { EDITS_MAX_ITEMS } from "./constants.js";
3
- import { DomainError } from "./domain-errors.js";
4
- import { HASH_LEN } from "./hashline/alphabet.js";
5
- import type { EditPlacement } from "./hashline/resolve.js";
6
- import { rejectUnknownFields } from "./utils.js";
7
-
8
- const normalizedEdit = Symbol("normalizedEdit");
9
-
10
- /**
11
- * The `text_ref` payload: two inclusive bare anchors bounding a served span, an optional serving
12
- * `file`, and a REQUIRED `mode` — `"copy"` re-inserts the span bytes, `"cut"` additionally retires
13
- * them. Both modes apply to this file AND to another served file (ticket-04b): a foreign-source
14
- * `"cut"` commits the insert and the source retirement as one correlated transaction (ADR-0028).
15
- */
16
- type SpanRef = {
17
- anchor_from: string;
18
- anchor_to: string;
19
- file?: string;
20
- mode: "copy" | "cut";
21
- };
22
-
23
- /**
24
- * Wire item (ticket-04): exactly one payload per item — `text` or `text_ref` — over the inclusive
25
- * anchor pair, with optional `at` placement. There is no `op` field and no verbs: the key set IS
26
- * the vocabulary (ADR-0007 line 26; content comes from the copy of another served file, which the
27
- * wire expresses as `text_ref.file`).
28
- */
29
- export type EditItem = {
30
- anchor_from: string;
31
- anchor_to: string;
32
- at?: "in-place" | "before" | "after";
33
- text?: string;
34
- text_ref?: SpanRef;
35
- };
36
-
37
- type EditMode = "general" | "literal";
38
-
39
- /**
40
- * Internal normalized vocabulary owned by the admission boundary (ticket-01, renamed ticket-04).
41
- * The wire never names these shapes: `normReq` builds them once from the validated payload and
42
- * everything downstream consumes the union — the engine switches on `payload.kind` exhaustively.
43
- */
44
- export type Placement = "in-place" | "before" | "after";
45
-
46
- // WHY: (§9.12) the wire `at` union and the engine seam's placement union are one vocabulary by
47
- // WHY: contract, not by copy: this compile-time mutual-assignability assertion fails
48
- // WHY: `pnpm run typecheck` the moment the two unions drift apart.
49
- /**
50
- * @public Intentional surface (knip): the tuple type is the wire/engine
51
- * vocabulary check, so the binding is never imported — its reason to exist
52
- * is the typecheck failure it produces when the unions drift.
53
- */
54
- export const _placementVocabularyAgreement: [
55
- Placement extends EditPlacement ? true : never,
56
- EditPlacement extends Placement ? true : never,
57
- ] = [true, true];
58
-
59
- /** The anchor pair an item targets — placement and payload are relative to its resolved span. */
60
- type AnchorSpan = { anchor_from: string; anchor_to: string };
61
-
62
- export type DesiredContent =
63
- | { kind: "literal"; text: string }
64
- | { kind: "reference"; span: SpanRef; mode: "copy" | "cut" }
65
- | { kind: "empty" };
66
-
67
- export type NormalizedEditItem = { target: AnchorSpan; at: Placement; payload: DesiredContent };
68
-
69
- export type NormalizedEditRequest = {
70
- file: string;
71
- edits: NormalizedEditItem[];
72
- mode?: EditMode;
73
- };
74
-
75
- /** The wire-folded request (the admission analyzer's view): canonical keys, pre-union. */
76
- type PreAdmissionRequest = {
77
- file: string;
78
- edits: EditItem[];
79
- mode?: EditMode;
80
- };
81
- type NormalizedPayload = NormalizedEditRequest & {
82
- readonly [normalizedEdit]: true;
83
- };
84
-
85
- type RawPayload = string | number | boolean | null | undefined | Record<string, unknown>;
86
-
87
- export type NormReqResult = NormalizedPayload | RawPayload;
88
-
89
- function isRec(value: unknown): value is Record<string, unknown> {
90
- return typeof value === "object" && value !== null && !Array.isArray(value);
91
- }
92
-
93
- function isNormalizedEdit(input: unknown): input is Record<string, unknown> {
94
- return isRec(input) && (input as Record<string | symbol, unknown>)[normalizedEdit] === true;
95
- }
96
-
97
- export const anchorFromSchema = Type.String({
98
- description: `Bare ${HASH_LEN}-char hash anchor of the first range line (inclusive)`,
99
- });
100
-
101
- export const anchorToSchema = Type.String({
102
- description: `Bare ${HASH_LEN}-char hash anchor of the last range line (inclusive)`,
103
- });
104
-
105
- export const editFileSchema = Type.String({
106
- minLength: 1,
107
- description: "Path to the text file to edit (a file, never a directory)",
108
- });
109
-
110
- const editAtSchema = Type.Union(
111
- [Type.Literal("in-place"), Type.Literal("before"), Type.Literal("after")],
112
- {
113
- description:
114
- 'Placement of the payload relative to the resolved target span: "in-place" (default) rewrites the span, "before"/"after" insert at its boundary and require a single-line resolved target',
115
- },
116
- );
117
-
118
- const textRefSchema = Type.Object(
119
- {
120
- anchor_from: anchorFromSchema,
121
- anchor_to: anchorToSchema,
122
- file: Type.Optional(
123
- Type.Union([
124
- Type.String({
125
- description:
126
- 'Served file the span is read from; another served file supports both "copy" and "cut" like this file does',
127
- }),
128
- Type.Null(),
129
- ]),
130
- ),
131
- mode: Type.Union([Type.Literal("copy"), Type.Literal("cut")], {
132
- description:
133
- '"copy" re-inserts the referenced span bytes; "cut" additionally retires them, in the serving file, foreign-source included',
134
- }),
135
- },
136
- { additionalProperties: false },
137
- );
138
-
139
- export const editModeSchema = Type.Union([Type.Literal("general"), Type.Literal("literal")], {
140
- description:
141
- 'How to treat bytes reproducing served rows: "general" refuses them, "literal" declares them as intended file content',
142
- });
143
-
144
- // WHY: structurally permissive-but-typed (ticket-04): exactly-one-payload and the finite key sets
145
- // WHY: are owned by the admission analyzer (`itemFrom` + `assertReq`), not by the schema — the
146
- // WHY: schema's `additionalProperties: false` still refuses unknown keys at the pi seam.
147
- export const editItemSchema = Type.Object(
148
- {
149
- anchor_from: anchorFromSchema,
150
- anchor_to: anchorToSchema,
151
- // WHY: (binary-selection-remediation Option C, ADR-0036) every optional property is declared
152
- // WHY: NULLABLE so the served schema agrees with admission: `null` reads as absent
153
- // WHY: (`foldAbsentSlots`). TypeBox's natural union-optional form serialises as
154
- // WHY: `anyOf: [<schema>, {"type":"null"}]`, the same spelling pi's strict compiler emits
155
- // WHY: (`makeJsonSchemaNodeStrict`). Required properties stay non-nullable on purpose.
156
- at: Type.Optional(Type.Union([editAtSchema, Type.Null()])),
157
- text: Type.Optional(
158
- Type.Union([
159
- Type.String({ description: 'Bare file content for the range; use "" to delete' }),
160
- Type.Null(),
161
- ]),
162
- ),
163
- text_ref: Type.Optional(Type.Union([textRefSchema, Type.Null()])),
164
- },
165
- {
166
- // WHY: (ticket-67, CORRECTION-1) no oneOf/anyOf union here by trade-off: available today, but it would foreclose a future strict constrained-decoding opt-in; admission-time XOR already enforces the rule.
167
- additionalProperties: false,
168
- description:
169
- 'Exactly one payload per item: supply "text" or "text_ref", never both (a null in an optional field reads as absent; every anchor and "text_ref.mode" are required).',
170
- },
171
- );
172
-
173
- export const editToolSchema = Type.Object(
174
- {
175
- file: editFileSchema,
176
- edits: Type.Array(editItemSchema, {
177
- description: "Ordered list of edit items",
178
- minItems: 1,
179
- maxItems: EDITS_MAX_ITEMS,
180
- }),
181
- mode: Type.Optional(Type.Union([editModeSchema, Type.Null()])),
182
- },
183
- { additionalProperties: false },
184
- );
185
-
186
- const ITEM_SHAPE =
187
- "an item is exactly { anchor_from, anchor_to, text[, at] } or { anchor_from, anchor_to, text_ref[, at] }";
188
-
189
- const EDIT_PAYLOAD_HINT =
190
- "Edit must be called with exactly one payload per item. Use the canonical payload " +
191
- '{"file": file, "edits": [{ "anchor_from": anchor_from, "anchor_to": anchor_to, "text": text }, ...], "mode"?: "general" | "literal"}: ' +
192
- '"file" is the text file to edit (a non-empty string, never a directory); each item names two inclusive ' +
193
- `bare-${HASH_LEN}-char anchors and exactly one payload — "text" (bare replacement content; an empty string deletes the ` +
194
- 'range) or "text_ref" ({ anchor_from, anchor_to, file?, mode (required): "copy" | "cut" } — the served span\'s bytes, ' +
195
- '"file" may name another served file, where both modes apply too); optional "at" is "in-place" (default), ' +
196
- '"before" or "after" (single-line resolved target only); optional "mode" is "general" (default, reproduced ' +
197
- 'served rows are refused) or "literal" (declared literal content).';
198
- export const EDIT_DESCRIPTION = `Edit a range of lines in a text file via \`edit\`: \`{ "file": file, "edits": [{ "anchor_from": a, "anchor_to": b, "text": text }, ...] }\` (one file per call). Use rows this session served for the file: \`read\`, a diff, or a bash view. \`anchor_from\`/\`anchor_to\` are bare ${HASH_LEN}-char HASH anchors. Copy the ${HASH_LEN} chars before \`│\` in this file's served rows (lease (session, file, anchor)). One payload per item: \`text\` (\`\\n\` joins lines, \`""\` deletes) or \`text_ref\` \`{anchor_from, anchor_to, mode (required), file?}\`, the bytes of a served span (\`mode\` \`"copy"\` keeps it, \`"cut"\` removes it). \`file\` names another served file. \`at\`: "in-place" (default), "before", "after". A null optional field reads as absent. \`[MODEL] [E_*]\` is your retry instruction. \`[W_*]\` is informational.`;
199
- export const EDIT_SNIPPET = `Edit a file range via \`edit\`: \`{"file":file,"edits":[{"anchor_from":a,"anchor_to":b,"text":text}]}\` — anchors are bare ${HASH_LEN}-char hashes copied from served \`HASH│content\` from rows this file served this session. One payload per item: \`text\` is bare content (\`""\` deletes) or \`text_ref\` writes a served span (\`"copy"\` keeps source, \`"cut"\` removes it from this file or \`file\`). \`at\`: "in-place" (default), "before", "after". Chain from diff anchors.`;
200
- export const EDIT_GUIDELINES: string[] = [
201
- `edit: an \`anchor\` is the bare ${HASH_LEN}-char hash (for example "wUpX"). A \`HASH│content\` line (for example \`wUpX│ pass\`) is a served row, and \`│\` separates the two. Copy only the ${HASH_LEN} chars before \`│\` into \`anchor_from\`/\`anchor_to\`.`,
202
- "edit: give each item two anchors and exactly one payload. `file` is the text file. One item is one edit. Several items batch to that one file. Choose one payload field: `text` or `text_ref`. `at` is optional. A null in an optional field (`text`, `text_ref`, `at`, the top-level `mode`, `text_ref.file`) reads as absent. A null in a required field (`file`, `edits`, any anchor, `text_ref.mode`) is refused.",
203
- "edit: `anchor_from`/`anchor_to` bound an inclusive range. In-place replaces both boundary lines. `before`/`after` insert at the boundary instead. Out-of-band writes (bash, scripts, formatters) bypass serve recording. When a served line changes, the refusal serves the current rows. Copy fresh anchors from it.",
204
- 'edit: `text` is plain file content. Join lines with `\\n`. Use `""` to delete the range. The tool refuses a line that copies a served hash echo. `text` is verbatim, so include the indentation you want.',
205
- 'edit: place the payload with `at`. `"in-place"` (default) rewrites the range. `"before"`/`"after"` insert at its boundary and need a single-line resolved target. `text: ""` with before/after writes nothing (noop).',
206
- 'edit: `text_ref` `{anchor_from, anchor_to, mode}` writes the bytes of a served span into the target. `mode: "copy"` keeps the source. `mode: "cut"` removes those lines from the source. When `file` names another served file, both modes apply to that file\'s rows. A `cut` then removes the span there in the same call. `mode` is required. Prefer `text_ref` to reproduce served bytes exactly or to move them. Use `text` for content you author.',
207
- "edit: an anchor is bound to the file that served it. The lease is (session, file, anchor). Copy `anchor_from`/`anchor_to` only from the served rows of the file the payload names. That is this file by default, or `text_ref.file` when it names another file.",
208
- "edit: after success the diff serves fresh `HASH│content` rows: context rows and `+` rows. Copy new anchors from the diff for your next call. Verify that the diff matches your intended mutation.",
209
- "edit: a `[MODEL] [W_*]` line in `content` is informational. The mutation was applied. A `[MODEL] [E_*]` line is your retry instruction or a rejection: follow it from the message alone. A `[MODEL]` line that presents `Current range (fresh read):` shows the current rows. Decide from them. A dimmed `[USER]` line in `details` is human info.",
210
- "edit: batch independent ranges in one `edits` array. Inside it, every edit validates BEFORE the first rename, and any failure there writes nothing. A foreign `cut` writes its files in a fixed order, and a defeated rollback restores captured bytes or is repaired on the next run.",
211
- ];
212
-
213
- function _getPayloadPromptFragments(): {
214
- description: string;
215
- snippet: string;
216
- guidelines: string[];
217
- hint: string;
218
- } {
219
- return {
220
- description: EDIT_DESCRIPTION,
221
- snippet: EDIT_SNIPPET,
222
- guidelines: [...EDIT_GUIDELINES],
223
- hint: EDIT_PAYLOAD_HINT,
224
- };
225
- }
226
-
227
- function describeReceived(input: unknown): string {
228
- if (input === undefined) return "Received no arguments.";
229
- if (input === null) return "Received null.";
230
- if (typeof input === "string") return `Received a bare string (${JSON.stringify(input)}).`;
231
- const json = JSON.stringify(input);
232
- if (typeof json === "string" && json.length > 600) {
233
- const truncated = json.slice(0, 600);
234
- return `Received: ${truncated}… (+truncated, full file+edits in tool input)`;
235
- }
236
- return `Received: ${json}`;
237
- }
238
-
239
- function quoted(keys: string[]): string {
240
- return keys.map((key) => `"${key}"`).join(", ");
241
- }
242
-
243
- function sanitizePath(value: unknown): string | null {
244
- if (typeof value !== "string") return null;
245
- let s = value.trim();
246
- // WHY: Gemma 4 bleed: model may wrap path in <|>, │, |, quotes, or backticks due to │ confusion — see via https://github.com/Rianico/pi-better-edit/issues/55
247
- // WHY: Strip leading/trailing wrappers iteratively — model may re-wrap after slice, see sanitizePath
248
- let changed = true;
249
- while (changed) {
250
- changed = false;
251
- if (s.startsWith("<|>") && s.endsWith("<|>") && s.length > 6) {
252
- s = s.slice(3, -3).trim();
253
- changed = true;
254
- }
255
- if (s.startsWith("│") && s.endsWith("│") && s.length > 2) {
256
- s = s.slice(1, -1).trim();
257
- changed = true;
258
- }
259
- if (s.startsWith("|") && s.endsWith("|") && s.length > 2) {
260
- s = s.slice(1, -1).trim();
261
- changed = true;
262
- }
263
- if (
264
- (s.startsWith('"') && s.endsWith('"')) ||
265
- (s.startsWith("'") && s.endsWith("'")) ||
266
- (s.startsWith("`") && s.endsWith("`"))
267
- ) {
268
- s = s.slice(1, -1).trim();
269
- changed = true;
270
- }
271
- if (s.startsWith("<|>")) {
272
- s = s.slice(3).trim();
273
- changed = true;
274
- }
275
- if (s.endsWith("<|>")) {
276
- s = s.slice(0, -3).trim();
277
- changed = true;
278
- }
279
- }
280
- return s.length > 0 ? s : null;
281
- }
282
-
283
- // WHY: (ticket-04 §3) the finite key-set gate: an item is legal only when its key set is EXACTLY
284
- // WHY: one of S1 {anchor_from, anchor_to, text}, S2 = S1 + at, S3 {anchor_from, anchor_to,
285
- // WHY: text_ref}, S4 = S3 + at — set EQUALITY, never superset, so a legacy key, an `op` field,
286
- // WHY: both payloads or neither payload all refuse with the offending keys named.
287
- const ITEM_KEY_SETS: readonly (readonly string[])[] = [
288
- ["anchor_from", "anchor_to", "text"],
289
- ["anchor_from", "anchor_to", "at", "text"],
290
- ["anchor_from", "anchor_to", "text_ref"],
291
- ["anchor_from", "anchor_to", "at", "text_ref"],
292
- ];
293
-
294
- function keySetEquals(keys: Set<string>, shape: readonly string[]): boolean {
295
- if (keys.size !== shape.length) return false;
296
- return shape.every((key) => keys.has(key));
297
- }
298
-
299
- function isLegalItemKeySet(keys: Set<string>): boolean {
300
- return ITEM_KEY_SETS.some((shape) => keySetEquals(keys, shape));
301
- }
302
-
303
- // WHY: bound to the union (§9.12): a list element outside `Placement` fails `pnpm run typecheck`.
304
- const AT_SPELLINGS = ["in-place", "before", "after"] as const satisfies readonly Placement[];
305
-
306
- // WHY: (ticket-67) strict structured-output harnesses spell "absent" as an explicit `null`
307
- // WHY: (every property required). Key existence alone then lies: `{ text: "…", text_ref: null }`
308
- // WHY: was refused as carrying both. `null`/`undefined` in the four optional slots (`text`,
309
- // WHY: `text_ref`, `at`, root `mode`) reads as ABSENT wherever presence is decided — one
310
- // WHY: predicate, so the gate, the validators and the normalizers cannot disagree.
311
- // WHY: (ticket-75) the nested reference's optional `file` is the same slot one level down: a
312
- // WHY: harness that must spell an omitted optional property emits `"file": null`, and reading it
313
- // WHY: as present refused a call no retry could satisfy — the #67 class, one object deeper.
314
- function isAbsentValue(value: unknown): value is null | undefined {
315
- return value === null || value === undefined;
316
- }
317
-
318
- // WHY: (ticket-75) ONE fold site for the declaration and for the stored item: the item's optional
319
- // WHY: slots and the nested optional `file` fold together, so `analyzeItem` validates exactly the
320
- // WHY: shape `analyzeRequest` stores and nothing downstream can see an absent value as present.
321
- function foldAbsentSlots(item: Record<string, unknown>): Record<string, unknown> {
322
- const folded: Record<string, unknown> = { ...item };
323
- for (const key of ["text", "text_ref", "at"] as const) {
324
- if (isAbsentValue(folded[key])) delete folded[key];
325
- }
326
- if (isRec(folded.text_ref)) {
327
- const reference: Record<string, unknown> = { ...folded.text_ref };
328
- if (isAbsentValue(reference.file)) delete reference.file;
329
- folded.text_ref = reference;
330
- }
331
- return folded;
332
- }
333
- function analyzeItem(value: unknown, index: number): string | undefined {
334
- if (!isRec(value)) {
335
- return `edit[${index}] must be an object: ${ITEM_SHAPE}.`;
336
- }
337
- // WHY: (ticket-75) `value` arrives already folded by `foldAbsentSlots`: an absent optional slot
338
- // WHY: is gone, nested `text_ref.file` included, so this key set IS the declaration the gate
339
- // WHY: reads and no second fold site can disagree with the one that stored the item.
340
- const keys = new Set(Object.keys(value));
341
- const hasText = keys.has("text");
342
- const hasRef = keys.has("text_ref");
343
- if (!isLegalItemKeySet(keys)) {
344
- if (hasText && hasRef) {
345
- return (
346
- `edit[${index}] carries both "text" and "text_ref" fields: choose exactly one payload field per item (${ITEM_SHAPE}). ` +
347
- `Choice A (literal content): Keep "text" and delete "text_ref" from the JSON object. ` +
348
- `Choice B (copy/cut served span): keep "text_ref" and delete "text" from the JSON object.`
349
- );
350
- }
351
- // WHY: (§9.3) an illegal key set is often also MISSING a required key; the unsupported-field
352
- // WHY: list alone renders blank there, so the refusal must name the missing keys too.
353
- const payloadKeys = new Set(["anchor_from", "anchor_to", "at", "text", "text_ref"]);
354
- const offending = [...keys].filter((key) => !payloadKeys.has(key));
355
- const missing = ["anchor_from", "anchor_to"].filter((key) => !keys.has(key));
356
- const clauses: string[] = [];
357
- if (offending.length > 0) clauses.push(`unsupported field(s) ${quoted(offending)}`);
358
- if (missing.length > 0) clauses.push(`missing required field(s) ${quoted(missing)}`);
359
- if (!hasText && !hasRef) {
360
- const named = clauses.length > 0 ? `${clauses.join(";")} — ` : "";
361
- return (
362
- `edit[${index}] carries no payload (neither "text" nor "text_ref" fields): ${named}choose exactly one payload field per item (${ITEM_SHAPE}) — ` +
363
- `supply the "text" field (authored content) OR the "text_ref" field (served span).`
364
- );
365
- }
366
- return `edit[${index}] has ${clauses.join(";")} (${ITEM_SHAPE}).`;
367
- }
368
- const { anchor_from, anchor_to, at, text, text_ref } = value;
369
- if (typeof anchor_from !== "string" || typeof anchor_to !== "string") {
370
- return `edit[${index}] "anchor_from"/"anchor_to" must be bare ${HASH_LEN}-char hash anchor strings copied from served output (before │): both anchors are required, so null is not a value here.`;
371
- }
372
- if (!isAbsentValue(at)) {
373
- if (at === "in_place") {
374
- return `edit[${index}] "at" was "in_place" — the canonical spelling is "in-place": "at" must be "in-place", "before" or "after".`;
375
- }
376
- if (typeof at !== "string" || !(AT_SPELLINGS as readonly string[]).includes(at)) {
377
- return `edit[${index}] "at" must be "in-place", "before" or "after" when present.`;
378
- }
379
- }
380
- if (hasText) {
381
- if (typeof text !== "string") {
382
- return `edit[${index}] "text" must be a string with \\n line separators, not an array. An empty string deletes the range.`;
383
- }
384
- return undefined;
385
- }
386
- if (!isRec(text_ref)) {
387
- return `edit[${index}] "text_ref" must be an object { anchor_from, anchor_to, file?, mode }.`;
388
- }
389
- const refKeys = new Set(Object.keys(text_ref));
390
- if (!refKeys.has("mode")) {
391
- return `edit[${index}] "text_ref" requires "mode": "copy" or "cut".`;
392
- }
393
- const refBase = new Set(["anchor_from", "anchor_to", "file", "mode"]);
394
- const refOffending = [...refKeys].filter((key) => !refBase.has(key));
395
- if (refOffending.length > 0) {
396
- return `edit[${index}] "text_ref" has unsupported field(s) ${quoted(refOffending)}; it is { anchor_from, anchor_to, file?, mode }.`;
397
- }
398
- const { mode } = text_ref;
399
- if (mode !== "copy" && mode !== "cut") {
400
- return `edit[${index}] "text_ref" "mode" must be "copy" or "cut": "mode" is required, so null is not a value here.`;
401
- }
402
- const refFile = text_ref.file;
403
- if ("file" in text_ref && typeof refFile !== "string") {
404
- return `edit[${index}] "text_ref" "file" must be a string naming the served file to read from — omit "file" to reference this file.`;
405
- }
406
- // WHY: (§9.2) an empty "file" was admitted and only failed deep in the loader as
407
- // WHY: `[E_UNSUPPORTED_FILE] Path is a directory: .` — the field-level refusal belongs here.
408
- if (refFile === "") {
409
- return `edit[${index}] "text_ref" "file" must name a served file to read from — an empty string is not a path (omit "file" to reference this file).`;
410
- }
411
- if (typeof text_ref.anchor_from !== "string" || typeof text_ref.anchor_to !== "string") {
412
- return `edit[${index}] "text_ref" "anchor_from"/"anchor_to" must be bare ${HASH_LEN}-char hash anchor strings copied from the served output of the file they name: both anchors are required, so null is not a value here.`;
413
- }
414
- return undefined;
415
- }
416
-
417
- // WHY: (remediation-2 B3 → ticket-04b) there was previously a second, LEXICAL "same file" test
418
- // WHY: here that refused foreign `mode: "cut"` at admission. One definition owns the question —
419
- // WHY: the engine's realpath classification (`sameResolvedPath`, mutation-engine/pipeline.ts) —
420
- // WHY: and admission stays a pure shape check: a foreign `cut` is ADMITTED and committed as one
421
- // WHY: correlated transaction (`runCutTransaction`, ADR-0028), witnessed through the entry point
422
- // WHY: by `edit.wire-contract.test.ts` and `edit.foreign-cut.test.ts`. Path identity on the real
423
- // WHY: filesystem belongs to the engine seam.
424
- /**
425
- * The single admission analyzer shared by `editRequestFrom` (normReq), `prepareEditArguments` and
426
- * `assertReq` (ticket-04): every entry point rejects the same inputs with the same message, so the
427
- * tool seam and the engine seam cannot drift apart.
428
- */
429
- type Admission = { ok: true; request: PreAdmissionRequest } | { ok: false; message: string };
430
-
431
- function analyzeRequest(input: unknown): Admission {
432
- if (!isRec(input)) {
433
- return { ok: false, message: `${EDIT_PAYLOAD_HINT} ${describeReceived(input)}` };
434
- }
435
- const rec = input as Record<string, unknown>;
436
- const rootBase = new Set(["file", "edits", "mode"]);
437
- const rootOffending = Object.keys(rec).filter((key) => !rootBase.has(key));
438
- if (rootOffending.length > 0) {
439
- return {
440
- ok: false,
441
- message:
442
- `Edit request has unsupported field(s) ${quoted(rootOffending)}; it is exactly ` +
443
- `{ file, edits: [{ anchor_from, anchor_to, text | text_ref[, at] }, ...], mode?: "general" | "literal" }. ` +
444
- describeReceived(input),
445
- };
446
- }
447
- let mode: EditMode | undefined;
448
- if ("mode" in rec && !isAbsentValue(rec.mode)) {
449
- if (rec.mode !== "general" && rec.mode !== "literal") {
450
- return {
451
- ok: false,
452
- message: `Edit request "mode" must be "general" or "literal" (absent means "general"). ${EDIT_PAYLOAD_HINT}`,
453
- };
454
- }
455
- mode = rec.mode;
456
- }
457
- if (!("file" in rec)) {
458
- return {
459
- ok: false,
460
- message: `Edit request requires "file" (the text file to edit). ${EDIT_PAYLOAD_HINT}`,
461
- };
462
- }
463
- const sanitized = sanitizePath(rec.file);
464
- if (sanitized === null) {
465
- return {
466
- ok: false,
467
- message: `Edit request "file" must be a non-empty string path to a text file. ${EDIT_PAYLOAD_HINT}`,
468
- };
469
- }
470
- if (!("edits" in rec) || !Array.isArray(rec.edits) || rec.edits.length === 0) {
471
- return {
472
- ok: false,
473
- message: `Edit request requires a non-empty "edits" array. ${EDIT_PAYLOAD_HINT}`,
474
- };
475
- }
476
- const file = sanitized;
477
- const items = rec.edits as unknown[];
478
- const failures: string[] = [];
479
- const edited: EditItem[] = [];
480
- for (let index = 0; index < items.length; index++) {
481
- // WHY: (ticket-75) fold BEFORE validation, so the item the gate reads is the item stored.
482
- const raw = items[index];
483
- const item = isRec(raw) ? foldAbsentSlots(raw) : raw;
484
- const refusal = analyzeItem(item, index);
485
- if (refusal !== undefined) {
486
- failures.push(refusal);
487
- continue;
488
- }
489
- // SAFETY: `analyzeItem` just admitted this raw record — every field it reads validated, so the
490
- // SAFETY: item has the `EditItem` shape; TypeScript cannot narrow a `Record<string, unknown>` member.
491
- edited.push(item as unknown as EditItem);
492
- }
493
- if (failures.length > 0) {
494
- return { ok: false, message: `${failures.join(" ")} ${describeReceived(input)}` };
495
- }
496
- if (mode !== undefined) return { ok: true, request: { file, edits: edited, mode } };
497
- return { ok: true, request: { file, edits: edited } };
498
- }
499
-
500
- export function editRequestFrom(input: unknown): PreAdmissionRequest | undefined {
501
- const admitted = analyzeRequest(input);
502
- return admitted.ok ? admitted.request : undefined;
503
- }
504
-
505
- function normalizedItemFrom(item: EditItem): NormalizedEditItem {
506
- const at = item.at ?? "in-place";
507
- const payload: DesiredContent =
508
- item.text_ref !== undefined
509
- ? { kind: "reference", span: item.text_ref, mode: item.text_ref.mode }
510
- : item.text === ""
511
- ? { kind: "empty" }
512
- : { kind: "literal", text: item.text ?? "" };
513
- return {
514
- target: { anchor_from: item.anchor_from, anchor_to: item.anchor_to },
515
- at,
516
- payload,
517
- };
518
- }
519
-
520
- export function normReq(input: unknown): NormReqResult {
521
- const admitted = analyzeRequest(input);
522
- // SAFETY: input is unvalidated at admission — cast to NormReqResult preserves runtime value for caller validation, narrowed by analyzeRequest refusing invalid shapes
523
- if (!admitted.ok) return input as NormReqResult;
524
- const items = admitted.request.edits.map(normalizedItemFrom);
525
- const record: Record<string, unknown> & { file: string; edits: NormalizedEditItem[] } =
526
- admitted.request.mode !== undefined
527
- ? { file: admitted.request.file, edits: items, mode: admitted.request.mode }
528
- : { file: admitted.request.file, edits: items };
529
- Object.defineProperty(record, normalizedEdit, {
530
- value: true,
531
- enumerable: false,
532
- });
533
- return record;
534
- }
535
-
536
- export function prepareEditArguments(args: unknown): Record<string, unknown> {
537
- const admitted = analyzeRequest(args);
538
- if (admitted.ok) {
539
- // SAFETY: `analyzeRequest` built this value from the validated record and admitted it, so the
540
- // SAFETY: widening back to the record shape the schema accepts is sound; the caller consumes it as validated.
541
- return admitted.request as unknown as Record<string, unknown>;
542
- }
543
- throw new DomainError("E_BAD_PAYLOAD", { message: admitted.message });
544
- }
545
-
546
- export function getPreviewInput(args: unknown): { file: string; edits: EditItem[] } | null {
547
- const req = editRequestFrom(args);
548
- if (!req) return null;
549
- return req;
550
- }
551
-
552
- const ROOT_KS = new Set(["file", "edits", "mode"]);
553
-
554
- // WHY: bound to the union (§9.12): a list element outside `Placement` fails `pnpm run typecheck`.
555
- const PLACEMENTS = ["in-place", "before", "after"] as const satisfies readonly Placement[];
556
-
557
- function isPlacementValue(value: string): value is Placement {
558
- return (PLACEMENTS as readonly string[]).includes(value);
559
- }
560
-
561
- function isNormalizedEditItem(value: unknown): value is NormalizedEditItem {
562
- if (!isRec(value)) return false;
563
- const { target, at, payload } = value;
564
- if (!isRec(target)) return false;
565
- if (typeof target.anchor_from !== "string" || typeof target.anchor_to !== "string") return false;
566
- if (typeof at !== "string" || !isPlacementValue(at)) return false;
567
- if (!isRec(payload)) return false;
568
- if (payload.kind === "empty") return true;
569
- if (payload.kind === "literal") return typeof payload.text === "string";
570
- if (payload.kind === "reference") {
571
- return (
572
- isRec(payload.span) &&
573
- typeof payload.span.anchor_from === "string" &&
574
- typeof payload.span.anchor_to === "string" &&
575
- (payload.mode === "copy" || payload.mode === "cut")
576
- );
577
- }
578
- return false;
579
- }
580
-
581
- export function assertReq(request: unknown): asserts request is NormalizedEditRequest {
582
- if (!isNormalizedEdit(request)) {
583
- // WHY: a raw (un-normalized) request reaching assertReq must hit the SAME finite key-set gate
584
- // WHY: as `prepareEditArguments` — one analyzer, one refusal message, both entry points.
585
- const admitted = analyzeRequest(request);
586
- if (!admitted.ok) {
587
- throw new DomainError("E_BAD_PAYLOAD", { message: admitted.message });
588
- }
589
- throw new DomainError("E_BAD_PAYLOAD", {
590
- message:
591
- "Edit request reached assertReq without normReq: the engine consumes the normalized shape. " +
592
- EDIT_PAYLOAD_HINT,
593
- });
594
- }
595
-
596
- rejectUnknownFields(
597
- request,
598
- ROOT_KS,
599
- "Edit request",
600
- 'Pass "file" (the text file to edit), "edits", and optional "mode" ("general" | "literal").',
601
- );
602
-
603
- const modeValue = (request as Record<string, unknown>).mode;
604
- if (modeValue !== undefined && modeValue !== "general" && modeValue !== "literal") {
605
- throw new DomainError("E_BAD_PAYLOAD", {
606
- message:
607
- 'Edit request "mode" must be "general" or "literal" (absent means "general"). ' +
608
- EDIT_PAYLOAD_HINT,
609
- });
610
- }
611
-
612
- // WHY: the file was answered at admission (analyzeRequest); the narrowed type carries it here.
613
- if (!Array.isArray(request.edits) || request.edits.length === 0) {
614
- throw new DomainError("E_BAD_PAYLOAD", {
615
- message: 'Edit request requires a non-empty "edits" array.',
616
- });
617
- }
618
-
619
- for (let index = 0; index < request.edits.length; index++) {
620
- const item = request.edits[index];
621
- if (!isNormalizedEditItem(item)) {
622
- throw new DomainError("E_BAD_PAYLOAD", {
623
- message: `Edit request edits[${index}] must be { target, at, payload } with exactly one payload: text content, a served-span reference with mode "copy" or "cut", or a deletion (no content).`,
624
- });
625
- }
626
- // WHY: (ticket-04 item (i), remediation-2 B4) the wire folds `"text": ""` into the deletion
627
- // WHY: payload, so this guard is a defense for direct `assertReq` callers only; the
628
- // WHY: enforcement point for the engine seam is the parse guard in
629
- // WHY: `mutation-engine/pipeline.ts` (`parseEdits`, same message), which `execute()` cannot
630
- // WHY: bypass. Naming the wire field `"text"` keeps the model-actionable wording.
631
- if (item.payload.kind === "literal" && item.payload.text === "") {
632
- throw new DomainError("E_BAD_PAYLOAD", {
633
- message: `Edit request edits[${index}] "text" must carry at least one line; "text": "" is the deletion payload. Nothing was written.`,
634
- });
635
- }
636
- }
637
- }