Repository navigation
fix: atomic, encoding-preserving .al writes with batch rollback for apply_fix and apply_fix_all - #48
Merged
Conversation
…pply_fix and apply_fix_all GuardedFileWriter becomes a DI singleton that writes each file to a sibling <path>.<guid>.alcops.tmp and moves it over the target, so a failure never leaves a truncated .al file, and re-emits the BOM and encoding detected from the bytes read for the stale check (UTF-8 +/- BOM, UTF-16/32 round-trip). apply_fix_all now commits in two phases: stale files still drop out as conflicts, but a write failure restores every file already written in that call from the original bytes and reports all of them. Deviations from #40: no .bak/File.Replace, because almcp's ProjectWatcher treats a move-over as an in-place update (keeps the DocumentId) while replace-with-backup removes and re-adds the document; and the encoding is detected at write time instead of being stored on TrackedDocument. Stray *.alcops.tmp files are swept on project load and on refresh (refresh only when older than 30 s). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- Refuse to re-encode BOM-less files that are not valid UTF-8 (strict decoder; UnsupportedEncoding). - Phase-1 read errors (sharing violation, access denied) become ReadFailed conflicts instead of aborting the batch. - Rollback skips a file modified after this call wrote it and leaves it as is. - FileWriteConflict gains a Kind, serialized as `kind`; apply_fix returns it as `error`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Decode with strict decoders for every detected BOM, not only BOM-less files, so invalid bytes in a UTF-8/16/32 file are refused as UnsupportedEncoding instead of rewritten as U+FFFD. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Roll back on any commit exception, report unencodable fixed text as a per-file UnsupportedEncoding conflict, align wording with the strict decoding introduced in round 2, and attach the re-run hint only to the changed-on-disk conflict. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
apply_fixandapply_fix_allare the only tools that write.alfiles. Onmainthe write was a truncating in-placeFile.WriteAllTextAsyncwith the default encoding, so a UTF-8-with-BOM file lost its BOM (whole-file git diff,git blamedestroyed), a UTF-16 file was silently converted to UTF-8, a crash mid-write could leave a truncated.alfile, and a failed write inapply_fix_allleft the earlier files of the batch modified.This PR makes every write atomic, encoding-preserving and, for
apply_fix_all, transactional:<file>.<guid>.alcops.tmpin the same directory, thenFile.Move(temp, path, overwrite: true). A crash leaves either the old or the new file, never a truncated one. The temp is deleted infinally; a stray one left by a hard kill is swept on the next project load (and on refresh, if older than 30 s).UnsupportedEncodinginstead of being re-encoded with U+FFFD, which is whatmaindid.apply_fix_allstages every file first (changed/deleted/unreadable/non-UTF-8 files drop out asconflicts, the rest proceed, as in fix: refresh cached project sessions from disk and guard fix writes against stale content #37), then commits them one by one. If a commit throws, every file already written in that call is restored from the original bytes held in memory and the whole committed set is reported inconflictswithapplied: false. A rollback that itself fails, or a file edited after this call wrote it, is named inmessageand stays infilesChanged.conflictsentries gain akind(StaleFile,UnsupportedEncoding,ReadFailed,WriteFailed,RolledBack,NotWritten);apply_fixreturns that kind aserror. Additive to the response shape; One error envelope and a fixed error vocabulary for all native tools #41 may rename the vocabulary.Design notes
Two deliberate deviations from the issue text:
.alcops.bak, noFile.Replace. Verified in the NAV SDK source: almcp'sProjectWatcher(Filter = "*.al", Renamed handler checksEndsWith(".al")) treats a move-over as an in-place update that keeps theDocumentId, whereasFile.Replacewith a backup produces two renames, so the watcher removes the document and re-adds it under a newDocumentId, with the document briefly missing. A backup file would also sit in the user's source tree for the whole batch. Rollback therefore works from the original bytes kept in memory, through the same temp + move path.TrackedDocumentorSourceText. The writer has to read the file for the stale check anyway; the encoding of that read is what gets written back. That is always correct (an editor flipping the BOM between load and write is honoured), needs no plumbing through the fix pipeline, and does not depend on every code-fix provider propagatingSourceText.Encoding(ChangedTextdoes,SourceText.From(string)does not).Other facts established while planning: no Microsoft tool writes
.alfiles (the LSP returnsWorkspaceEdits to VS Code;al_writetranslationwrites XLIFF in place), and the compiler accepts UTF-8 with or without BOM and UTF-16/32 with BOM, so preserving any detected BOM is safe. Neither our*.alenumeration nor almcp'sPath.GetExtension == ".al"check matches the temp name.Accepted limitations (documented in
GuardedFileWriterremarks / AGENTS.md): the stale-check → move window is still best-effort against a concurrent writer (out of scope per the issue);File.Movereplaces a symlink at the target path with a regular file and does not preserve Unix mode bits or NTFS ACLs; the temp is not written withWriteThrough, so a power loss immediately after the rename can still lose the new content on some file systems (optional hardening, not done).Review notes
Three Sonnet
/code-review highrounds ran before this PR was opened; the last one found no blocking defect.Round 1 (fixed in
fix: address review round 1):UnsupportedEncoding).apply_fix_allinstead of skipping that file (regression vs fix: refresh cached project sessions from disk and guard fix writes against stale content #37, fixed:ReadFailed).Carried, not fixed:
.alfiles are replaced by regular files on write (documented).Round 2 (fixed in
fix: address review round 2):StreamReaderswapped in its lossy encoding when a BOM was present, so a BOM'd file with one invalid byte was still re-encoded as U+FFFD. Decoding now sniffs the BOM itself and uses a strict decoder for every case (UTF-32 LE/BE, UTF-8 BOM, UTF-16 LE/BE, UTF-8 without BOM).Observed outside this diff (pre-existing, not changed here):
CodeFixRunner.ApplyFixAllAsynctakesOriginalContentfrom the live workspace rather than the snapshot the fix was computed from. If a concurrent call refreshes the workspace with an external edit while a fix-all is running, the stale check compares against the newer text and the fix computed from the older text can overwrite the edit. Candidate for a follow-up.Round 3 (fixed in
fix: address review round 3, all low severity):UnsupportedEncodingconflict.Accepted as designed (round 3):
LoadProjectAsyncsweeps with no age guard (a second server instance on the same folder mid-write is out of scope); the refresh sweep is a second directory walk; a commit failure in single-fileapply_fixsurfaces as{ error: "IOException" }via the outer catch rather than aWriteFailedconflict (#41 unifies the vocabulary).Test plan
dotnet test --configuration Release: 257 → 293 tests, all passing locally with BC DevTools installed (the almcp-backed tests ran rather than skipped).GuardedFileWriter: UTF-8 BOM / no BOM / UTF-16 LE+BE / UTF-32 LE+BE byte-exact round-trips; CRLF and LF; BOM never causes a falseStaleFile; no temp left after success; move failure leaves the target untouched and no temp; batch all-fresh / one-stale / second-of-three-fails-rolls-back-first (first file carries a BOM to prove byte-level restore) / rollback-fails-reported / stale-and-failure; invalid UTF-8 refused (single and in a batch); locked file →ReadFailed(Windows-only); edited-after-commit not rolled back; conflict kinds on rollback.apply_fixkeeps a BOM, returnsIOExceptionwith the file intact on a move failure, returnsUnsupportedEncodingon a non-UTF-8 file;apply_fix_allmixed encodings per file, second commit fails → all three restored and reported with kinds, no temp files left.*.alcops.tmpswept on load and on refresh (age guard respected,.alpackagesuntouched), temp siblings never enumerated as.al.Fixes #40
🤖 Generated with Claude Code