fix(#70): reject after an interior tweak restores the original via tracked applied span (INV-5)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
BenStullsBets
2026-07-02 16:41:51 -07:00
parent d8f2d7383a
commit a9f8dd0e4f
2 changed files with 113 additions and 11 deletions
+59 -11
View File
@@ -42,6 +42,16 @@ interface DocState {
unresolved: Set<string>;
/** ids already optimistically applied to the buffer (so the trigger doesn't re-apply). */
applied: Set<string>;
/**
* #70 (INV-5): the F12 applied-range bookkeeping — proposal id -> the applied
* span's CURRENT buffer range. Written at optimistic-apply, re-synced whenever
* the exact anchor resolves (renderAll), shifted through interior-safe edits,
* and DELETED (distrusted) when an edit straddles a span boundary — a clamped
* range is a guess, and stale text is never applied by guess (INV-11). Lets
* Reject restore the original after the human types INSIDE the pending range
* (which orphans the exact-substring anchor).
*/
appliedSpans: Map<string, OffsetRange>;
}
export class ProposalController implements vscode.Disposable {
@@ -135,6 +145,7 @@ export class ProposalController implements vscode.Disposable {
live: new Map(),
unresolved: new Set(),
applied: new Set(),
appliedSpans: new Map(),
};
this.docs.set(docPath, state);
}
@@ -374,6 +385,7 @@ export class ProposalController implements vscode.Disposable {
// end shifts by the net length delta of the replacement).
const appliedStart = resolved.start;
const appliedEnd = appliedStart + proposal.replacement.length;
state.appliedSpans.set(proposalId, { start: appliedStart, end: appliedEnd });
const appliedFp = buildFingerprint(document.getText(), { start: appliedStart, end: appliedEnd });
this.store.update(docPath, (a) => setProposalApplied(a, proposalId, appliedFp, original));
this.renderAll(document);
@@ -422,6 +434,7 @@ export class ProposalController implements vscode.Disposable {
this.attribution.signalLanded(document);
this.store.update(docPath, (a) => removeProposal(a, proposalId));
hit.state.applied.delete(proposalId);
hit.state.appliedSpans.delete(proposalId);
this.renderAll(document);
return true;
}
@@ -429,26 +442,48 @@ export class ProposalController implements vscode.Disposable {
/**
* F12/#64 (INV-51): REJECT an optimistically-applied proposal — replace its live
* applied span with the stored `original`, then clear it. Reverts the whole block
* regardless of any in-place edits the human made to the inserted text.
* regardless of any in-place edits the human made to the inserted text: when an
* interior tweak has orphaned the exact anchor (INV-11), the revert falls back to
* the shift-tracked applied span (#70, INV-5). When neither locates the span
* (e.g. a boundary-straddling external rewrite distrusted it), the reject FAILS
* loudly — warning shown, proposal kept, `false` returned — instead of silently
* leaving the proposed text in the buffer. Undo (or ✓ Keep) remains the way out.
*/
async revertInPlace(docPath: string, proposalId: string): Promise<boolean> {
async revertInPlace(docPath: string, proposalId: string, opts?: { silent?: boolean }): Promise<boolean> {
const hit = this.byId(docPath, proposalId);
if (!hit) return false;
const document = this.openDoc(hit.state);
if (!document) return false;
// Never optimistically applied → nothing of ours is in the buffer; clearing
// the record IS the reject (the legacy pending-only path).
if (hit.proposal.original === undefined) {
this.store.update(docPath, (a) => removeProposal(a, proposalId));
hit.state.applied.delete(proposalId);
hit.state.appliedSpans.delete(proposalId);
this.renderAll(document);
return true;
}
const fp = hit.state.artifact.anchors[hit.proposal.anchorId]?.fingerprint;
const resolved = fp ? resolve(document.getText(), fp) : "orphaned";
if (resolved !== "orphaned" && hit.proposal.original !== undefined) {
const we = new vscode.WorkspaceEdit();
we.replace(
document.uri,
new vscode.Range(document.positionAt(resolved.start), document.positionAt(resolved.end)),
hit.proposal.original,
);
if (!(await vscode.workspace.applyEdit(we))) return false;
const span = resolved !== "orphaned" ? resolved : hit.state.appliedSpans.get(proposalId);
if (!span) {
if (!opts?.silent) {
void vscode.window.showWarningMessage(
"Cowriting: this proposal's applied text can't be located (it changed or moved) — undo your edits, or Keep it to leave the buffer as-is (it is never reverted by guess).",
);
}
return false;
}
const we = new vscode.WorkspaceEdit();
we.replace(
document.uri,
new vscode.Range(document.positionAt(span.start), document.positionAt(span.end)),
hit.proposal.original,
);
if (!(await vscode.workspace.applyEdit(we))) return false;
this.store.update(docPath, (a) => removeProposal(a, proposalId));
hit.state.applied.delete(proposalId);
hit.state.appliedSpans.delete(proposalId);
this.renderAll(document);
return true;
}
@@ -510,6 +545,9 @@ export class ProposalController implements vscode.Disposable {
this.recordProposal(state, proposal, { start: off, end: off }, false);
} else {
this.recordProposal(state, proposal, resolved, true);
// #70: an exact resolve is ground truth — (re)sync the applied-range
// bookkeeping (covers a prior-session apply, whose in-memory map is empty).
if (state.applied.has(proposal.id)) state.appliedSpans.set(proposal.id, resolved);
}
}
this.renderStatus(state);
@@ -528,10 +566,20 @@ export class ProposalController implements vscode.Disposable {
private onDidChange(e: vscode.TextDocumentChangeEvent): void {
const state = this.docs.get(this.keyOf(e.document));
if (!state || state.live.size === 0) return;
if (!state || (state.live.size === 0 && state.appliedSpans.size === 0)) return;
for (const change of e.contentChanges) {
const edit = { start: change.rangeOffset, end: change.rangeOffset + change.rangeLength, newLength: change.text.length };
for (const [id, range] of state.live) state.live.set(id, shift(range, edit));
// #70: an edit fully inside a tracked applied span (or fully outside it)
// keeps the span meaningful; one straddling a boundary — e.g. a whole-buffer
// external replace — makes the shifted range a clamped guess, so the span
// is distrusted (deleted) rather than reverted-to by guess (INV-11).
for (const [id, range] of state.appliedSpans) {
const outside = edit.end <= range.start || edit.start >= range.end;
const inside = edit.start >= range.start && edit.end <= range.end;
if (outside || inside) state.appliedSpans.set(id, shift(range, edit));
else state.appliedSpans.delete(id);
}
}
}