Skip to content

Defer bundle writes until all validations pass - #162

Merged
ChALkeR merged 8 commits into
mainfrom
claude/stasis-core-add-directories-niat1q
Aug 10, 2026
Merged

Defer bundle writes until all validations pass#162
ChALkeR merged 8 commits into
mainfrom
claude/stasis-core-add-directories-niat1q

Conversation

@exo-nikita

Copy link
Copy Markdown
Collaborator

Summary

Refactor the stasis add command to defer all file writes until after validating the entire input set. This ensures that if validation fails, the project remains untouched rather than left in a partially-updated state. Additionally, implement auto-exclusion filtering for directory sweeps to prevent unintended capture of type declarations, secrets, and stasis's own outputs.

Key Changes

  • Deferred writes: Rename addToBundleFileprepareBundleFile and updateLockfileprepareLockfile to return write thunks instead of executing immediately. All merges are computed first, then writes execute only after all validations pass.

  • Four-step pipeline: Restructure addCommand into distinct phases:

    1. GLOB: Expand directories into files using fs.globSync, tracking which paths were swept vs. explicitly named
    2. FILTER: Drop auto-excluded files from swept results (but not explicitly named ones)
    3. VALIDATE: Check entire set for missing files, undeclared types, and non-UTF-8 content before touching any target
    4. ADD: Compute all merges, then execute writes
  • Auto-exclusion rules: New isAutoExcludedFile() function filters out:

    • Type declarations (.d.ts, .d.mts, .d.cts)
    • Secrets files (.env, .env.*, *.env)
    • Stasis artifacts (stasis.lock.json, *.stasis.*.br)
    • Generated sidecars (*.map, *.js.flow) unless opted into via resources config
  • Improved error reporting: Collect all validation failures (missing files, undeclared types, non-UTF-8 content) into a single error message rather than failing on the first offender. Directory sweeps that match only auto-excluded files report what was filtered.

  • Explicit override: Files named explicitly on the command line bypass the auto-exclusion filter, allowing stasis add .env to work even though .env is auto-excluded from stasis add .

  • Repeatable operations: stasis add . now never attests the bundle or lockfile it's writing, making the operation idempotent across runs.

Notable Implementation Details

  • expandDirectories() now returns a Map<rel, { swept, stats }> to distinguish between explicitly named paths and those found by glob expansion
  • validateFiles() collects errors across the entire set before throwing, enabling comprehensive error messages
  • Configured bundle targets are tracked in a Set to prevent self-reference during sweeps
  • The glob step reuses filesystem stats to avoid re-probing the same inodes during validation

https://claude.ai/code/session_01KrgpYdYarjgv8ur9kpBpia

`stasis add <dir>` already globbed a directory into every file under it, but
then packed whatever it found: a `types.d.ts` went in as `module-typescript`
code (types only, erased at runtime -- the resolvers refuse to land on one, so
no loader can ever serve it), a `web.env` as `env` code carrying secrets, and
`add .` attested the very bundle it was writing, so the next run conflicted
with the recorded copy. None of those are caught by the `resources` check --
they all classify as code.

`add` now runs in four ordered steps:

- GLOB: expand each directory (recursive, sorted so nothing depends on readdir
  order), tagging each path as swept or named by the caller, and carrying the
  stat the walk already took into validation instead of re-probing the inode.
- FILTER: drop the auto-excluded set from what the sweep FOUND -- type
  declarations, the `.env` family, stasis's own outputs (by name, plus the
  configured bundle targets), and `*.map`/`*.js.flow` sidecars unless their
  extension is declared in `resources` (matching --fs's `map` opt-in). A
  path named explicitly is never filtered, so `add .env` still captures.
  Dropped files are counted in the summary line, never skipped silently.
- VALIDATE: everything that survives still has to be recognized source or a
  declared resource, now checked across the whole set before any target is
  touched -- one error names every offender instead of one per re-run (a lone
  offender keeps its exact message).
- ADD: unchanged merge semantics (add-if-missing, divergent bytes for an
  attested path are a conflict), except every target's merge is computed before
  the first write, so a refused run leaves no half-updated project behind.

isStasisArtifactName moves to util.js (shared with --fs, which had the only
copy) and gains basename handling for the path-shaped callers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KrgpYdYarjgv8ur9kpBpia
@exo-nikita
exo-nikita force-pushed the claude/stasis-core-add-directories-niat1q branch from 8494fc6 to 606fb01 Compare July 28, 2026 19:13
claude added 7 commits July 28, 2026 19:23
…e set

Directories a sweep no longer descends into, matched by segment at any depth:
`.git`, `.github`, `.settings` (VCS/CI/IDE metadata), `example`/`examples`
(sample apps import the package, they aren't it), `__tests__`/`__mocks__`/
`jest` (test scaffolding), and Apple prebuilt slice dirs (`ios-arm64...`,
compiled output). Files: the auto-excluded set now also covers everything the
native capture skips as non-build-input noise -- NATIVE_EXCLUDE_EXTS and
NATIVE_EXCLUDE_NAMES, i.e. docs/legal, editor/lint/CI config, logs, and the
`*.map`/`*.js.flow` sidecars -- on top of type declarations, the `.env` family
and stasis's own outputs.

Exclusions apply to what a GLOB found, never to what the caller named, so any
of these is still addable by naming it: `add .github/workflows`, `add src/
examples`, `add README.md`. Two consequences of that rule:

- Directory rules are checked against the match path relative to the NAMED
  root, not the project root, so `add src/examples` sweeps the dir it points
  at while `add src` skips the one it merely found.
- `resources` no longer un-excludes a swept `*.map`/`*.js.flow`. It decides
  what a file IS once the sweep offers it, not whether the sweep offers it;
  naming the file is the way in, and the declaration then makes it a resource.

A path a run reaches more than once keeps its most specific provenance -- named
beats swept, nearer root beats farther -- so `add src src/examples` includes the
subtree it names regardless of the order the two entries are listed in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KrgpYdYarjgv8ur9kpBpia
The sweep exclusions added a third copy of a directory list `stasis-plugins`
and `stasis` already carried: metro.js's NATIVE_WALK_SKIP_DIRS and
bundle.js's NATIVE_SKIP_DIRS. The two existing ones had already drifted --
bundle.js was missing `.github`/`example`/`examples`, metro.js was missing
`DerivedData` -- which is what three hand-typed copies of one policy do.

util.js's `isAutoExcludedDir` is now the single "not the code being shipped"
name rule (metadata, sample apps, test scaffolding, Apple slice dirs), and both
walks compose it with their own build-output dirs (`build`, `.gradle`, `.cxx`,
`Pods`, `DerivedData`, `node_modules`). That folds in each walk's separate
`isAppleSliceDir` call, and fixes metro's `#hasPodspec`, which checked the set
without the slice-dir rule its sibling call site had. Converging the lists
means the static native walk now also skips `.github`/`example`/`examples` and
metro's also skips `DerivedData`.

Also from the same review pass:

- `toPosix` is exported from util.js instead of the `split(/[\\/]/u).join('/')`
  idiom being spelled out in util.js, bundle-util.js and add.js.
- `add` serializes each target inside its write thunk rather than up front, so
  peak memory is one target's bytes instead of every target's at once. The
  merges still all happen before the first write -- that's what makes a
  conflict abort leave the project untouched.
- validationError's three copies of the singular/plural skeleton collapse into
  one `phrase()` helper; `validateFiles`'s `hash` flag is `withIntegrity`, the
  thing it actually means.
- tests: the auto-excluded fixture set is one list the count derives from
  instead of a hand-maintained `11`, and the repeated
  `Object.keys(decode(...).modules.get('.').files).toSorted()` is one
  `packedFiles()` helper (12 call sites).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KrgpYdYarjgv8ur9kpBpia
The glob step recorded per-path provenance ({ swept, stats }, null for a named
path) and resolved multiply-reached paths by a "most specific wins" comparison
(named beats swept, nearer root beats farther), with a separate FILTER pass
consuming it. That ordering rule re-derived information each walk already had,
and it isn't needed: exclusion is monotone across nested roots -- a nearer
root's match segments are a suffix of a farther root's, so a sweep that keeps
a path can never be overruled by another sweep, and named paths are simply
never offered to the filter. "Most specific wins" and "kept by any entry" are
the same verdict.

So expandDirectories now filters each walk's finds itself (it has the
root-relative match in hand for the dir rules; file rules come in as a
`dropSwept` callback) and returns { files, excluded } directly: named paths
always land in `files`, a swept path lands wherever its own walk put it, and a
path some entry kept is scrubbed from `excluded` at the end. Behavior is
byte-identical -- same enumeration, same counts, same messages -- with the
provenance shape, the segment-length comparator, and addCommand's separate
filter loop gone (-12 lines).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KrgpYdYarjgv8ur9kpBpia
…ction

Round-2 cleanup pass over the branch, all four review angles again:

- The two native walks' leftover extra sets were byte-identical after the
  round-1 convergence, still spelled out in both packages. util.js now exports
  the whole composed predicate (isSkippedNativeWalkDir = build-output dirs +
  isAutoExcludedDir + isNativeArtifact); metro.js and bundle.js drop their
  local set+one-liner. The atRoot/isExcludedNativeDir clause stays at the two
  call sites that have an atRoot.
- expandDirectories takes the `outputs` Set directly instead of a dropSwept
  callback -- the file rule now reads inline next to the dir rule, matching how
  the native walks consume their predicate. The dir rule reuses toPosix
  instead of re-inlining its separator regex, and the redundant files.has
  guard is gone: the final excluded-cleanup loop alone enforces the invariant.
- The walk-sort comment now states the real reason the sort is load-bearing:
  serialize() emits `reason` in insertion order (everything else re-sorts), so
  an unsorted walk would make the attested bundle's bytes readdir-dependent.
- prepare*'s returned `summary` field is `counts`, ending the rename dance
  against addCommand's summary-lines array; the sorted-path-list join is one
  module-level listPaths shared by validationError and the all-excluded error.
- isAutoExcludedDir takes the bare dir name it is always given (dirent name or
  path segment) instead of basename()-ing it, documented as such.
- README describes the excluded dirs categorically (VCS/CI/IDE metadata,
  sample apps, test scaffolding, Apple slice dirs) instead of transcribing the
  Set entry-by-entry -- the code and its tests are the source of truth.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KrgpYdYarjgv8ur9kpBpia
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KrgpYdYarjgv8ur9kpBpia
@ChALkeR
ChALkeR merged commit eeb5ce0 into main Aug 10, 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.

3 participants