fix(lsp): close review gaps in workspace edit reporting
Normalize batch dependency paths through the registry's lexical canonical form so equivalent URI spellings do not revive the initial-state preflight bug. Separate execution-started state from the count of completed plan items. Preflight failures retain the no-mutation guarantee, while runtime failures conservatively acknowledge that the failing item may itself have changed a buffer or the filesystem. Add real-server-pump acceptance for dot-path dependency aliases, partial text edits within one item, and resource-operation side effects, and record the review-round corrections in the framing.
This commit is contained in:
parent
c804dd523c
commit
cb7fe818bd
|
|
@ -1298,27 +1298,32 @@ end
|
||||||
-- (best-effort: it may itself have been renamed/deleted), on the
|
-- (best-effort: it may itself have been renamed/deleted), on the
|
||||||
-- failure path as well as the success path. Returns
|
-- failure path as well as the success path. Returns
|
||||||
-- `edit_count, file_count, resource_op_count` on success, or
|
-- `edit_count, file_count, resource_op_count` on success, or
|
||||||
-- `nil, message, applied_op_count` if the preflight rejected the edit
|
-- `nil, message, applied_op_count, execution_started` if the preflight
|
||||||
-- OR any op failed while executing. No exception escapes this function
|
-- rejected the edit OR any op failed while executing. No exception
|
||||||
-- (Q#RD7) — the three callers all handle `nil, message` already, and a
|
-- escapes this function (Q#RD7) — the three callers all handle
|
||||||
-- raise reaching them meant an unattended server request went
|
-- `nil, message` already, and a raise reaching them meant an unattended
|
||||||
-- unanswered.
|
-- server request went unanswered.
|
||||||
--
|
--
|
||||||
-- The third failure value is load-bearing, not decoration: Q#RD3
|
-- The third failure value is load-bearing, not decoration: Q#RD3
|
||||||
-- permits partial application, so `applied_op_count > 0` means earlier
|
-- permits partial application, so `applied_op_count > 0` means earlier
|
||||||
-- plan items ARE still applied and no caller may say otherwise.
|
-- plan items ARE still applied and no caller may say otherwise.
|
||||||
|
-- `execution_started` is independently load-bearing: a plan item can
|
||||||
-- Strip trailing separators so a path compares by components.
|
-- mutate before it fails (multiple text edits are sequential, and a
|
||||||
local function strip_trailing_slash(p)
|
-- resource primitive may have intermediate filesystem effects), so
|
||||||
while #p > 1 and p:sub(-1) == "/" do p = p:sub(1, #p - 1) end
|
-- zero fully-applied items does NOT prove that nothing was mutated.
|
||||||
return p
|
|
||||||
end
|
|
||||||
|
|
||||||
-- True when `a` and `b` name the same path, or one lies beneath the
|
-- True when `a` and `b` name the same path, or one lies beneath the
|
||||||
-- other. Component-aware, like the Rust side's `Path::starts_with`: a
|
-- other. Component-aware, like the Rust side's `Path::starts_with`: a
|
||||||
-- raw string prefix would make `/tree` an ancestor of `/tree-sibling`.
|
-- raw string prefix would make `/tree` an ancestor of `/tree-sibling`.
|
||||||
|
--
|
||||||
|
-- Compare on the buffer registry's lexical canonical form, not the raw
|
||||||
|
-- decoded URI spelling. `file:///tree/./x` and `file:///tree/x` reach
|
||||||
|
-- the same filesystem entry; treating them as unrelated would judge a
|
||||||
|
-- later delete against the initial filesystem and fabricate NotFound
|
||||||
|
-- for an earlier create. This is deliberately lexical — resolving
|
||||||
|
-- symlinks would change filesystem identity and fail for a new path.
|
||||||
local function paths_related(a, b)
|
local function paths_related(a, b)
|
||||||
a, b = strip_trailing_slash(a), strip_trailing_slash(b)
|
a, b = pmacs.path.canonicalize(a), pmacs.path.canonicalize(b)
|
||||||
if a == b then return true end
|
if a == b then return true end
|
||||||
if #a < #b then a, b = b, a end
|
if #a < #b then a, b = b, a end
|
||||||
return a:sub(1, #b) == b and (b == "/" or a:sub(#b + 1, #b + 1) == "/")
|
return a:sub(1, #b) == b and (b == "/" or a:sub(#b + 1, #b + 1) == "/")
|
||||||
|
|
@ -1358,12 +1363,16 @@ local function apply_workspace_edit(ops)
|
||||||
if op.op == "edit" then
|
if op.op == "edit" then
|
||||||
if op.edits and #op.edits > 0 then
|
if op.edits and #op.edits > 0 then
|
||||||
local path = pmacs.lsp.path_for_uri(op.uri)
|
local path = pmacs.lsp.path_for_uri(op.uri)
|
||||||
if not path then return nil, "cannot resolve " .. tostring(op.uri), 0 end
|
if not path then
|
||||||
|
return nil, "cannot resolve " .. tostring(op.uri), 0, false
|
||||||
|
end
|
||||||
plan[#plan + 1] = { kind = "edit", path = path, edits = op.edits }
|
plan[#plan + 1] = { kind = "edit", path = path, edits = op.edits }
|
||||||
end
|
end
|
||||||
elseif op.op == "create" then
|
elseif op.op == "create" then
|
||||||
local path = pmacs.lsp.path_for_uri(op.uri)
|
local path = pmacs.lsp.path_for_uri(op.uri)
|
||||||
if not path then return nil, "cannot resolve " .. tostring(op.uri), 0 end
|
if not path then
|
||||||
|
return nil, "cannot resolve " .. tostring(op.uri), 0, false
|
||||||
|
end
|
||||||
plan[#plan + 1] = {
|
plan[#plan + 1] = {
|
||||||
kind = "create", path = path,
|
kind = "create", path = path,
|
||||||
overwrite = op.overwrite, ignore_if_exists = op.ignore_if_exists,
|
overwrite = op.overwrite, ignore_if_exists = op.ignore_if_exists,
|
||||||
|
|
@ -1374,7 +1383,7 @@ local function apply_workspace_edit(ops)
|
||||||
local to = pmacs.lsp.path_for_uri(op.new_uri)
|
local to = pmacs.lsp.path_for_uri(op.new_uri)
|
||||||
if not from or not to then
|
if not from or not to then
|
||||||
return nil, "cannot resolve rename " ..
|
return nil, "cannot resolve rename " ..
|
||||||
tostring(op.old_uri) .. " -> " .. tostring(op.new_uri), 0
|
tostring(op.old_uri) .. " -> " .. tostring(op.new_uri), 0, false
|
||||||
end
|
end
|
||||||
plan[#plan + 1] = {
|
plan[#plan + 1] = {
|
||||||
kind = "rename", old_path = from, new_path = to,
|
kind = "rename", old_path = from, new_path = to,
|
||||||
|
|
@ -1384,7 +1393,9 @@ local function apply_workspace_edit(ops)
|
||||||
batch_changes[#batch_changes + 1] = to
|
batch_changes[#batch_changes + 1] = to
|
||||||
elseif op.op == "delete" then
|
elseif op.op == "delete" then
|
||||||
local path = pmacs.lsp.path_for_uri(op.uri)
|
local path = pmacs.lsp.path_for_uri(op.uri)
|
||||||
if not path then return nil, "cannot resolve " .. tostring(op.uri), 0 end
|
if not path then
|
||||||
|
return nil, "cannot resolve " .. tostring(op.uri), 0, false
|
||||||
|
end
|
||||||
-- Delete precondition check (Q#RD3). This is a FILTER, not a
|
-- Delete precondition check (Q#RD3). This is a FILTER, not a
|
||||||
-- transaction. It catches, before anything in the batch is
|
-- transaction. It catches, before anything in the batch is
|
||||||
-- mutated: a plan-time modified or mid-edit buffer, a known
|
-- mutated: a plan-time modified or mid-edit buffer, a known
|
||||||
|
|
@ -1414,7 +1425,9 @@ local function apply_workspace_edit(ops)
|
||||||
recursive = op.recursive,
|
recursive = op.recursive,
|
||||||
ignore_if_not_exists = op.ignore_if_not_exists,
|
ignore_if_not_exists = op.ignore_if_not_exists,
|
||||||
}
|
}
|
||||||
if verdict.kind == "refuse" then return nil, verdict.message, 0 end
|
if verdict.kind == "refuse" then
|
||||||
|
return nil, verdict.message, 0, false
|
||||||
|
end
|
||||||
end
|
end
|
||||||
plan[#plan + 1] = {
|
plan[#plan + 1] = {
|
||||||
kind = "delete", path = path,
|
kind = "delete", path = path,
|
||||||
|
|
@ -1456,7 +1469,7 @@ local function apply_workspace_edit(ops)
|
||||||
end
|
end
|
||||||
if not ok then
|
if not ok then
|
||||||
restore_origin()
|
restore_origin()
|
||||||
return nil, tostring(err), applied_ops
|
return nil, tostring(err), applied_ops, true
|
||||||
end
|
end
|
||||||
applied_ops = applied_ops + 1
|
applied_ops = applied_ops + 1
|
||||||
end
|
end
|
||||||
|
|
@ -1470,16 +1483,22 @@ end
|
||||||
--
|
--
|
||||||
-- Q#RD3 explicitly permits partial application: an earlier text edit
|
-- Q#RD3 explicitly permits partial application: an earlier text edit
|
||||||
-- can apply and dirty a buffer before a later delete refuses. So
|
-- can apply and dirty a buffer before a later delete refuses. So
|
||||||
-- "nothing was mutated" is a claim about `applied`, not a constant —
|
-- "nothing was mutated" is reserved for failures before execution.
|
||||||
-- asserting it unconditionally is a false statement about the user's
|
-- `applied == 0` after execution began proves only that no whole plan
|
||||||
-- files in precisely the case the framing predicted.
|
-- item finished; a multi-edit item or resource primitive can still
|
||||||
local function workspace_edit_failure(message, applied)
|
-- have changed state before its error.
|
||||||
|
local function workspace_edit_failure(message, applied, execution_started)
|
||||||
applied = applied or 0
|
applied = applied or 0
|
||||||
if applied > 0 then
|
if applied > 0 then
|
||||||
return string.format(
|
return string.format(
|
||||||
"failed after %d operation%s — those earlier changes remain applied: %s",
|
"failed after %d operation%s completed — those earlier changes remain " ..
|
||||||
|
"applied; the failing operation may also have changed state: %s",
|
||||||
applied, (applied == 1 and "" or "s"), tostring(message))
|
applied, (applied == 1 and "" or "s"), tostring(message))
|
||||||
end
|
end
|
||||||
|
if execution_started then
|
||||||
|
return "failed during the first operation — it may have changed state " ..
|
||||||
|
"before failing: " .. tostring(message)
|
||||||
|
end
|
||||||
return "aborted, nothing was mutated: " .. tostring(message)
|
return "aborted, nothing was mutated: " .. tostring(message)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -1971,7 +1990,7 @@ local function handle_server_requests()
|
||||||
-- being fixed, one line out of scope. The wrap costs
|
-- being fixed, one line out of scope. The wrap costs
|
||||||
-- nothing and makes the boundary uniform regardless of
|
-- nothing and makes the boundary uniform regardless of
|
||||||
-- which call fails.
|
-- which call fails.
|
||||||
local ok, a, b, c = pcall(function()
|
local ok, a, b, c, d = pcall(function()
|
||||||
local parsed = pmacs.lsp._parse_workspace_edit(edit)
|
local parsed = pmacs.lsp._parse_workspace_edit(edit)
|
||||||
return apply_workspace_edit(parsed.ops)
|
return apply_workspace_edit(parsed.ops)
|
||||||
end)
|
end)
|
||||||
|
|
@ -1981,11 +2000,11 @@ local function handle_server_requests()
|
||||||
elseif a then
|
elseif a then
|
||||||
applied = true
|
applied = true
|
||||||
else
|
else
|
||||||
-- `c` is the count of plan items already applied. The
|
-- `c` is the count of plan items already applied; `d`
|
||||||
-- server is told so, because `applied = false` alone
|
-- says execution began at all. The server needs both,
|
||||||
-- reads as "the workspace is unchanged" and Q#RD3 says
|
-- because a failing plan item can itself mutate before
|
||||||
-- it need not be.
|
-- returning an error.
|
||||||
reason = workspace_edit_failure(b, c)
|
reason = workspace_edit_failure(b, c, d)
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
reason = "missing edit"
|
reason = "missing edit"
|
||||||
|
|
@ -2473,7 +2492,7 @@ function pmacs.lsp.rename()
|
||||||
pmacs.editor.set_status("LSP: rename produced no edits")
|
pmacs.editor.set_status("LSP: rename produced no edits")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local n, files, res = apply_workspace_edit(ops)
|
local n, files, res, execution_started = apply_workspace_edit(ops)
|
||||||
if not n then
|
if not n then
|
||||||
-- On failure the second value is the message and the third
|
-- On failure the second value is the message and the third
|
||||||
-- is how many plan items already applied. It is NOT always
|
-- is how many plan items already applied. It is NOT always
|
||||||
|
|
@ -2481,7 +2500,8 @@ function pmacs.lsp.rename()
|
||||||
-- unconditionally — that was false in exactly the
|
-- unconditionally — that was false in exactly the
|
||||||
-- edit-then-delete case the framing predicted.
|
-- edit-then-delete case the framing predicted.
|
||||||
pmacs.editor.set_status(
|
pmacs.editor.set_status(
|
||||||
"LSP: rename " .. workspace_edit_failure(files, res))
|
"LSP: rename " ..
|
||||||
|
workspace_edit_failure(files, res, execution_started))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local msg = string.format(
|
local msg = string.format(
|
||||||
|
|
@ -2540,13 +2560,14 @@ end
|
||||||
local function apply_code_action(rec, act)
|
local function apply_code_action(rec, act)
|
||||||
local bits = {}
|
local bits = {}
|
||||||
if act.has_edit then
|
if act.has_edit then
|
||||||
local n, files, res = apply_workspace_edit(act.edit)
|
local n, files, res, execution_started = apply_workspace_edit(act.edit)
|
||||||
if not n then
|
if not n then
|
||||||
-- Same failure shape as the rename caller: `files` is the
|
-- Same failure shape as the rename caller: `files` is the
|
||||||
-- message, `res` the applied-op count (Q#RD3 permits partial
|
-- message, `res` the applied-op count (Q#RD3 permits partial
|
||||||
-- application, so it can be non-zero).
|
-- application, so it can be non-zero).
|
||||||
pmacs.editor.set_status(
|
pmacs.editor.set_status(
|
||||||
"LSP: code action " .. workspace_edit_failure(files, res))
|
"LSP: code action " ..
|
||||||
|
workspace_edit_failure(files, res, execution_started))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local b = string.format("%d edit(s) / %d file(s)", n, files)
|
local b = string.format("%d edit(s) / %d file(s)", n, files)
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
# Framing — `apply_resource_op` delete destroys unsaved work
|
# Framing — `apply_resource_op` delete destroys unsaved work
|
||||||
|
|
||||||
**Revision 5, plus §9.** Status: **APPROVED and MERGED as #186
|
**Revision 5, plus §§9–10.** Status: **APPROVED and MERGED as #186
|
||||||
(framing only); the implementation is PR #190** on branch
|
(framing only); the implementation is PR #190** on branch
|
||||||
`resource-op-delete-guard-impl`, worktree `../pmacs-rd-impl`. The
|
`resource-op-delete-guard-impl`, worktree `../pmacs-rd-impl`. The
|
||||||
revision-5 body below is unchanged except for the two bookkeeping
|
revision-5 body below is unchanged except for the two bookkeeping
|
||||||
edits §9.6 names and makes in place; **§9 records the corrections
|
edits §9.6 names and makes in place; **§§9–10 record the corrections
|
||||||
implementation review round 1 found**, including two corrections to
|
implementation review rounds 1–2 found**, including corrections to this
|
||||||
this document. The "DO NOT implement, DO NOT merge" banner this line
|
document. The "DO NOT implement, DO NOT merge" banner this line replaces
|
||||||
replaces was true when revision 5 was written and is not now.
|
was true when revision 5 was written and is not now.
|
||||||
|
|
||||||
Revision 5's lane header — `resource-op-delete-guard`, worktree
|
Revision 5's lane header — `resource-op-delete-guard`, worktree
|
||||||
`../pmacs-resource-op-delete`, based on `githubsucks/main` @
|
`../pmacs-resource-op-delete`, based on `githubsucks/main` @
|
||||||
|
|
@ -1543,10 +1543,10 @@ that passes against its pre-image has no bite and is rejected.
|
||||||
17. **Every new test is checked with `scripts/bite`** and none reports
|
17. **Every new test is checked with `scripts/bite`** and none reports
|
||||||
VACUOUS.
|
VACUOUS.
|
||||||
|
|
||||||
**Criteria 18, 19a–19c and 20 are added by §9**, after implementation
|
**Criteria 18, 19a–19c and 20 are added by §9; criteria 21 and
|
||||||
review round 1. They are listed there, with their pre-images, rather
|
22a–22b by §10**, after implementation review rounds 1 and 2. They are
|
||||||
than interleaved here, so this section stays readable as the record of
|
listed there, with their pre-images, rather than interleaved here, so
|
||||||
what revision 5 asked for.
|
this section stays readable as the record of what revision 5 asked for.
|
||||||
|
|
||||||
|
|
||||||
## 6. Parked — not deferred-and-forgotten
|
## 6. Parked — not deferred-and-forgotten
|
||||||
|
|
@ -1600,7 +1600,7 @@ suites; `cargo test --test m4_acceptance -- --skip basedpyright`;
|
||||||
|
|
||||||
Touched suite: **`m4_acceptance`** — the resource-op home (§1.14) and
|
Touched suite: **`m4_acceptance`** — the resource-op home (§1.14) and
|
||||||
the home of every criterion, 1–16 including 11a–11d, plus §9's 18,
|
the home of every criterion, 1–16 including 11a–11d, plus §9's 18,
|
||||||
19a–19c and 20.
|
19a–19c and 20, and §10's 21 and 22a–22b.
|
||||||
|
|
||||||
*Amended at implementation (§9.6).* Revision 5 also named
|
*Amended at implementation (§9.6).* Revision 5 also named
|
||||||
**`lsp_dispatch_seams_acceptance`**, for criterion 15's throwing parse
|
**`lsp_dispatch_seams_acceptance`**, for criterion 15's throwing parse
|
||||||
|
|
@ -1647,7 +1647,7 @@ the gate list below and §5, which revision 3 left disagreeing:
|
||||||
|---|---|
|
|---|---|
|
||||||
| `src/lua_bindings/mod.rs` | the delete arm's four phases (Q#RD2); the shared query binding and its structured verdict (Q#RD6, Q#RD12); the narrow `*errors*` append surface (Q#RD7) |
|
| `src/lua_bindings/mod.rs` | the delete arm's four phases (Q#RD2); the shared query binding and its structured verdict (Q#RD6, Q#RD12); the narrow `*errors*` append surface (Q#RD7) |
|
||||||
| `builtin/runtime/lsp.lua` | the preflight conflict check (Q#RD3); the parse-plus-apply wrap, origin restore, and boundary logging (Q#RD7) |
|
| `builtin/runtime/lsp.lua` | the preflight conflict check (Q#RD3); the parse-plus-apply wrap, origin restore, and boundary logging (Q#RD7) |
|
||||||
| `tests/m4_acceptance.rs` | criteria 1–16 including 11a–11d, plus §9's 18, 19a–19c and 20 — **including criterion 15's throwing parse stub**, per the permitted simplification below (§9.6) |
|
| `tests/m4_acceptance.rs` | criteria 1–16 including 11a–11d, plus §9's 18, 19a–19c and 20 and §10's 21 and 22a–22b — **including criterion 15's throwing parse stub**, per the permitted simplification below (§9.6) |
|
||||||
| `src/bin/pmacs_fake_lsp.rs` | **one parameterized mode, `applyeditplan`**, whose `WorkspaceEdit` is read from a test-written file, plus a sink for the client's response — see §9.6 for why one mode replaced the eight named below |
|
| `src/bin/pmacs_fake_lsp.rs` | **one parameterized mode, `applyeditplan`**, whose `WorkspaceEdit` is read from a test-written file, plus a sink for the client's response — see §9.6 for why one mode replaced the eight named below |
|
||||||
|
|
||||||
~~`tests/lsp_dispatch_seams_acceptance.rs`~~ — struck at implementation
|
~~`tests/lsp_dispatch_seams_acceptance.rs`~~ — struck at implementation
|
||||||
|
|
@ -1875,3 +1875,102 @@ never considered. The rest of the guard was swept for that shape.
|
||||||
**Nothing else in this lane decides an affected set.** The reporting
|
**Nothing else in this lane decides an affected set.** The reporting
|
||||||
path names a buffer only inside a refusal it already computed, and
|
path names a buffer only inside a refusal it already computed, and
|
||||||
`restore_origin` is best-effort by construction.
|
`restore_origin` is best-effort by construction.
|
||||||
|
|
||||||
|
|
||||||
|
## 10. Corrections found during implementation — review round 2
|
||||||
|
|
||||||
|
This section records the two remaining findings on the round-1 repair.
|
||||||
|
Neither changes the feature boundary, Q#RD1's refusal, Q#RD2's phase
|
||||||
|
order, or Q#RD3's choice of a filter rather than a transaction. Both
|
||||||
|
make the round-1 correction true for inputs its first acceptance set did
|
||||||
|
not enumerate.
|
||||||
|
|
||||||
|
### 10.1 Batch dependency comparison uses the registry's lexical path form
|
||||||
|
|
||||||
|
Section 9.3 correctly required component-aware comparison, but its first
|
||||||
|
implementation compared **raw decoded URI strings** after stripping
|
||||||
|
only trailing slashes. That is component-aware without being
|
||||||
|
path-equivalence-aware: `file:///tree/./x` and
|
||||||
|
`file:///tree/x` reach the same filesystem entry while comparing
|
||||||
|
unequal. A legal ordered `create /tree/./x → delete /tree/x` was
|
||||||
|
therefore refused at plan time with the same fabricated `NotFound`
|
||||||
|
§9.3 had just fixed for identical spellings. Reproduced through the
|
||||||
|
real server pump.
|
||||||
|
|
||||||
|
**Decision:** dependency comparison routes both operands through
|
||||||
|
`pmacs.path.canonicalize`, which is
|
||||||
|
`editor_core::normalize_buffer_path` itself — absolute, lexically clean,
|
||||||
|
redundant-separator and `.` / `..` folding, with no filesystem access
|
||||||
|
and no symlink resolution. This reuses the buffer registry's canonical
|
||||||
|
form rather than growing a Lua mirror (the COHERENCE §14 / dired Q#DR2
|
||||||
|
rule).
|
||||||
|
|
||||||
|
Only the **comparison** is normalized. The plan item retains the decoded
|
||||||
|
path for execution, so this correction does not silently widen Q#RD10's
|
||||||
|
raw phase-4 reconciliation or resolve symlinks. Section 9.7's two raw
|
||||||
|
execution-path findings remain exactly as scoped there.
|
||||||
|
|
||||||
|
### 10.2 Zero completed items does not prove zero mutation
|
||||||
|
|
||||||
|
Section 9.4's `applied_op_count` reports plan items that completed
|
||||||
|
before a failure. Its first renderer treated `0` as proof that nothing
|
||||||
|
was mutated. That inference is false **inside one failing item**:
|
||||||
|
|
||||||
|
- a `TextDocumentEdit` contains multiple buffer edits applied
|
||||||
|
sequentially, so an intercept can accept the first and reject the
|
||||||
|
second after the first edit changed the buffer;
|
||||||
|
- a resource primitive can have intermediate filesystem effects before
|
||||||
|
its terminal error — today the rename arm creates destination parents
|
||||||
|
before attempting the rename, so a missing source can leave a new
|
||||||
|
directory behind.
|
||||||
|
|
||||||
|
Both cases were reproduced through the real server pump with the failing
|
||||||
|
item first in the plan. The response said `aborted, nothing was
|
||||||
|
mutated` while the buffer or filesystem visibly disagreed.
|
||||||
|
|
||||||
|
**Decision:** the failure result now carries
|
||||||
|
`execution_started` independently of `applied_op_count`. Only parse and
|
||||||
|
plan-time failures render `nothing was mutated`. Once execution starts,
|
||||||
|
the renderer is deliberately conservative:
|
||||||
|
|
||||||
|
- with completed items, it says those earlier changes remain applied
|
||||||
|
and the failing item may also have changed state;
|
||||||
|
- with zero completed items, it says the first operation may have
|
||||||
|
changed state before failing.
|
||||||
|
|
||||||
|
This does not claim that every failing primitive mutates. It refuses to
|
||||||
|
make a stronger recovery claim than the applier can prove, and one
|
||||||
|
renderer still serves the server response, rename status, and code
|
||||||
|
action status.
|
||||||
|
|
||||||
|
### 10.3 Acceptance added by this round
|
||||||
|
|
||||||
|
21. **Lexically equivalent dependency paths are related** (§10.1).
|
||||||
|
The server sends `create /dir/./x → delete /dir/x → create witness`;
|
||||||
|
the batch succeeds, `x` is gone, and the witness exists.
|
||||||
|
*Bite:* fails against raw-string `paths_related`, which preflights
|
||||||
|
`/dir/x` against the initial filesystem and refuses with `NotFound`.
|
||||||
|
|
||||||
|
22a. **A failing multi-edit item is reported conservatively** (§10.2).
|
||||||
|
One `TextDocumentEdit` carries two replacements; a deterministic
|
||||||
|
intercept accepts the higher-offset edit and rejects the second.
|
||||||
|
The first edit remains in the buffer, `applied` is false, and the
|
||||||
|
reason must not say nothing was mutated.
|
||||||
|
*Bite:* fails when `applied_op_count == 0` alone selects the
|
||||||
|
no-mutation message.
|
||||||
|
|
||||||
|
22b. **A failing resource item is reported conservatively** (§10.2).
|
||||||
|
A rename with an absent source and a destination under a new parent
|
||||||
|
fails after creating that parent. The directory remains and the
|
||||||
|
reason must acknowledge possible state change.
|
||||||
|
*Bite:* fails against a text-edit-only repair, or any renderer that
|
||||||
|
still equates zero completed resource items with zero mutation.
|
||||||
|
|
||||||
|
### 10.4 Coherence and scope
|
||||||
|
|
||||||
|
This round still serves **COHERENCE §1.2's silence asymmetry** and
|
||||||
|
§23's requirement that background computation not become opaque: it
|
||||||
|
makes the already-added server/user failure trace truthful. It touches
|
||||||
|
no new golden-journey step, adds no interaction island, adds no setting,
|
||||||
|
and creates no background work. No config-registry, ownership, or
|
||||||
|
protocol change follows.
|
||||||
|
|
|
||||||
|
|
@ -9669,3 +9669,180 @@ fn rd20_the_user_facing_message_reports_partial_application() {
|
||||||
);
|
);
|
||||||
assert!(a.exists(), "the file survives the refused delete");
|
assert!(a.exists(), "the file survives the refused delete");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Criterion 21 (review round 2) — batch dependency comparison uses
|
||||||
|
/// the same lexical path form as the buffer registry. Distinct URI
|
||||||
|
/// spellings such as `/tree/./x` and `/tree/x` reach the same
|
||||||
|
/// filesystem entry and therefore must be treated as the same target.
|
||||||
|
///
|
||||||
|
/// Bite: fails against raw-string `paths_related`, which judges the
|
||||||
|
/// delete against the initial filesystem, fabricates `NotFound`, and
|
||||||
|
/// refuses the legal ordered batch before its create runs.
|
||||||
|
#[test]
|
||||||
|
fn rd21_equivalent_dot_path_create_then_delete_is_not_preflight_refused() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let anchor = dir.path().join("anchor.rs");
|
||||||
|
let victim = dir.path().join("victim.rs");
|
||||||
|
let witness = dir.path().join("witness.rs");
|
||||||
|
std::fs::write(&anchor, b"anchor\n").expect("write anchor");
|
||||||
|
let dot_uri = format!("file://{}/./victim.rs", dir.path().display());
|
||||||
|
assert_ne!(
|
||||||
|
dot_uri,
|
||||||
|
rd_uri(&victim),
|
||||||
|
"fixture: the URI spellings must differ"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut state = pmacs::editor::EditorState::new();
|
||||||
|
let plan = serde_json::json!({
|
||||||
|
"documentChanges": [
|
||||||
|
{ "kind": "create", "uri": dot_uri },
|
||||||
|
{ "kind": "delete", "uri": rd_uri(&victim) },
|
||||||
|
{ "kind": "create", "uri": rd_uri(&witness) }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
let sink = rd_plan_server(&mut state, dir.path(), &plan);
|
||||||
|
rd_open(&mut state, "B", &anchor);
|
||||||
|
rd_wait_initialized(&mut state);
|
||||||
|
|
||||||
|
rd_trigger_apply_edit(&mut state);
|
||||||
|
let response = rd_wait_response(&mut state, &sink, 10);
|
||||||
|
assert!(
|
||||||
|
rd_applied(&response),
|
||||||
|
"lexically equivalent paths name the same target, so the ordered \
|
||||||
|
create/delete batch is legal: {response:?}"
|
||||||
|
);
|
||||||
|
assert!(!victim.exists(), "the created target was deleted");
|
||||||
|
assert!(
|
||||||
|
witness.exists(),
|
||||||
|
"THE BITE: the batch ran past the delete, so success is not vacuous"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Criterion 22a (review round 2) — zero COMPLETED plan items does not
|
||||||
|
/// imply zero mutation. Text edits within one `TextDocumentEdit` run
|
||||||
|
/// sequentially, so a later edit can reject after an earlier one
|
||||||
|
/// changed the buffer.
|
||||||
|
///
|
||||||
|
/// Bite: fails against a renderer that keys "nothing was mutated" only
|
||||||
|
/// on `applied_op_count == 0`.
|
||||||
|
#[test]
|
||||||
|
fn rd22a_partial_edits_inside_one_item_are_reported_conservatively() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let target = dir.path().join("target.rs");
|
||||||
|
std::fs::write(&target, b"abcdef\n").expect("write target");
|
||||||
|
|
||||||
|
let mut state = pmacs::editor::EditorState::new();
|
||||||
|
let plan = serde_json::json!({
|
||||||
|
"documentChanges": [{
|
||||||
|
"textDocument": { "uri": rd_uri(&target), "version": 1 },
|
||||||
|
"edits": [
|
||||||
|
{
|
||||||
|
"range": {
|
||||||
|
"start": { "line": 0, "character": 4 },
|
||||||
|
"end": { "line": 0, "character": 5 }
|
||||||
|
},
|
||||||
|
"newText": "X"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"range": {
|
||||||
|
"start": { "line": 0, "character": 1 },
|
||||||
|
"end": { "line": 0, "character": 2 }
|
||||||
|
},
|
||||||
|
"newText": "Y"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
let sink = rd_plan_server(&mut state, dir.path(), &plan);
|
||||||
|
rd_open(&mut state, "B", &target);
|
||||||
|
rd_wait_initialized(&mut state);
|
||||||
|
state
|
||||||
|
.lua_host
|
||||||
|
.lua()
|
||||||
|
.load(
|
||||||
|
"N = 0 \
|
||||||
|
pmacs.buffer.add_intercept(B, function(op) \
|
||||||
|
N = N + 1 \
|
||||||
|
if N == 2 then error('second edit rejected') end \
|
||||||
|
return op \
|
||||||
|
end)",
|
||||||
|
)
|
||||||
|
.exec()
|
||||||
|
.expect("install deterministic second-edit failure");
|
||||||
|
|
||||||
|
rd_trigger_apply_edit(&mut state);
|
||||||
|
let response = rd_wait_response(&mut state, &sink, 10);
|
||||||
|
assert!(!rd_applied(&response), "the second edit rejects");
|
||||||
|
let text: String = state
|
||||||
|
.lua_host
|
||||||
|
.lua()
|
||||||
|
.load("return B:slice(0, B:len())")
|
||||||
|
.eval()
|
||||||
|
.expect("read back");
|
||||||
|
assert_eq!(
|
||||||
|
text, "abcdXf\n",
|
||||||
|
"THE BITE: the first edit in the same item remains applied"
|
||||||
|
);
|
||||||
|
let reason = rd_reason(&response);
|
||||||
|
assert!(
|
||||||
|
reason.contains("first operation") && reason.contains("changed state"),
|
||||||
|
"the response must conservatively report the failing item's possible \
|
||||||
|
mutation: {reason:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!reason.contains("nothing was mutated"),
|
||||||
|
"the response must not deny the mutation visible in the buffer: {reason:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Criterion 22b (review round 2) — the same conservative reporting
|
||||||
|
/// covers a resource primitive. The rename arm creates destination
|
||||||
|
/// parents before attempting the rename, so a missing source can leave
|
||||||
|
/// a directory behind even though the first plan item failed.
|
||||||
|
///
|
||||||
|
/// Bite: fails against a text-edit-only repair that still reports
|
||||||
|
/// "nothing was mutated" for a failing resource operation.
|
||||||
|
#[test]
|
||||||
|
fn rd22b_a_failing_resource_item_can_leave_filesystem_state() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let anchor = dir.path().join("anchor.rs");
|
||||||
|
let missing = dir.path().join("missing.rs");
|
||||||
|
let created_parent = dir.path().join("created-parent");
|
||||||
|
let destination = created_parent.join("destination.rs");
|
||||||
|
std::fs::write(&anchor, b"anchor\n").expect("write anchor");
|
||||||
|
assert!(!missing.exists(), "fixture: rename source must be absent");
|
||||||
|
assert!(
|
||||||
|
!created_parent.exists(),
|
||||||
|
"fixture: destination parent must start absent"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut state = pmacs::editor::EditorState::new();
|
||||||
|
let plan = serde_json::json!({
|
||||||
|
"documentChanges": [{
|
||||||
|
"kind": "rename",
|
||||||
|
"oldUri": rd_uri(&missing),
|
||||||
|
"newUri": rd_uri(&destination)
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
let sink = rd_plan_server(&mut state, dir.path(), &plan);
|
||||||
|
rd_open(&mut state, "B", &anchor);
|
||||||
|
rd_wait_initialized(&mut state);
|
||||||
|
|
||||||
|
rd_trigger_apply_edit(&mut state);
|
||||||
|
let response = rd_wait_response(&mut state, &sink, 10);
|
||||||
|
assert!(!rd_applied(&response), "renaming an absent source fails");
|
||||||
|
assert!(
|
||||||
|
created_parent.is_dir(),
|
||||||
|
"THE BITE: the primitive created its destination parent before failing"
|
||||||
|
);
|
||||||
|
let reason = rd_reason(&response);
|
||||||
|
assert!(
|
||||||
|
reason.contains("first operation") && reason.contains("changed state"),
|
||||||
|
"the response must conservatively report possible filesystem effects: \
|
||||||
|
{reason:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!reason.contains("nothing was mutated"),
|
||||||
|
"the response must not deny the directory left on disk: {reason:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue