Skip to content

feat(bundle): Bundle.fromJSON with a contents-free mode, and Bundle.fileKeyAt - #188

Merged
ChALkeR merged 8 commits into
mainfrom
claude/contents-free-bundle
Oct 1, 2026
Merged

ChALkeR merged 8 commits into
mainfrom
claude/contents-free-bundle

Conversation

@exo-nikita

@exo-nikita exo-nikita commented Oct 1, 2026 •

Copy link
Copy Markdown
Collaborator

This PR holds the stasis-core part of #184, split out so it can be reviewed and merged on its own. It adds what a streaming bundle reader needs from Bundle. The reader itself stays in #184 (@exodus/stasis/bundle-reader).

Changes

Bundle.fromJSON(json, { contents = true })

  • This is Bundle.parse on a value that is already parsed; parse now just calls fromJSON(JSON.parse(text)).
  • With contents: false, the result is contents-free:
    • Every file value must be a symbol placeholder, left where the reader took that file's contents out. A file that still holds anything else is rejected.
    • Each bucket's files becomes a frozen, null-prototype object whose keys still list the files, but every value is a getter that throws.
    • As a result sources, serialize() and merge() also throw, because they read file contents. Metadata stays readable (hasCode, the SBOM components, and so on).
  • The placeholder rule also rejects every bundle in which the reader and fromJSON could disagree about which strings are files. Examples are a v0 bundle with modules, an array where an object belongs, and non-string contents. Bundle.parse itself is unchanged.

Bundle.fileKeyAt(path)

  • Takes a key path in the bundle JSON (v1 sources|modules.<dir>.files.<rel>, v0 sources.<path>).
  • Returns the flat key, as sources keys it, of the file whose contents sit there. Any other position returns undefined.
  • A non-canonical key throws. The check is the same canonicalFileKey that fromJSON applies through flatFileKeys.

Root-key fix

  • Only the root listing (rel '') may use the flat key '.'. A file named '.' was a second spelling of it; both Bundle.parse and Lockfile.parse now reject it. The check is the new canonicalFileKey in artifact-util, which flatFileKeys now uses.
  • In v0, the root listing is spelled '' or '.', matching how formats already treats them. Both map to rel '', and a v0 bundle with both is rejected.

Docs: a short "Contents-free bundles" section in doc/file-formats.md.

Tests

  • New tests/bundle-contents.test.js, which goes through the reader's real flow: replace each string fileKeyAt locates with a placeholder, then call fromJSON(…, { contents: false }). It checks:
    • every field, file list and SBOM component is kept, and reading contents, sources, serialize() and merge() throw;
    • any file the reader didn't take out is rejected, including the shapes where fileKeyAt and fromJSON disagree;
    • walking every string with fileKeyAt gives exactly bundle.sources; non-file positions give undefined; non-canonical keys throw.
  • tests/public-exports.test.js: the root-key and v0-alias cases.
  • Local runs: node --run lint is clean and node --run test passes all 72 suites.

Once this merges, #184 will be rebased so it only adds the reader, which will call fromJSON(tree, { contents: false }).

🤖 Generated with Claude Code

https://claude.ai/code/session_01FinLHpaKeRRjstqzMnwj6u

claude added 7 commits October 1, 2026 10:11
A contents-free Bundle (hasContents false, from withoutContents() or
`contents: false`) keeps every field and each bucket's file list, but a
read of a file's contents throws, as do `sources`, serialize() and
merge(). withReason() works from the file keys, so it keeps working.
A Bundle built on contents-free buckets stays contents-free.

Bundle.fromJSON is parse() without the JSON.parse, and Bundle.fileKeyAt
maps a key path in the bundle JSON to the flat key of the file whose
contents sit there. Together they let a streaming reader take files out
as they arrive and still validate the bundle as parse() does.

Only the root listing (rel '') may take the flat key '.': a file named
'.' was a second spelling of it. v0 spells the root listing '' or '.';
both map to rel '', and a bundle carrying both is rejected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FinLHpaKeRRjstqzMnwj6u
…-free fromJSON

