feat(bundle): Bundle.fromJSON with a contents-free mode, and Bundle.fileKeyAt - #188
Merged
Merged
Conversation
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
…ents 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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 })Bundle.parseon a value that is already parsed;parsenow just callsfromJSON(JSON.parse(text)).contents: false, the result is contents-free:filesbecomes a frozen, null-prototype object whose keys still list the files, but every value is a getter that throws.sources,serialize()andmerge()also throw, because they read file contents. Metadata stays readable (hasCode, the SBOM components, and so on).fromJSONcould disagree about which strings are files. Examples are a v0 bundle withmodules, an array where an object belongs, and non-string contents.Bundle.parseitself is unchanged.Bundle.fileKeyAt(path)sources|modules.<dir>.files.<rel>, v0sources.<path>).sourceskeys it, of the file whose contents sit there. Any other position returnsundefined.canonicalFileKeythatfromJSONapplies throughflatFileKeys.Root-key fix
'') may use the flat key'.'. A file named'.'was a second spelling of it; bothBundle.parseandLockfile.parsenow reject it. The check is the newcanonicalFileKeyin artifact-util, whichflatFileKeysnow uses.''or'.', matching howformatsalready 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
tests/bundle-contents.test.js, which goes through the reader's real flow: replace each stringfileKeyAtlocates with a placeholder, then callfromJSON(…, { contents: false }). It checks:sources,serialize()andmerge()throw;fileKeyAtandfromJSONdisagree;fileKeyAtgives exactlybundle.sources; non-file positions giveundefined; non-canonical keys throw.tests/public-exports.test.js: the root-key and v0-alias cases.node --run lintis clean andnode --run testpasses 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