Skip to content

Gate unmodeled File/IO/Dir instance methods instead of NoMethodError (issue #27) - #28

Open
sam-xif wants to merge 4 commits into
nori/vfs-step2-file-openfrom
nori/fix-27-unmodeled-file-io-dir-gate
Open

sam-xif wants to merge 4 commits into
nori/vfs-step2-file-openfrom
nori/fix-27-unmodeled-file-io-dir-gate

Conversation

@sam-xif

@sam-xif sam-xif commented Oct 5, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes #27: a File object from File.open answered NoMethodError for every real File/IO instance method the model does not implement — path, fileno, eof?, binmode, reopen, stat, … — where CRuby returns an ordinary value. NoMethodError is inside the type-error family the validator claims to exclude, so File.open(p,"r").path was a wrong answer, not a gate.

Found while landing step 2 of #7 (PR #26, on whose branch this stacks). Step 1 modeled File/Dir singleton methods and step 2 the descriptor I/O, but no File/IO instance method outside those surfaces was considered, and the IO/File classes carried no refusal for the rest.

Root cause

The VFS classes were added to the boot heap and their modeled methods installed, but IO/File/Dir were never added to the generated CRuby-name tables that Interp.crubyShadow walks on a lookup miss (RubyCore/CRubyNames.lean only listed them as Object constants). The shadow never fired, so the miss fell through to the genuine-NoMethodError arm.

Fix (data-only, reuses the existing shadow mechanism)

  • scripts/gen_cruby_names.rb FOLD gains IO, File, Dir, folding in only the modules the L0 chain omits (File::Constants, Enumerable). IO is deliberately not folded into File — it is already in File's model chain, and folding it would make crubyClassDefines "File" "close" true and spuriously gate the modeled File#close/#read, which resolve to IO.
  • RubyCore/CRubyNames.lean regenerated (additive: 65 lines).
  • Ratchet/Static/NativeInstanceNames.lean gains the 71 new instance selectors that Denote/Sem/Names/NativePrefix.lean kernel-checks coverage of.
  • Ratchet/Guards/NativeGuards.lean nativeSingletonNew gains "IO": CRuby's IO.new is a real singleton constructor, and the singleton_covered theorem in Denote/Sem/Names/NativeGuards.lean requires every singleton new to be declared.

Behaviour

program before after CRuby
File.open(p,"r").path NoMethodError (wrong) unmodeled method IO#path (gate) "/tmp/readme.txt"
File.open(p,"r").fileno NoMethodError (wrong) unmodeled method IO#fileno (gate) 5
File.open(p,"r").zzz NoMethodError NoMethodError NoMethodError
File.read(p) / IO#read / #read(n) / #close / #closed? agree agree (unchanged) agree

Verification

  • scripts/run_clink_rebuild.sh → CLINK REBUILD CHECKS PASS (125 certified, 0 gated; standard axioms only).
  • lake build rubycore active-ratchet green; scripts/check-isolation.sh OK.
  • Full scripts/run_typed_ratchet.sh output is identical to the pre-change baseline (80/263 climbed, prefix 17, negatives 46/46, same Sorbet verdict moved set) — this sandbox has an unpinned Sorbet that makes the corpus stage red on the unmodified branch too, so the comparison is baseline-relative.
  • Model-vs-CRuby 4.0.5 byte-for-byte on the step-1/2 modeled surface (File.read/size/exist?/file?/directory?/basename, File.open + read/read(n)/close/closed?); the listed unmodeled methods now gate instead of mis-raising.

Notes

Closes #27. Part of #7 (step-2 follow-up).


Authored by Nori, an autonomous agent.

…fixture

Implements the first shippable step of issue #7: the symbolic virtual
filesystem. A file is a heap object (`Payload.file`/`Payload.dir`), the fixture
is initial data H0' allocated at boot, path resolution is one pure total walk,
and every unmodeled operation is a named Unsupported rather than a guess or a
fabricated Errno.

- New bootstrap classes IO, File < IO, Dir; new payload variants file/dir.
- VFS.components/walk/resolve/lookup in Heap.lean; fixture allocated after main
  and the two realized eigenclasses, with `vfsBase` derived from
  `classTable.length` so new classes shift the fixture ids automatically.
- Builtins/FS.lean: the six File operations plus IO.read/IO.write and
  Dir.exist?, discharged through the prelude's class-method path. File.write
  mutates inode bytes in place and returns the byte count; inspect/to_s of a raw
  file/dir object gate rather than guess CRuby's path-bearing rendering.
- FS singleton methods are installed on their constants' eigenclasses in
  Prelude.boot (like Exception.exception), so they use ordinary dispatch and add
  no `send` special case. Only real CRuby class methods are installed, so
  Dir.read-style typos still NoMethodError exactly as CRuby does.
- Repaired the boot-shift the new classes caused: intMetaId/strMetaId (prelude
  heap ids) 79/88 -> 87/96, ConstantReachControls' concrete heap/Next id, the
  runFS_frame/pathArg?/VFS.lookup root-frame lemmas, and bootGlobalConsts.

Evidence: lake build rubycore + active-ratchet green, #guard bootOkB passes,
run_typed_ratchet.sh prints RATCHET GREEN, check-proofs.sh passes, and the step-1
surface agrees with CRuby byte-for-byte. The hermetic difftest "vfs" envelope and
un-gating the bootstraptest filesystem family (issue #8) are the next step.

Authored by Nori (agent).
The first cut of `VFS.walk` treated `..` as a no-op, so
`File.read("/tmp/vfsdata/../readme.txt")` gated where CRuby answers. Store the
parent in the `dir` payload (root's is `none`) and climb it, which is the one
definition issue #7 asks for. `.`, `..`, `./c` and `a/b/../c` now agree with
CRuby byte-for-byte.

Authored by Nori (agent).
Add descriptors to the symbolic virtual filesystem of issue #7. A file is
still a heap object; a descriptor is now one too.

- Payload.io (inode, mode, pos, closed) and IOMode (read/write/append/
  readWrite); each descriptor is its own object so per-descriptor position
  and closed-ness are observable, while the inode bytes stay shared.
- File.open non-block, modes r/w/a/r+ (w truncates, a appends, r+ reads
  then overwrites at pos). IO#read (to EOF, or at most n, with the CRuby
  "" vs nil split), IO#write (byte count), IO#close, IO#closed?, IO#flush.
- Class-object receivers keep the step-1 File.read/write and IO.read/write
  paths; .io receivers take the descriptor path.
- File#open joins blockSensitiveBids: the block form (step 4) gates rather
  than silently running the block zero times.
- Repr/Trace/nativeReprOwner gained the .io arm, still gating on rendering
  a descriptor (CRuby's File#inspect is path-bearing).
- openIO allocates and mutates the heap, so runFS_frame needed a new
  openIO_frame lemma; it joins rootFrameLem and closes the same way.

Missing path, closed stream, wrong-mode read, negative read length and the
block form all gate with a named reason (Errno/IOError are step 7).

Verified: lake build rubycore; RATCHET GREEN (run_typed_ratchet.sh, with and
without RATCHET_SKIP_AGREEMENT=1); check-isolation.sh OK; check-proofs.sh OK
(standard axioms). Model-vs-CRuby 4.0.5 byte-for-byte on a "hello\n" fixture
for read/read(n)/read(0)/EOF, w-truncate, a-append, r+ read-then-write, and
default mode.
…(issue #27)

A File object from File.open answered NoMethodError for every real File/IO
instance method the model does not implement (path, fileno, eof?, binmode,
reopen, stat, ...), where CRuby returns an ordinary value. NoMethodError is in
the family the validator excludes, so File.open(p,"r").path was a wrong answer
rather than an honest gap.

Root cause: the VFS classes were added to the boot heap and their modeled
methods installed, but IO/File/Dir were never added to the generated CRuby-name
tables that Interp.crubyShadow walks on a lookup miss. The shadow never fired,
so the miss reached the genuine-NoMethodError arm.

Fix is data-only, reusing the existing shadow mechanism:
- gen_cruby_names.rb FOLD gains IO/File/Dir, folding only the modules the L0
  chain omits (File::Constants, Enumerable). IO is deliberately not folded into
  File (already in File's chain; folding it would spuriously gate File#close).
- CRubyNames.lean regenerated (additive).
- NativeInstanceNames.lean gains the 71 new selectors that NativePrefix.lean
  kernel-checks coverage of.
- NativeGuards.nativeSingletonNew gains "IO" (CRuby's IO.new is a real
  singleton constructor, required by the singleton_covered theorem).

Now File.open(p,"r").path gates as `unmodeled method IO#path`; an invented
method (f.zzz) still raises NoMethodError; the modeled surface
(File.read/size/exist?, IO#read/#read(n)/#close/#closed?) is unchanged.

Evidence: run_clink_rebuild.sh CLINK REBUILD CHECKS PASS (125 certified, 0
gated, standard axioms); lake build rubycore active-ratchet green;
check-isolation.sh OK; full run_typed_ratchet.sh identical to the pre-change
baseline. Model-vs-CRuby 4.0.5 byte-exact on the modeled surface.

Closes #27. Part of #7 (step 2 follow-up).

Authored by Nori, an autonomous agent.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants