Skip to content

Commit bad1656

Browse files
committed
feat: validate Go edits and accept safe corrections
- Format changed Go files with the standard library and map diagnostics and final-state coordinates through formatting changes. - Surface exact indentation corrections and let routed retries apply them with `N: accept`. - Standardize previews and repair context on `LINE|TEXT` output, updating protocol documentation and tests.
1 parent 5de6fba commit bad1656

26 files changed

Lines changed: 928 additions & 206 deletions

commit_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -172,19 +172,19 @@ func TestCommitResetsStateButScriptEndDoesNot(t *testing.T) {
172172
name: "explicit commit resets cursor",
173173
script: "in file.txt\ntsel 2 \"beta\"\ntype \"B\"\ncommit\n",
174174
wantHeader: "in file.txt 1:1",
175-
wantLine: "2 B",
175+
wantLine: "2|B",
176176
},
177177
{
178178
name: "no-op commit clears selection",
179179
script: "in file.txt\ntsel 2 \"beta\"\ncommit\n",
180180
wantHeader: "in file.txt 1:1",
181-
wantLine: "2 beta",
181+
wantLine: "2|beta",
182182
},
183183
{
184184
name: "script end preserves pending cursor",
185185
script: "in file.txt\ntsel 2 \"beta\"\ntype \"B\"\ncommit\ntsel 2 \"B\"\ntype \"CC\"\n",
186186
wantHeader: "in file.txt 2:3",
187-
wantLine: "2 CC",
187+
wantLine: "2|CC",
188188
},
189189
}
190190
for _, test := range tests {

compare/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ func scenarios() []scenario {
6969
"calc.go": "package calc\n\nfunc total(subtotal, tax int) int { return subtotal + tax + adjustmentForRegion(subtotal, tax) }\n",
7070
},
7171
script: "in calc.go\ntsel 3 \"subtotal + tax\"\ntype \"subtotal - discount + tax\"\n",
72-
patch: "*** Begin Patch\n*** Update File: calc.go\n@@\n-func total(subtotal, tax int) int { return subtotal + tax + adjustmentForRegion(subtotal, tax) }\n+func total(subtotal, tax int) int { return subtotal - discount + tax + adjustmentForRegion(subtotal, tax) }\n*** End Patch\n",
72+
patch: "*** Begin Patch\n*** Update File: calc.go\n@@\n-func total(subtotal, tax int) int { return subtotal + tax + adjustmentForRegion(subtotal, tax) }\n+func total(subtotal, tax int) int {\n+\treturn subtotal - discount + tax + adjustmentForRegion(subtotal, tax)\n+}\n*** End Patch\n",
7373
},
7474
{
7575
name: "last occurrence delete",

doc/brief.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,8 @@ historical-commit benchmark that measures hpatch against the native edit path by
5151
workspace.
5252
- Script commands: `in`, `new`, `mv`, `rm`, `sel`, `tsel`, `rsel`, `type`,
5353
`del`, `copy`, `cut`, `paste`, and `commit`; `type` also accepts a framed heredoc body.
54-
- Routed rejected-script corrections can replace, delete, or insert commands by command
55-
index without resending the complete script.
54+
- Routed rejected-script corrections can replace, accept displayed safe corrections for,
55+
delete, or insert commands by command index without resending the complete script.
5656

5757
## Non-goals
5858

doc/spec/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ The interface contract is authoritative for syntax and semantics.
2020
- [`REQ-CLI-001`](interface.md): invocation modes and final-state reporting
2121
- [`REQ-METRICS-001`](interface.md): persistent token, command, and feature metrics
2222
- [`REQ-SCRIPT-001`](interface.md): script grammar
23-
- [`REQ-CORRECT-001`](interface.md): compact correction replacement, deletion, and insertion
23+
- [`REQ-CORRECT-001`](interface.md): compact correction replacement, safe acceptance, deletion, and insertion
2424
- [`REQ-FILE-001`](interface.md): file selection and lifecycle
2525
- [`REQ-SELECT-001`](interface.md): selection behavior
2626
- [`REQ-EDIT-001`](interface.md): edit behavior

doc/spec/interface.md

Lines changed: 60 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -283,36 +283,42 @@ script with one operation per nonblank command header:
283283

284284
```text
285285
N: COMMAND replace command N
286+
N: accept apply hpatch's displayed safe correction for command N
286287
-N delete command N
287288
+N: COMMAND insert before command N
288289
N+: COMMAND insert after command N
289290
```
290291

291292
A replacement or insertion whose command is `type <<TAG` consumes its heredoc body and
292293
closing delimiter as part of that one correction operation. All indices refer to the
293-
original rejected script before any correction operation is applied. Replacements and
294-
deletions may name an index at most once and conflict with each other for the same index.
295-
Multiple insertions at one anchor are allowed and retain payload order; their position is
296-
relative to the original anchor even when that anchor is deleted. Every nonblank line
297-
outside a correction heredoc must be a correction operation.
298-
299-
The router validates all operations and referenced indices before rebuilding the script.
300-
It then reparses and reevaluates the complete transformed script against the unchanged
301-
workspace. A correction failure changes nothing. A successful transformation becomes the
302-
base for a later correction, retains the correction-chain correlation ID, increments the
303-
attempt, and charges metrics for only the compact payload the agent emitted.
294+
original rejected script before any correction operation is applied. Replacements,
295+
acceptances, and deletions may name an index at most once and conflict with each other for
296+
the same index. An acceptance is valid only when the immediately repairable rejected
297+
script retained an exact correction for that command; it never approves the rejected
298+
mutation itself. Multiple insertions at one anchor are allowed and retain payload order;
299+
their position is relative to the original anchor even when that anchor is deleted. Every
300+
nonblank line outside a correction heredoc must be a correction operation.
301+
302+
The router validates all operations, retained acceptances, and referenced indices before
303+
rebuilding the script. It then reparses and reevaluates the complete transformed script
304+
against the unchanged workspace. A correction failure changes nothing. A successful
305+
transformation becomes the base for a later correction, retains the correction-chain
306+
correlation ID, increments the attempt, and charges metrics for only the compact payload
307+
the agent emitted.
304308

305309
Acceptance:
306310

307311
1. `N: COMMAND` remains compatible with existing replacement corrections.
308-
2. `-N`, `+N: COMMAND`, and `N+: COMMAND` can remove obsolete commands and insert new
312+
2. `N: accept` substitutes exactly the safe correction displayed for command N; an absent
313+
or stale suggestion rejects without evaluating or mutating the workspace.
314+
3. `-N`, `+N: COMMAND`, and `N+: COMMAND` can remove obsolete commands and insert new
309315
commands without resending the complete script.
310-
3. Multiple same-anchor insertions preserve payload order, including when the anchor is
311-
deleted, while duplicate replacement/deletion or an absent index rejects the complete
312-
correction.
313-
4. A correction heredoc is one operation; an invalid or unterminated correction heredoc
316+
4. Multiple same-anchor insertions preserve payload order, including when the anchor is
317+
deleted, while duplicate replacement/acceptance/deletion or an absent index rejects
318+
the complete correction.
319+
5. A correction heredoc is one operation; an invalid or unterminated correction heredoc
314320
produces one bounded diagnostic and does not reinterpret its body as operations.
315-
5. Every corrected script is revalidated atomically against the unchanged workspace and
321+
6. Every corrected script is revalidated atomically against the unchanged workspace and
316322
retains the established correlation and emitted-payload metrics behavior.
317323

318324
## REQ-FILE-001 — File commands
@@ -513,10 +519,13 @@ Acceptance:
513519

514520
Input is read completely and the entire script is evaluated before an external filesystem
515521
commit or stdout. Script `commit` barriers only advance the in-memory generation and
516-
never create an externally visible partial result. An unchanged normal-mode change set
517-
performs no filesystem operation but still reports
518-
its final active editor state. An unchanged translate result emits no patch and fails
519-
because it cannot represent an update; it emits no final-state report.
522+
never create an externally visible partial result. Before finalization, every changed file
523+
whose final path ends in `.go` is parsed and formatted with Go's standard-library
524+
`go/format`; a parse failure rejects the complete transaction, while non-Go files receive
525+
no language validation. An unchanged normal-mode change set performs no filesystem
526+
operation but still reports its final active editor state. An unchanged translate result
527+
emits no patch and fails because it cannot represent an update; it emits no final-state
528+
report.
520529

521530
Translate output contains file actions in deterministic first-touch order:
522531

@@ -559,14 +568,15 @@ moves. A new empty file reports position `1:1` and one empty preview line number
559568
After the header, the report writes up to three total logical lines nearest the cursor or
560569
first selection start: normally the preceding, containing, and following lines; at a
561570
boundary, the first or last three available lines without duplication. Each row is
562-
`LINE TEXT`. `TEXT` contains at most the first 64 Unicode code points of rendered line content, without
563-
a line terminator or added ellipsis. Control characters are escaped so each preview stays
564-
on one output line. Each successful `tsel` line repair appends a
565-
`repaired command N tsel line REQUESTED to RESOLVED in PATH` note plus up to three marked
566-
post-edit preview lines around the repaired location. The complete report is rendered
567-
before commit or patch output, but it is emitted only after that mode-specific effect
568-
succeeds. A report-write failure after the effect is best-effort and cannot retroactively
569-
change the successful effect or claim rollback.
571+
`LINE|TEXT`, matching `nl -ba -w1 -s'|'` output. `TEXT` contains at most the first 64
572+
Unicode code points of rendered line content, without a line terminator or added ellipsis.
573+
Tabs are preserved and other control characters are escaped so each preview stays on one
574+
output line. Each successful `tsel` line repair appends a
575+
`repaired command N tsel line REQUESTED to RESOLVED in PATH` note plus up to three
576+
post-edit `LINE|TEXT` preview lines around the repaired location. The complete report is
577+
rendered before commit or patch output, but it is emitted only after that mode-specific
578+
effect succeeds. A report-write failure after the effect is best-effort and cannot
579+
retroactively change the successful effect or claim rollback.
570580

571581
Normal mode stages new contents in same-directory temporary files before starting the
572582
commit. Parse, validation, read, and evaluation failures leave the initial tree
@@ -599,39 +609,42 @@ than selecting a nearby line.
599609
A command failure that addressed an existing baseline additionally writes repair context
600610
on the lines following its diagnostic. Selectors resolve against a baseline the caller
601611
cannot see, so a diagnostic alone forces a blind retry that costs a whole script; repair
602-
context supplies the measurements that failure implies. A rejected column range reports the
603-
addressed line's rune-column count, restates that one tab is one column, and lists the
612+
context supplies the measurements that failure implies. A rejected column range reports
613+
the addressed line's rune-column count, restates that one tab is one column, and lists the
604614
rune-column span of each whitespace-separated token on that line. Token spans are used
605615
rather than sampled columns because a sampled character usually recurs on the line and
606-
cannot be located unambiguously. An out-of-range line or line range reports the file's
607-
line count. An edit conflict reports which current-generation baseline lines earlier commands
608-
already claim and, when a later selector can safely be rerun after materialization,
609-
identifies the selector command before which `commit` belongs. Every repair block
610-
includes a window of baseline lines around the addressed line, marks that line, and
611-
escapes control characters so each rendered line stays on one output line. A failure with
612-
no active baseline, including a missing file, emits its diagnostic alone. Repair context is
613-
supplementary: it never changes exit status, stdout, mutation, or metrics classification.
616+
cannot be located unambiguously. An out-of-range line or line range reports the file's
617+
line count. An edit conflict reports which current-generation baseline lines earlier
618+
commands already claim and, when a later selector can safely be rerun after
619+
materialization, identifies the selector command before which `commit` belongs. Every
620+
repair block includes a `LINE|TEXT` window of baseline lines around the addressed line and
621+
escapes non-tab control characters so each rendered line stays on one output line. A
622+
failure with no active baseline, including a missing file, emits its diagnostic alone.
623+
Repair context is supplementary: it never changes exit status, stdout, mutation, or
624+
metrics classification.
614625

615626
Acceptance:
616627

617628
1. Normal success has empty stdout and one rendered final-state report on stderr after
618629
commit; translate success has patch-only stdout and one pending-state report on stderr
619630
after the patch is completely written.
620-
2. Rendered cursor affinity, selection ranges, moved paths, empty files, three-line
621-
boundary windows, Unicode columns, 64-code-point truncation, and control escaping
631+
2. Rendered cursor affinity, selection ranges, moved paths, empty files, `LINE|TEXT`
632+
preview windows, Unicode columns, 64-code-point truncation, and control escaping
622633
produce the specified report without implying cross-invocation persistence.
623-
3. Malformed input, malformed or out-of-bounds line selection, unrelated literal or
634+
3. Changed Go files are formatted with the standard library before output, and invalid Go
635+
rejects the transaction without mutation; non-Go files receive no language validation.
636+
4. Malformed input, malformed or out-of-bounds line selection, unrelated literal or
624637
whitespace collision, unknown or future command, invalid UTF-8, missing or non-regular
625638
file, logical path collision, staging failure, translation failure, and cancellation
626639
produce no mutation, patch output, or final-state report.
627-
4. Injected external filesystem commit and rollback failures are reported without false
640+
5. Injected external filesystem commit and rollback failures are reported without false
628641
atomicity claims and without a successful final-state report; script `commit` barriers
629642
remain externally invisible.
630-
5. Failure to write a fully rendered report after a successful external effect does not
643+
6. Failure to write a fully rendered report after a successful external effect does not
631644
reverse that effect or record a complete report-input token estimate.
632-
6. A rejected column range, out-of-range line, missing literal occurrence, and edit
633-
conflict each emit repair context sufficient to correct the command without rereading
634-
the file; a failure with no active baseline emits its diagnostic alone.
645+
7. A rejected column range, out-of-range line, missing literal occurrence, and edit
646+
conflict each emit repair context sufficient to correct the command without rereading
647+
the file; a failure with no active baseline emits its diagnostic alone.
635648

636649
## REQ-GUIDE-001 — Agent guidance
637650

editor.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ type editor struct {
4444
cursorCommand int
4545
edits []baselineEdit
4646
corrections []lineCorrection
47+
lastOrigin editOrigin
48+
finalContent *string
49+
finalOffsets *formattedOffsetMap
4750
}
4851

4952
type logicalLine struct {
@@ -207,6 +210,11 @@ func (e *editor) typeText(replacement string, origin editOrigin) error {
207210
if selected.linewise && lineTerminatorSuffix(selectedReplacement) == "" {
208211
selectedReplacement += lineTerminatorSuffix(e.baseline[selected.start:selected.end])
209212
}
213+
if len(e.selections) == 1 {
214+
if correction := detectIndentationCorrection(e.baseline, selected, selectedReplacement); correction != nil {
215+
return correction
216+
}
217+
}
210218
edits[index] = baselineEdit{
211219
start: selected.start,
212220
end: selected.end,
@@ -315,6 +323,10 @@ func (e *editor) recordEdits(candidates []baselineEdit) error {
315323
additions = append(additions, candidate)
316324
}
317325
e.edits = append(e.edits, additions...)
326+
if len(additions) != 0 {
327+
e.lastOrigin = additions[len(additions)-1].editOrigin
328+
}
329+
318330
return nil
319331
}
320332

@@ -384,6 +396,9 @@ func (e *editor) orderedEdits() []baselineEdit {
384396
}
385397

386398
func (e *editor) content() string {
399+
if e.finalContent != nil {
400+
return *e.finalContent
401+
}
387402
edits := e.orderedEdits()
388403

389404
var result strings.Builder

hooks_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ func TestErrorHookReceivesFailureAndRepairContext(t *testing.T) {
4545
"## Diagnostic\n\n hpatch: command 2, source line 2",
4646
"## Repair context",
4747
"found 0 of 1 requested matches at or after line 1",
48-
">1 present words",
48+
"1|present words",
4949
} {
5050
if !strings.Contains(string(body), fragment) {
5151
t.Fatalf("hook body does not contain %q:\n%s", fragment, body)

internal/router/hpatch_correction.go

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,25 @@ type hpatchCorrectionKind uint8
2020

2121
const (
2222
hpatchReplace hpatchCorrectionKind = iota
23+
hpatchAccept
2324
hpatchDelete
2425
hpatchInsertBeforeAnchor
2526
hpatchInsertAfterAnchor
2627
)
2728

29+
func (k hpatchCorrectionKind) mutationVerb() string {
30+
switch k {
31+
case hpatchReplace:
32+
return "replaced"
33+
case hpatchAccept:
34+
return "accepted"
35+
case hpatchDelete:
36+
return "deleted"
37+
default:
38+
return "mutated"
39+
}
40+
}
41+
2842
type hpatchCorrection struct {
2943
kind hpatchCorrectionKind
3044
command int
@@ -63,24 +77,24 @@ func parseHPatchCorrections(payload string) ([]hpatchCorrection, error) {
6377

6478
kind, indexText, replacement, ok := parseHPatchCorrectionHeader(line)
6579
if !ok {
66-
return nil, fmt.Errorf("correction %q is not `INDEX: COMMAND`, `-INDEX`, `+INDEX: COMMAND`, or `INDEX+: COMMAND`", hpatchCorrectionPreview(line))
80+
return nil, fmt.Errorf("correction %q is not `INDEX: COMMAND`, `INDEX: accept`, `-INDEX`, `+INDEX: COMMAND`, or `INDEX+: COMMAND`", hpatchCorrectionPreview(line))
6781
}
6882
command, err := strconv.Atoi(indexText)
6983
if err != nil {
7084
return nil, fmt.Errorf("correction index %q is out of range", indexText)
7185
}
7286

73-
if kind == hpatchReplace || kind == hpatchDelete {
87+
if kind == hpatchReplace || kind == hpatchAccept || kind == hpatchDelete {
7488
if previous, exists := mutations[command]; exists {
7589
if previous != kind {
76-
return nil, fmt.Errorf("command %d cannot be both replaced and deleted", command)
90+
return nil, fmt.Errorf("command %d cannot be both %s and %s", command, previous.mutationVerb(), kind.mutationVerb())
7791
}
7892
return nil, fmt.Errorf("correction for command %d appears more than once", command)
7993
}
8094
mutations[command] = kind
8195
}
8296

83-
if kind != hpatchDelete {
97+
if kind != hpatchDelete && kind != hpatchAccept {
8498
if strings.TrimSpace(replacement) == "" {
8599
return nil, fmt.Errorf("correction for command %d has no replacement command", command)
86100
}
@@ -102,6 +116,10 @@ func parseHPatchCorrections(payload string) ([]hpatchCorrection, error) {
102116

103117
func parseHPatchCorrectionHeader(line string) (hpatchCorrectionKind, string, string, bool) {
104118
if match := hpatchReplacementPattern.FindStringSubmatch(line); match != nil {
119+
if match[3] == "accept" {
120+
return hpatchAccept, match[1], "", true
121+
}
122+
105123
return hpatchReplace, match[1], match[3], true
106124
}
107125
if match := hpatchDeletionPattern.FindStringSubmatch(line); match != nil {
@@ -144,7 +162,7 @@ type hpatchFrameTransform struct {
144162
// applyHPatchCorrections resolves every operation against the original command
145163
// frames before rebuilding the script. Insertions retain payload order even when
146164
// their original anchor is deleted.
147-
func applyHPatchCorrections(base string, corrections []hpatchCorrection) (string, error) {
165+
func applyHPatchCorrections(base string, corrections []hpatchCorrection, suggestions ...map[int]string) (string, error) {
148166
lines := hpatchsyntax.SplitPhysicalLines(base)
149167
frames := hpatchCommandFrames(lines)
150168
if len(frames) == 0 {
@@ -157,6 +175,10 @@ func applyHPatchCorrections(base string, corrections []hpatchCorrection) (string
157175
}
158176

159177
transforms := make([]hpatchFrameTransform, len(frames))
178+
var available map[int]string
179+
if len(suggestions) != 0 {
180+
available = suggestions[0]
181+
}
160182
for _, correction := range corrections {
161183
transform := &transforms[correction.command-1]
162184
switch correction.kind {
@@ -166,6 +188,16 @@ func applyHPatchCorrections(base string, corrections []hpatchCorrection) (string
166188
}
167189
transform.replacement = correction.replacement
168190
transform.hasReplacement = true
191+
case hpatchAccept:
192+
replacement, ok := available[correction.command]
193+
if !ok {
194+
return "", fmt.Errorf("command %d has no displayed correction to accept", correction.command)
195+
}
196+
if transform.hasReplacement || transform.deleted {
197+
return "", fmt.Errorf("command %d has conflicting replacement or deletion operations", correction.command)
198+
}
199+
transform.replacement = replacement
200+
transform.hasReplacement = true
169201
case hpatchDelete:
170202
if transform.hasReplacement || transform.deleted {
171203
return "", fmt.Errorf("command %d has conflicting replacement or deletion operations", correction.command)

0 commit comments

Comments
 (0)