Skip to content

Commit 2b0d7c5

Browse files
committed
feat: improve command failure diagnostics
Include command index, source line, operation, path, and category in script errors, while escaping control characters and newlines in diagnostics. Add coverage for syntax, selection, file, and edit failures. Update the README, interface specification, and agent instructions with current in-memory editing semantics and workspace-root requirements for translation and patch application.
1 parent ddcc88c commit 2b0d7c5

7 files changed

Lines changed: 271 additions & 38 deletions

File tree

AGENT_INSTRUCTIONS.md

Lines changed: 88 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,104 @@
11
# Editing with hpatch
22

3-
Inspect the relevant source, then send a compact script to `hpatch translate`. Pass its
4-
stdout directly to the native `apply_patch` tool inside the same wrapper operation:
3+
Inspect the relevant source before selecting it. Run `hpatch translate` from the
4+
workspace root with paths relative to that root, then pass its stdout directly to the
5+
native `apply_patch` tool inside one model tool call or equivalent host orchestration
6+
boundary:
57

68
```text
7-
translated = exec("hpatch translate", stdin=SCRIPT)
8-
if translated failed: return its error
9-
apply_patch(translated.stdout)
9+
translated = exec("hpatch translate", stdin=SCRIPT, cwd=WORKSPACE_ROOT)
10+
if translated failed:
11+
return its diagnostic
12+
13+
applied = native_apply_patch(translated.stdout)
14+
if applied failed:
15+
return its diagnostic
16+
17+
reread the intended workspace paths
18+
run focused validation
1019
```
1120

12-
Keep the translated patch internal. Do not invoke a shell executable named
13-
`apply_patch`, return the patch to the model, or ask the model to repeat it. The native
14-
tool call is what applies the change and lets the harness display its diff.
21+
The boundary requirement means the translated patch stays internal: do not invoke a
22+
shell executable named `apply_patch`, return the patch to the model, or ask the model
23+
to repeat it. Use a non-PTY stdin facility when the wrapper provides one. Keep stdout
24+
patch-only and propagate diagnostics from either stage.
25+
26+
The available native `apply_patch` resolves relative paths from its fixed workspace
27+
root and has no working-directory override. The working directory used by
28+
`hpatch translate` is not embedded in its textual patch or transferred to the native
29+
tool. Therefore:
30+
31+
- use workspace-relative paths and normally run translation from the workspace root;
32+
- do not expect an absolute path or translator working directory to patch outside it;
33+
- for an artifact ultimately required in a system temporary directory, edit and
34+
validate it in an ignored workspace-local staging directory, then relocate the
35+
completed artifact;
36+
- treat an opaque successful native result such as `{}` as no operation summary:
37+
reread the intended files and validate behavior;
38+
- treat an absent, malformed, unrelated, or unknown/future native result as
39+
unconfirmed rather than inventing hpatch semantics.
1540

1641
```text
1742
in PATH select an existing file
1843
new PATH select a new empty file at cursor 0:0
1944
mv PATH move the selected file
20-
rm remove the selected file
21-
sel LINE START:END select inclusive one-based columns
22-
tsel LINE OCCURRENCE "TEXT" select a literal; -1 means the last occurrence
23-
rsel START:END select inclusive complete lines
45+
rm remove the selected file and clear active state
46+
sel LINE START:END select inclusive one-based Unicode columns
47+
tsel LINE OCCURRENCE "TEXT" select a nonempty one-line literal; -1 is last
48+
rsel START:END select inclusive complete logical lines
2449
type "TEXT" replace the selection or insert at the cursor
2550
del delete the selection
2651
dup duplicate the selection
2752
```
2853

29-
Commands run sequentially and may switch files with multiple `in` commands. `type` and
30-
`tsel` operands are JSON strings.
54+
Commands run sequentially against the current in-memory contents and may switch files
55+
with multiple `in` commands. Returning to a file resets its cursor to `0:0` but
56+
retains pending edits. Every selection replaces the prior cursor or selection.
57+
`type` and `del` leave the cursor immediately after the inserted text or at the
58+
deleted selection's start. `dup` selects the new copy.
59+
60+
`rsel` owns each selected line's terminator when present. Replacing complete lines
61+
with `type` therefore requires the replacement string to include any desired final
62+
newline. Earlier edits immediately affect later line numbers:
63+
64+
```text
65+
in example.go
66+
rsel 2:3
67+
type "replacement line\n"
68+
tsel 3 1 "later"
69+
type "updated"
70+
```
71+
72+
Here the original lines 2–3, including line 3's terminator, are replaced. The later
73+
`tsel` observes the resulting current line 3, not the original file.
74+
75+
`type` and `tsel` operands are JSON strings. Generate operands with a JSON encoder
76+
instead of hand-escaping multiline text, quotes, backslashes, template literals, or
77+
Unicode. A `type` value may contain line terminators; a `tsel` value may not.
78+
79+
Additional examples:
80+
81+
```text
82+
new note.txt
83+
type "foo"
84+
type " "
85+
type "bar\n"
86+
87+
in config.txt
88+
tsel 4 -1 "old"
89+
type "new"
90+
91+
in logs.txt
92+
tsel 8 1 "debug"
93+
del
94+
95+
in block.txt
96+
rsel 2:4
97+
dup
98+
99+
in old.txt
100+
mv current.txt
101+
102+
in obsolete.txt
103+
rm
104+
```

