Skip to content

Defer bundle writes until all validations pass - #162

Open
exo-nikita wants to merge 3 commits into
mainfrom
claude/stasis-core-add-directories-niat1q
Open

Defer bundle writes until all validations pass#162
exo-nikita wants to merge 3 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 2 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
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