Skip to content

Record fs.lstatSync/statSync kind as payload-free stat records#66

Merged
ChALkeR merged 2 commits into
mainfrom
claude/lstat-sync-recording-chst5u
Jul 17, 2026
Merged

Record fs.lstatSync/statSync kind as payload-free stat records#66
ChALkeR merged 2 commits into
mainfrom
claude/lstat-sync-recording-chst5u

Conversation

@exo-nikita

Copy link
Copy Markdown
Collaborator

Summary

Extends the --fs filesystem capture feature to record the kind (file vs directory) of paths that are stat'd but never read or imported. These are stored as payload-free stat records (stat:file/stat:directory in formats) that allow the bundle to serve isFile()/isDirectory() type checks at load time even after the file is deleted from disk.

Key Changes

  • New stat record format: Added stat:file and stat:directory as payload-free formats in KNOWN_FORMATS. These records attest only to a path's existence and kind, with no bytes or hash entries.

  • Capture-side recording:

    • Implemented state.addFsStat(url, kind) to record stat observations as weak, payload-free entries
    • Added captureStat() helper in fs.js to capture the kind from real Stats objects
    • Patched fs.lstatSync() and fs.statSync() to record observed kinds during capture while passing through real Stats to the caller
    • Supports both sync and async forms of lstat/stat
  • Load-side serving:

    • Extended getFsStat() to return the recorded kind for stat:* format entries
    • Added hasFsFileContent() to distinguish between actual file content vs payload-free stat records (used by resolution shims and sidecar skip logic)
    • Stat records contribute to implied directory index, so ancestor directories are answered even if never explicitly stat'd
  • Format upgrade logic:

    • When a path is both stat'd and read in the same run, the content record supersedes the stat record (via addFile/addFsDir dropping stale stat entries)
    • When a prior capture attested a path as stat-only and a re-run reads it, the weak stat baseline upgrades to the real format without triggering a format-flip abort
    • Implemented mergeFormat() in #mergedFormats() to handle stat-to-content upgrades across lockfile baselines and sidecar merges
  • Security & consistency:

    • Stat records of symlinks that escape the root are not attested (mirrors byte-reader security stance)
    • Symlinks themselves record nothing (symlink kind is not modelled)
    • Kind flips within stat records (stat'd as file, later as directory) conflict and taint capture
    • Child-process stat records flow through the shard channel via shardSnapshot and mergeShard
  • Documentation: Updated file-formats.md and fs.js comments to explain the new stat record semantics and their role in serving type checks at load time.

Notable Implementation Details

  • Stat records are intentionally weak: they never conflict with real content records and are superseded by them, allowing flexible capture ordering (check-then-read or read-then-check patterns work identically)
  • The addFsStat() method guards against recording beside existing content (hashes, sources, or resources) to maintain the invariant that stat records carry no payload
  • Implied directory inference now includes stat records, so a stat:file at node_modules/dep/index.js implies node_modules and node_modules/dep are directories
  • Single-argument form only: lstatSync(path, options) with options is out of scope and passes through untouched

https://claude.ai/code/session_017z3Eb4Udqc5BkdQtt2YA72

…ords

lstatSync/statSync (and their async forms under --fs=async) were
serve-only: a path that was ONLY stat'd -- never read, readdir'd, or
imported -- was not recorded, so on a bundle=load run its stat fell
through to the real fs and threw ENOENT once the path was gone from
disk.

Capture now records the observed KIND as a payload-free stat record:
formats[file] = 'stat:file' | 'stat:directory' -- no bytes, no hash, no
module-files entry. That is exactly what the load-side shim serves (the
type getters isFile()/isDirectory(); every other Stats member stays
real-if-on-disk / benign-synthetic-once-gone), so only type getters are
supported, and the existence probes (existsSync/accessSync/realpathSync)
answer from the record too. The caller always receives the real Stats
(and real errors) at capture; symlinks/sockets/FIFOs aren't modelled and
record nothing; escaping-symlink and sidecar-attested paths are skipped
like the byte readers; options forms (bigint, throwIfNoEntry) pass
through uncaptured.

Stat records are deliberately WEAK: real content recorded for the same
path supersedes them -- in-place (addFile/addFsDir drop the stale tag,
so check-then-read never conflicts), across lock=add/bundle=add re-runs
(the format-flip guard and #mergedFormats upgrade a stat baseline), and
at bundle serialization (a record a write-mode sidecar upgraded is
dropped so the bundle keeps cross-checking against the unified
lockfile). addFile refuses an explicit 'stat:*' format, and a forked
child's stat records ride the shard channel: shardSnapshot forwards the
formats entry and mergeShard replays it by re-deriving the kind from
disk.

Since getFsStat now answers 'file' for byte-less records, callers that
mean "the bundle carries this file's bytes" -- the parentless CJS
re-resolution shim and attestedBySidecar -- switch to the new
hasFsFileContent. Implied ancestor directories are derived from stat
records too, stasis build allowlists the new formats, and the loader
fails closed with a dedicated message on importing a stat-only path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017z3Eb4Udqc5BkdQtt2YA72

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Extends Stasis’s --fs capture/load system to persist payload-free stat observations (stat:file / stat:directory) so fs.lstatSync/statSync kind checks (and related existence probes) can be served from the bundle even when the path is later removed from disk.

Changes:

  • Introduces new KNOWN_FORMATS entries for payload-free stat records and plumbs them through capture, merge/upgrade logic, and load-time stat serving.
  • Adds State.addFsStat() plus helpers (getFsStat stat-format branch, hasFsFileContent, merged format upgrade rules, shard replay) to keep stat records weak and safely superseded by real content.
  • Adds comprehensive tests/fixtures and documentation updates covering stat-only behavior, upgrades, async variants, symlink security, and shard-channel propagation.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
tests/fixtures/cli-run-fork-shard/src/worker.js Adds a child-process-only lstatSync probe to validate shard propagation of stat-only records.
tests/fixtures/cli-run-fork-shard/src/statonly.dat Fixture file for the shard stat-only probe.
tests/cli-fs.test.js Adds test coverage for stat-only records, upgrades to content records, async stat capture, symlink escape behavior, and shard forwarding.
stasis/src/cmd/build.js Allows stasis build to accept stat:* formats in artifacts.
stasis-core/src/util.js Adds stat:* to KNOWN_FORMATS and introduces isStatFormat().
stasis-core/src/state.js Implements recording/serving of stat-only records, weak-upgrade merge logic, sidecar skip correctness (hasFsFileContent), implied-dir inference for stat records, and shard replay.
stasis-core/src/hooks.js Improves error messaging when attempting to import a stat:* format, and adjusts CJS resolution to require byte content.
stasis-core/src/fs.js Captures stat kind during lstat/stat in write mode, while serving kind from bundle in load mode (sync + async).
doc/file-formats.md Documents stat-only record semantics and their role in --fs capture/load.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread stasis-core/src/state.js Outdated
mergeShard replayed a child's payload-free stat records with lstatSync,
which reports the link itself for a path that is a symlink on disk --
but a child's statSync capture follows links and records the TARGET's
kind keyed at the requested path (pnpm's public node_modules/<pkg> links
are exactly this shape), so every such record was silently dropped at
the merge and a later bundle=load run ENOENT'd on it.

Replay with statSync instead: it reproduces the recorded kind for every
key a legitimate shard can carry (an lstat of a symlink was never
recorded at all, and for non-links lstat and stat agree). Because
statSync follows links, re-assert the capture side's containment
invariant (fs.js captureStat's realContained) before attesting: the
real location must stay inside the root's real path, so a shard entry
keyed at an in-root symlink pointing out of the tree is skipped, never
recorded. A dangling link now falls into the existing gone-from-disk
skip.

Reported by the Copilot review on #66.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017z3Eb4Udqc5BkdQtt2YA72
@ChALkeR
ChALkeR merged commit 52e6443 into main Jul 17, 2026
5 checks passed
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.

4 participants