README.md

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,11 @@ type " world\n"
3838
EOF
3939
```
4040

41-
The second command writes an `Add File` patch to stdout. Errors go to stderr and return
42-
a nonzero status. Normal mode preserves existing file permission bits and creates files
43-
from `new` with mode `0644`.
41+
The second command writes an `Add File` patch to stdout. Successful translation keeps
42+
stdout patch-only. Errors return nonzero and go to stderr; evaluation diagnostics
43+
identify the command index, source line, operation, relevant path, and failure category.
44+
Normal mode preserves existing file permission bits and creates files from `new` with
45+
mode `0644`.
4446

4547
Translate mode emits LF-only logical-line patches. OpenAI `apply_patch` cannot preserve
4648
CRLF bytes when such a patch is applied, so applying translated output to a CRLF file
@@ -80,9 +82,15 @@ dup
8082
`in` and `new` select a file at the cursor position before its first character.
8183
`type` inserts at the cursor or replaces a selection, then advances the cursor. Lines
8284
and inclusive columns are one-based and count Unicode code points. String operands use
83-
JSON syntax. Commands execute sequentially, including across repeated `in` commands.
84-
Paths use normal host filesystem semantics: relative paths resolve from the current
85-
directory and absolute paths remain absolute.
85+
JSON syntax; generate nontrivial operands with a JSON encoder. Commands execute
86+
sequentially against current in-memory content, including across repeated `in`
87+
commands. `rsel` includes each selected line's terminator when present, so complete-line
88+
replacement text must include any desired final newline.
89+
90+
Paths use normal host filesystem semantics during translation: relative paths resolve
91+
from hpatch's current directory and absolute paths remain absolute. The translated text
92+
contains paths but no current-directory metadata. A downstream patch tool independently
93+
chooses its application root, so its root must be aligned with the paths hpatch emits.
8694

8795
The complete behavior and failure contract is in
8896
[`doc/spec/interface.md`](doc/spec/interface.md). A shorter instruction sheet for

doc/spec/interface.md

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -86,10 +86,14 @@ Numbers are base-ten integers. Text lines, selection columns, and inclusive endp
8686
are one-based. Cursor `0:0` denotes the position before the first code point of a file;
8787
it is editor state, not a command operand. `OCCURRENCE` is nonzero: positive values
8888
count exact, non-overlapping literal matches from the start; negative values count
89-
them from the end. JSON operands use standard JSON string decoding. Paths are nonempty
90-
filesystem paths normalized with the host OS path rules. Relative paths resolve from
91-
the process working directory; absolute paths remain absolute. Trailing operands and
92-
unknown commands are invalid.
89+
them from the end. JSON operands use standard JSON string decoding. `type` strings may
90+
contain line terminators; `tsel` strings must stay within one logical line.
91+
92+
Paths are nonempty filesystem paths normalized with the host OS path rules. During
93+
translation, relative paths resolve from the hpatch process working directory and
94+
absolute paths remain absolute. Emitted patch paths do not carry that process working
95+
directory as metadata. A downstream patch consumer resolves them according to its own
96+
application-root contract. Trailing operands and unknown commands are invalid.
9397

9498
Acceptance:
9599

@@ -150,12 +154,17 @@ points, including one code point per tab; the line terminator is not selectable.
150154
The literal must be nonempty and cannot contain a line terminator. Matches do not
151155
overlap. `-1` selects the last match.
152156

153-
`rsel` selects an inclusive range of complete current logical lines. It records a
154-
linewise selection so duplication creates another complete adjacent line range,
155-
including when the selected final line has no terminator.
157+
`rsel` selects an inclusive range of complete current logical lines. For every
158+
selected line it owns the line terminator when one is present. It records a linewise
159+
selection so duplication creates another complete adjacent line range, including when
160+
the selected final line has no terminator. Replacing an `rsel` selection with `type`
161+
therefore removes the selected final terminator unless the replacement string supplies
162+
one.
156163

157164
Every selection replaces the previous cursor or selection. Commands observe current
158-
contents and current line numbering after earlier edit actions.
165+
contents and current line numbering after earlier edit actions. `type` leaves the
166+
cursor immediately after inserted text, `del` leaves it at the removed selection's
167+
start, and `dup` selects the duplicated copy.
159168

160169
Acceptance:
161170

@@ -243,8 +252,12 @@ does not modify source files. Normal mode continues to preserve existing line en
243252
outside explicitly inserted strings. Applying translated output to a non-LF file may
244253
normalize that file to LF; this is a declared format limitation, not byte equivalence.
245254

246-
Failures emit one concise diagnostic to stderr, prefixed with `hpatch:` and, for script
247-
errors, the script line number. They return nonzero and emit no stdout.
255+
Failures emit one concise diagnostic to stderr, prefixed with `hpatch:`. Script
256+
diagnostics identify the one-based nonblank command index, one-based source line,
257+
operation, relevant operand or selected path when one exists, and a failure category
258+
(`syntax`, `file`, `selection`, or `edit`). Control bytes in every diagnostic are escaped
259+
and embedded newlines are folded so one failure remains one logical line. Failures
260+
return nonzero and emit no stdout.
248261

249262
Acceptance:
250263

hpatch_test.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,66 @@ func TestEvaluationFailuresDoNotMutateOrEmitPatch(t *testing.T) {
266266
}
267267
}
268268

269+
func TestFailureDiagnosticsIdentifyCommandContext(t *testing.T) {
270+
tests := []struct {
271+
name string
272+
script string
273+
want string
274+
}{
275+
{
276+
name: "selection failure includes selected path",
277+
script: "in file.txt\ntsel 1 2 \"aa\"",
278+
want: "hpatch: command 2, source line 2, operation \"tsel\", path \"file.txt\", category selection: occurrence 2 of \"aa\" not found on line 1\n",
279+
},
280+
{
281+
name: "file failure includes operand path",
282+
script: "new file.txt",
283+
want: "hpatch: command 1, source line 1, operation \"new\", path \"file.txt\", category file: destination file.txt already exists\n",
284+
},
285+
{
286+
name: "malformed command is syntax",
287+
script: "in file.txt\nsel 1 2",
288+
want: "hpatch: command 2, source line 2, operation \"sel\", category syntax: unknown or malformed command\n",
289+
},
290+
{
291+
name: "unknown future command is syntax",
292+
script: "\nin file.txt\nsplice 1:2",
293+
want: "hpatch: command 2, source line 3, operation \"splice\", category syntax: unknown or malformed command\n",
294+
},
295+
{
296+
name: "tab-separated malformed command identifies token",
297+
script: "in file.txt\nsplice\t1:2",
298+
want: "hpatch: command 2, source line 2, operation \"splice\", category syntax: unknown or malformed command\n",
299+
},
300+
{
301+
name: "control byte in malformed operation is escaped",
302+
script: "in file.txt\n\x1b[31msplice 1:2",
303+
want: "hpatch: command 2, source line 2, operation \"\\x1b[31msplice\", category syntax: unknown or malformed command\n",
304+
},
305+
{
306+
name: "control byte in path message is escaped",
307+
script: "new \x1b[31mfile.txt",
308+
want: "hpatch: command 1, source line 1, operation \"new\", path \"\\x1b[31mfile.txt\", category file: destination \\x1b[31mfile.txt already exists\n",
309+
},
310+
{
311+
name: "edit without file omits path",
312+
script: "type \"x\"",
313+
want: "hpatch: command 1, source line 1, operation \"type\", category edit: type requires an active file\n",
314+
},
315+
}
316+
for _, test := range tests {
317+
t.Run(test.name, func(t *testing.T) {
318+
root := t.TempDir()
319+
writeTestFile(t, root, "file.txt", "aaa\n", 0o644)
320+
writeTestFile(t, root, "\x1b[31mfile.txt", "occupied\n", 0o644)
321+
stdout, stderr, exitCode := runForTest(root, []string{"translate"}, test.script)
322+
if exitCode != 1 || stdout != "" || stderr != test.want {
323+
t.Fatalf("Run() = exit %d, stdout %q, stderr %q; want stderr %q", exitCode, stdout, stderr, test.want)
324+
}
325+
})
326+
}
327+
}
328+
269329
func TestInvalidUTF8IsRejected(t *testing.T) {
270330
root := t.TempDir()
271331
if err := os.WriteFile(filepath.Join(root, "binary.txt"), []byte{0xff}, 0o644); err != nil {

program.go

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package hpatch
22

33
import (
44
"encoding/json"
5+
"errors"
56
"fmt"
67
"path/filepath"
78
"regexp"
@@ -31,12 +32,30 @@ type program struct {
3132
}
3233

3334
type commandError struct {
34-
Line int
35-
Message string
35+
Command int
36+
Line int
37+
Operation string
38+
Path string
39+
Category string
40+
Message string
3641
}
3742

3843
func (e *commandError) Error() string {
39-
return fmt.Sprintf("line %d: %s", e.Line, e.Message)
44+
var context []string
45+
if e.Command != 0 {
46+
context = append(context, fmt.Sprintf("command %d", e.Command))
47+
}
48+
context = append(context, fmt.Sprintf("source line %d", e.Line))
49+
if e.Operation != "" {
50+
context = append(context, fmt.Sprintf("operation %q", e.Operation))
51+
}
52+
if e.Path != "" {
53+
context = append(context, fmt.Sprintf("path %q", e.Path))
54+
}
55+
if e.Category != "" {
56+
context = append(context, "category "+e.Category)
57+
}
58+
return fmt.Sprintf("%s: %s", strings.Join(context, ", "), e.Message)
4059
}
4160

4261
func parse(source string) (*program, error) {
@@ -47,9 +66,20 @@ func parse(source string) (*program, error) {
4766
if strings.TrimSpace(line) == "" {
4867
continue
4968
}
69+
commandIndex := len(program.instructions) + 1
5070
command, err := parseInstruction(lineNumber, line)
5171
if err != nil {
52-
return nil, err
72+
message := err.Error()
73+
if sourceError, ok := errors.AsType[*commandError](err); ok {
74+
message = sourceError.Message
75+
}
76+
return nil, &commandError{
77+
Command: commandIndex,
78+
Line: lineNumber,
79+
Operation: strings.Fields(line)[0],
80+
Category: "syntax",
81+
Message: message,
82+
}
5383
}
5484
program.instructions = append(program.instructions, command)
5585
}

run.go

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ import (
77
"io/fs"
88
"os"
99
"path/filepath"
10+
"strconv"
1011
"strings"
12+
"unicode"
1113
"unicode/utf8"
1214
)
1315

@@ -112,13 +114,29 @@ func resolveFilesystemPath(workingDirectory, path string) string {
112114
return filepath.Join(workingDirectory, path)
113115
}
114116

117+
func sanitizeDiagnostic(message string) string {
118+
var sanitized strings.Builder
119+
for _, character := range message {
120+
switch {
121+
case character == '\n':
122+
sanitized.WriteString("; ")
123+
case unicode.IsControl(character):
124+
escaped := strconv.QuoteRune(character)
125+
sanitized.WriteString(escaped[1 : len(escaped)-1])
126+
default:
127+
sanitized.WriteRune(character)
128+
}
129+
}
130+
return sanitized.String()
131+
}
132+
115133
func fail(stderr io.Writer, message string) int {
116-
message = strings.ReplaceAll(message, "\n", "; ")
134+
message = sanitizeDiagnostic(message)
117135
_, _ = fmt.Fprintf(stderr, "hpatch: %s\n", message)
118136
return 1
119137
}
120138

121139
func warn(stderr io.Writer, message string) {
122-
message = strings.ReplaceAll(message, "\n", "; ")
140+
message = sanitizeDiagnostic(message)
123141
_, _ = fmt.Fprintf(stderr, "hpatch: warning: %s\n", message)
124142
}

0 commit comments

Comments
 (0)