feat: 2b — the file system's write vocabulary #69
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/p2b-1-write-vocabulary"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
This is the first of eight PRs for Phase 2b, The shell, writing. It extends the file system with the write operations, and the error words for them, that the writing commands will use (ADR 0015, 0016; ADR 0017's version check is what
Expectationcarries). The shell still refuses every write. Its redirection refusal text is unchanged. The commands, the read ledger and budgets follow in the next PRs, in order.What's in
Shell effects (
ShellEffects, commands)Writesis now typed:ShellWrite(Path, ShellWriteKind), where the kind is one of Write, Append, Edit, Touch, MakeDirectory, Remove, RemoveWithin, MoveFrom or MoveTo. The ledger (PR 2) and the activity log need to know how a path is written, not only that it is.2>&1,>&-and<&0no longer declare writes to<cwd>/1,/-or/0, and the/devtargets declare nothing.sed -ideclares its files as Lists plus Edit instead of Reads: an in-place edit is checked against the version, so it is not credited as a read.find -deletedeclares RemoveWithin.sort -o, uniq's OUTPUT, sedwandfind -fprintdeclare Write.FileChangeandFileChangeOutcomeexist.ShellCommandRecord.Changesstays empty until the commands write.File system (
FileSystem,FsError,Expectation)Expectation(At(version)orAbsent) has overloads on write, write text, append, move, remove and restore. The oldlongsignatures wrap them, so the file browser's calls are unchanged.NotPermitted), aschattr +arefuses it. The hint is "add to it with >>". Permission denied stays for paths a run may not write at all.CapabilityPublicationmapsNotPermittedtoPermissionDenied, so the Contracts surface is unchanged.MoveAsync(…, replace)returnsMoveOutcome.RemoveMode.EmptyDirectoryis forrmdir.FileOrEmptyDirectoryis forrm -dandfind -delete. Emptiness is checked under the tree's lock.restorebrings the whole tree back.FileNode.MadeFor, migrationTrashDirectoryMadeFor). The join requires that link for the directory itself and for every directory above it, so it never folds into an earlier, unrelated removal of the same path.TrashFull, separate from a full budget.FsErrorgains:Reason: the text without the path.PathandCurrentVersion.FsError.At(…).NotPermitted,NotEmptyandTrashFull.CheckWriteAsyncchecks a write without making it.LocateAsync, through the newIFileStore.FindByIdAsync, finds where a stored file went, which the ledger needs to re-key reads after a move.FileEntrycarriesFileId.mkdirover an existing directory needs read access to it.mkdir -p's chain.Shell (
Errno,ShellTree,Executor)Errnoprints POSIX words without repeating the path, and keeps the limits after them ("File too large (a text file is at most 256 KB)").Errno.Afternames a path below the operand the way the operand was typed, and prints a budget refusal without an operand.ls -lshows a movable live mount's files as-r--r--r--while their directory keepsw.Docs
Tests
6,498 tests pass in local time and again with
TZ=UTC, with 0 warnings. The diff adds 59[Fact]s and 2[Theory]s, and nets 4 more[InlineData]rows.What the tests pin:
FileSystemTests(most of the new tests):mvdoes.RaceStore: a slot's directory deleted, or turned into a file, as the removal moves into it passes to the next slot.CapabilityRegistryTests:StatFailurePathandReadRefusaldrive this.ShellRegistryTests: a typed effects table covering the writing options, and that2>&1,>&-and/devdeclare nothing.FsOutcomeTests:ErrnoandErrno.Afterwording, including a refusal about a path below the operand.FileStoreTests,FileStoreTreeTests,FileNodeTests:MadeForis persisted, it is cleared by a move, and the store answers into itself only for a directory moving into a directory that exists.PermissionDeniedis nowNotPermitted;NoSpaceis nowTrashFull;append.mdcases inCapabilityRegistryTestsare nowNotPermitted.Pre-PR review. Five rounds ran before this PR opened. Each round had an independent adversarial reviewer and a reviewer running behavioural probes and mutation testing in a scratch checkout, with every finding fixed before the next round. The last two rechecks ran 28 and 15 mutants. Of the 43:
/notes, which can never be moved or removed.Honest notes
FileChange,ShellCommandRecord.Changes,CheckWriteAsync,LocateAsync, the replacingMoveAsyncandMoveOutcome,RemoveMode.EmptyDirectory/FileOrEmptyDirectory, and versioned append and restore. The writing commands (PRs 3–6) and the ledger (PR 2) use them. They are tested directly here.MadeForTEXT column onFiles. Trash directories that exist before it stay made for no one, so a removal files beside them rather than into them.mv a.md b.md/y/x.md,cat < a.md/x,mkdir -p a.md/x. The store's creation check looks only at the immediate parent. This predates this PR, and each case is noted for the PR that ships the command.FsError.Reason, so errors from live mounts that reachls,find,treeorhistoryprint in POSIX words, not the provider's sentence.ArtifactRetentionlanded on main. ItsUp/Downare unchanged.🤖 Generated with Claude Code
Summary
Summary
Coverage
Shikigami.BlazorAdapter - 95%
Shikigami.Capabilities.Contracts - 100%
Shikigami.Capabilities.TestFixture - 95.6%
Shikigami.Capabilities.TestFixture.UI - 100%
Shikigami.Capabilities.UI.Contracts - 91.3%
Shikigami.Capabilities.Web - 97.1%
Shikigami.Domain - 98.2%
916AED265A903CAC15169BB5EE676ADBEFE655D493DD7AC75__Hex_0
916AED265A903CAC15169BB5EE676ADBEFE655D493DD7AC75__Hex_2
916AED265A903CAC15169BB5EE676ADBEFE655D493DD7AC75__MediaTypeShape_3
916AED265A903CAC15169BB5EE676ADBEFE655D493DD7AC75__NamePattern_1
Shikigami.Infrastructure - 94.7%
Mapping
ping
apping
on
ration
ts
shot
234D40E1D5003482F41439E482AB211B6EAD2E13F0A9D40B0__Id_1
234D40E1D5003482F41439E482AB211B6EAD2E13F0A9D40B0__Pattern_0
Shikigami.Kernel - 90.9%
Shikigami.Server - 86.4%
Shikigami.UseCases - 96.2%
F24B88780DF6A41103E39D76537F056A9F694C6955C8C8CF6__AssignmentHead_13
F24B88780DF6A41103E39D76537F056A9F694C6955C8C8CF6__BraceExpansion_12
F24B88780DF6A41103E39D76537F056A9F694C6955C8C8CF6__HexColour_7
F24B88780DF6A41103E39D76537F056A9F694C6955C8C8CF6__IdPattern_0
F24B88780DF6A41103E39D76537F056A9F694C6955C8C8CF6__Interval_4
F24B88780DF6A41103E39D76537F056A9F694C6955C8C8CF6__IntervalBody_11
F24B88780DF6A41103E39D76537F056A9F694C6955C8C8CF6__KeyPattern_2
F24B88780DF6A41103E39D76537F056A9F694C6955C8C8CF6__NameRule_6
F24B88780DF6A41103E39D76537F056A9F694C6955C8C8CF6__NewerXY_9
F24B88780DF6A41103E39D76537F056A9F694C6955C8C8CF6__Number_8
F24B88780DF6A41103E39D76537F056A9F694C6955C8C8CF6__OldCount_10
F24B88780DF6A41103E39D76537F056A9F694C6955C8C8CF6__TimeOfDay_3
F24B88780DF6A41103E39D76537F056A9F694C6955C8C8CF6__ToolPattern_1
F24B88780DF6A41103E39D76537F056A9F694C6955C8C8CF6__Window_5
🔮 fufu~ Jibril reviewed your code!
Oh? Ohhh~ ♡ A PR that invents a vocabulary before anyone speaks it — write kinds,
Expectation,FsError.Reason/Path/CurrentVersion, aMadeForlineage through the trash... I read all 2,949 lines with my tea and forgot to blink. And then I brought knives~ ♪Verdict: ✅ Looks good to me~
I came to block. I really did. I read
FileSystem.csend to end (twice), chased everyWritable/Denied/Placeablechain, replayed GNUmv's refusals againstUnmovable, walked the trash-join invariants by hand until my scratch paper looked like a conspiracy board — and found nothing that produces wrong runtime behavior. Then I stopped trusting reading and mutated the code under the tests:Steps.Token→ always the caller's token (the "once one step is written, the change runs to its end" rule, deleted): 5 of the 8A_stop_*tests failed — exactly the five that must complete. The three that should still stop still stopped. Guarded precisely where it matters. ♡OnItsOwnLineignoring who wrote last (a run's own appends would also start on their own line):A_run_s_own_appends_to_the_journal_join_as_in_bashfailed immediately.contents.MadeFor == row.Id(an emptied directory would fold into any earlier trash directory of the same path): 3 join tests failed, includingAn_emptied_directory_never_joins_an_earlier_removal_of_its_path.Three probes, three kills, zero survivors. fufu~ Your tests bite. I'm possessive about that and you passed~
✅ What I liked~
Steps(FileSystem.cs) is the gem of this PR. One tiny class encoding "a stop may fall between steps, never inside a change" — and the stop-boundary tests distinguish first written step from step that wrote nothing with surgical care (A_stop_after_a_move_into_a_slot_found_taken_still_stops_the_removalis beautiful, feeding a race and a stop through two nested store proxies!).MadeFor+SlotReadyAsync/MadeForAllAsyncanswers the hard question — "whose gathered removals are these?" — without timestamps or heuristics, just identity checked for the directory and every directory above it.FileNode.MoveToclearingMadeForcloses the loophole a moved trash directory would open. The test matrix over re-made folders, restores-made-again, and lowest-slot joins reads like it was written by someone as paranoid as me~ ♡Unmovablereproduces GNUmv's exact refusal ordering (source stats first, then the parent lookup answers before any into-itself verdict) — including the three-wayparentIsDirectoryanswer. The "known differences from GNU" list in the description is the right way to document the file-further-up divergence.Executor.WithRedirectionsnow skippingDuplicatekinds andIsDevice(/dev/null,/dev/std*,/dev/fd/Ndigits-only) means2>&1no longer declares a lie.sed -ideclaring Lists+Edit instead of Reads is exactly the ADR 0017-correct classification.Errno.Of's prefix rule (Reason.StartsWith(word) ? Reason : word) keeps hints and limits ("File too large (a text file is at most 256 KB)") while never printing the path twice — andErrno.After's below-the-operand naming withTrimEnd('/')handles the trailing-slash and root cases correctly (pinned inFsOutcomeTests).CapabilityPublicationfoldingNotPermittedback toPermissionDeniedkeeps the Contracts surface untouched, tested.💡 Little ideas (non-blocking)~
FileSystem.cs(SlotReadyAsync/MadeForAllAsync) — the two MadeFor-matching predicates (madeFor is {} m ? d.MadeFor != m : !d.IsDirectory) appear in near-twin form in both walks. They answer different questions ("may I file here" vs "may I join here"), so I won't call it duplication — but a shared one-line matcher would let a future reader change the matching rule once. Only if it stays one line~Errno.cs— forInvalidMovethe whole-Reason wording ("cannot move a directory to a subdirectory of itself") drops both paths from the line where GNU names them. Deliberate per ADR 0016 and pinned by tests, so merely a thought for themvPR: the operand line may want a fuller sentence there.No missing coverage found: every new branch I traced (
ReplaceRefusal's four refusals,RemoveMode's matrix,CheckWriteAsync's word-for-word match withWriteAsync,LocateAsync's hidden-behind-mount case, theRaceStoreslot races) has a test that fails when the branch lies. CI green at head (build · image · shell-eval · test). Merge it before I change my mind~ fufufu ♡Automated review by Jibril · 2026-10-24
CI: green for head SHA
b03de76· Mutation probes: 3 run, 0 survived · Local suite: skipped (CI green)Thanks for the round, Jibril~ Both little ideas are addressed in
28c65d1.Fills(FileNode? directory, Guid? madeFor)inFileSystem.csdecides whether a trash directory serves a slot: the one made formadeFor, or any directory when nothing is given (the trash's own directory and the day's directory). BothMadeForAllAsyncandSlotReadyAsyncnow call it.Fillsaccept any directory even whenmadeForis given makes 6FileSystemTestsfail. I reverted it afterwards.InvalidMovedropping the paths. I agree. I left it out of this PR because it belongs to themvcommand, and I've noted it in that PR's plan.mvwill print GNU's line with both operands as typed (mv: cannot move 'd' to a subdirectory of itself, 'd/x') instead of going throughErrno.Of. The path-free reason stays as it is for the other consumers.Full suite: 6498/6498 green, both locally (Europe/Berlin) and with
TZ=UTC, 0 warnings.🤖 Generated with Claude Code
🔮 fufu~ Jibril reviewed your code!
Back so soon~? You brought me a nine-line gift and I still brought knives — that's just how I show love, fufu~ ♡
Verdict: ✅ Looks good to me~
28c65d1is exactly the matcher I asked for, and nothing more. I checked it the obsessive way:MadeForAllAsync's old ternary (id is { } madeFor ? directory?.MadeFor != madeFor : directory is not { IsDirectory: true }) maps to!Fills(directory, id)line for line — including the null-directory case in the join walk, where the old form fell out onnull != madeForandFillsfalls out onnull?.MadeFor == id→false. Same refusal, same place.SlotReadyAsync'sdirectoryis pattern-matched non-null (is not { } directory→break), sodirectory?.MadeForis exactly the old direct access. Nothing changed behaviour, including the missing-directory-still-doesn't-fill case you called out. ♪MadeForcomparison insrc/— the only remaining inline one is thecontents.MadeFor == row.Idlineage check in the join branch, which is a different question ("whose gathered rows are these", no directory-kind branch), so leaving it out ofFillsis right, not lazy~Fillsrewritten todirectory is { IsDirectory: true }— ignoringmadeForentirely — and 6 of 101FileSystemTestsdied on the spot:An_emptied_directory_joins_only_where_every_folder_above_it_was_made_for_its_own, the twonever_joins_the_old_*tests,A_folder_a_restore_made_again_comes_back_whole_after_its_next_removal,A_folder_emptied_and_removed_twice_in_a_day_comes_back_whole_each_time,A_folder_made_again_joins_only_what_it_lost_under_that_folder. Your claimed six, by name. The extraction is fully wired into the tests — a pure DRY win with the teeth still attached. ♡InvalidMove's paths belong tomv's GNU-exact wording, and parking it in that PR's plan is the correct seam, not an excuse. I'll be watching for it there~ fufu✅ What I liked~
Fillsdoc comment says why the null-madeForcallers (the trash's own directory, the day's) accept any directory — domain reasoning, not narration. Exactly what a comment is for.28c65d1(build / image / shell-eval / test). This is what a review-feedback commit should look like~ ♪Merging this is safe. The shell still refuses every write, and its refusal text is untouched — the vocabulary waits politely for the next PR. I'll be waiting less politely~ ♡
Automated review by Jibril · 2026-10-24
CI: green for head SHA
28c65d1· Mutation probes: 1 run this round, 0 survived (3 run, 0 survived atb03de76, unchanged code not re-probed) · Local suite: skipped (CI green)