- fromJSON requires plain objects for sources, modules and each bucket's
  files, string file contents, and no `modules` in a v0 bundle. These were
  the shapes where fileKeyAt and fromJSON could disagree on which strings
  are files.
- fromJSON(json, { contents: false }) accepts symbol placeholders and
  builds a contents-free Bundle, so placeholders can't reach serialize()
  or merge().
- fromJSON copies config and reason instead of keeping the caller's
  objects.
- Locked buckets are reused rather than rebuilt on every copy, and the
  constructor no longer relies on Iterator helpers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FinLHpaKeRRjstqzMnwj6u
…e side in merge

- fromJSON copies `reason` as is (each list shallowly) instead of sorting
  it through mergeReason, which threw on a non-string list. A bundle with
  a malformed informational `reason` parses again, as it did before.
- merge() says when the other Bundle is the contents-free one.
- Drop extract's non-string-contents check: Bundle.parse rejects those
  bundles first, so it could never run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FinLHpaKeRRjstqzMnwj6u
filePosition is now the only place that knows where file contents sit in
the bundle JSON (v1 `sources|modules.<dir>.files.<rel>`, v0
`sources.<path>`), how a v0 path splits into a bucket, and how the flat
key is checked. fromJSON keys every file it accepts through it, and
fileKeyAt returns its key, so the two can no longer drift apart.

v0 paths no longer need their own posixPathEscapes checks: a canonical
key can't escape the root. A bad v0 path now fails with the same
"non-canonical file key" error from parse and from fileKeyAt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FinLHpaKeRRjstqzMnwj6u
…e lock tracking

A contents-free Bundle is meant for one thing: a streaming reader's
output, handed to metadata-only consumers. Nothing rebuilds or copies
one. So drop what existed only to support that:

- the module-level WeakSet of locked buckets, with the constructor check
  that kept a rebuilt Bundle contents-free and lockModule's skip for
  buckets that were already locked;
- withoutContents(). Use Bundle.fromJSON(value, { contents: false }).

A rebuild still can't leak or write anything: every file is a getter
that throws.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FinLHpaKeRRjstqzMnwj6u
…nts: false

The only producer of a contents-free Bundle is a streaming reader, and
its only consumers read metadata. So keep just what that path needs:

- Bundle.fromJSON(json, { contents }), with parse calling it. With
  `contents: false`, every file value must be the reader's symbol
  placeholder, and each bucket's files become getters that throw. That
  one check rejects every bundle where the reader and fromJSON could
  disagree on which strings are files, so parse itself stays as it was.
- Bundle.fileKeyAt on the shared filePosition, which the v0 loop also
  uses (its posixPathEscapes checks are covered by the canonical key).
- The root-key '.' fix.

Dropped:
- the Bundle-level contents flag (hasContents, the constructor option
  and the guards in sources/serialize/merge); the throwing getters
  already cover them;
- the keys-only withReason;
- the config/reason copies;
- the stricter container and string-contents checks in parse, with
  their extract and state-sidecar test changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FinLHpaKeRRjstqzMnwj6u
@exo-nikita exo-nikita changed the title feat(bundle): contents-free Bundle, Bundle.fromJSON and Bundle.fileKeyAt feat(bundle): Bundle.fromJSON with a contents-free mode, and Bundle.fileKeyAt Oct 1, 2026
filePosition had one real caller left. fromJSON already checks every key
with canonicalFileKey through flatFileKeys, and inferModuleDir already
splits v0 paths. So the layout now lives in fileKeyAt, and the v0 loop
is main's again, apart from the '' / '.' alias check. Also drop the
fromJSON-vs-parse test: parse is fromJSON(JSON.parse(text)).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FinLHpaKeRRjstqzMnwj6u
@ChALkeR
ChALkeR merged commit 8e23c4e into main Oct 1, 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