From 635625d42c36f6fdce3e117035803b4c7d28b8d5 Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Wed, 2 Sep 2026 00:25:48 +0200 Subject: [PATCH] fix(server): bundle Bun platform packages so one effect instance serves CORS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since 0.0.34 a Bun-hosted server omits `Access-Control-Allow-Origin` from real GET/POST responses while answering OPTIONS preflight correctly, so a desktop app cannot reach a remote environment on a separate HTTPS origin. Node-hosted servers are fine. Compression and the auth refresh / DPoP / cloud credential headers are broken the same way and for the same reason. The CORS code did not change. The CLI bundling change did. `dist/bin.mjs` inlines `effect`, but `@effect/platform-bun` and `@effect/sql-sqlite-bun` stayed external so the bundler would never have to resolve `bun:sqlite`. Under Bun that external `BunHttpServer` loads a second `effect` from node_modules beside the bundle. Effect keys each request's pre-response handler off a module-level WeakMap in `effect/unstable/http/internal/preResponseHandler`: `HttpMiddleware.cors` and `compression` write to it, and the platform server's `toHandled` reads it when sending the response. With two `effect` instances the bundled copy writes handlers the node_modules copy never reads. Preflight survives because `cors` answers OPTIONS inline without touching the WeakMap. `@effect/platform-node` is bundled into the same graph, which is why Node never saw this. Externalize Bun's own module namespace (`bun`, `bun:*`) instead of the packages that import it. That is all the bundler could not resolve, and nothing has to resolve it under Node either: every Bun import sits behind a `typeof Bun !== "undefined"` dynamic import, so it lands in a chunk only a Bun-hosted server loads. The two packages also leave `dependencies`, since nothing resolves them at runtime any more. That trades one silent failure for another, so it is now checked. Inlining moves `bun:sqlite` into the bundle, and it is only harmless while its chunk stays reachable solely through `import()`; statically reachable, every `node bin.mjs` dies with ERR_UNSUPPORTED_ESM_URL_SCHEME. Rolldown merges a dynamic import into its importer with an INEFFECTIVE_DYNAMIC_IMPORT warning and exit 0, and removing these packages from `dependencies` is not a backstop either — the desktop self-containment probe runs `node bin.mjs --version` and never takes the Bun branch. So `assertNoEagerBunImports` in `apps/server/scripts/cli.ts` walks the emitted chunk graph from `bin.mjs` and `service-launcher.mjs` following static edges only and fails the build on any Bun specifier in that set. It runs on every PR through `vp run build:desktop`, unlike the desktop probe. Verified by bundling one probe that mirrors `server.ts`'s conditional `BunHttpServer` import plus `http.ts`'s cors and compression middleware, built both ways and run under Bun 1.2.15. Packages external: no `access-control-allow-origin`, no `content-encoding`, 9728 bytes. Packages bundled: `access-control-allow-origin: *`, `content-encoding: gzip`, 81 bytes. Preflight identical in both. The shipped bundle has no bare `@effect/*-bun` import left, keeps one `bun:sqlite` external in the chunk Node never loads, and defines the pre-response WeakMap exactly once. The real CLI still serves CORS and sets `vary: Accept-Encoding` under Node. Bundle JS grows 8,662,684 -> 8,705,534 bytes while `bin.mjs` itself drops 180,952; the npm install loses 537 KB of now-unused packages. The new check passes on a real build (11 eagerly loaded chunks, no Bun modules). Adding a static `@effect/sql-sqlite-bun/SqliteClient` import to `bin.ts` made it report `bin.mjs -> bun:sqlite` and exit 1, and the bundle it rejected does fail under Node with ERR_UNSUPPORTED_ESM_URL_SCHEME. `vp test run scripts/lib/cli-external-packages.test.ts` (20) passes and `apps/server` typecheck is clean. `scripts/build-desktop-artifact.test.ts` has 7 failures on Windows (symlink EPERM, WSL archive, macOS entitlements) that reproduce identically on an unmodified tree. --- apps/server/package.json | 4 +- apps/server/scripts/cli.ts | 51 +++++++++++ apps/server/scripts/cliErrors.ts | 12 +++ pnpm-lock.yaml | 12 +-- scripts/build-desktop-artifact.ts | 8 +- scripts/lib/cli-external-packages.test.ts | 81 +++++++++++++++-- scripts/lib/cli-external-packages.ts | 106 ++++++++++++++++++---- 7 files changed, 239 insertions(+), 35 deletions(-) diff --git a/apps/server/package.json b/apps/server/package.json index 3f7ae6096461..2d92762b0f1e 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -23,10 +23,8 @@ }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.3.170", - "@effect/platform-bun": "catalog:", "@effect/platform-node": "catalog:", "@effect/platform-node-shared": "catalog:", - "@effect/sql-sqlite-bun": "catalog:", "@ff-labs/fff-node": "0.9.4", "@opencode-ai/sdk": "^1.3.15", "@pierre/diffs": "catalog:", @@ -36,6 +34,8 @@ "yaml": "catalog:" }, "devDependencies": { + "@effect/platform-bun": "catalog:", + "@effect/sql-sqlite-bun": "catalog:", "@effect/vitest": "catalog:", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", diff --git a/apps/server/scripts/cli.ts b/apps/server/scripts/cli.ts index 2de5b702a286..0a19d3cd38ef 100644 --- a/apps/server/scripts/cli.ts +++ b/apps/server/scripts/cli.ts @@ -15,6 +15,7 @@ import { resolveWebAssetBrandForPackageVersion, resolveWebIconOverrides, } from "../../../scripts/lib/brand-assets.ts"; +import { findEagerBunRuntimeImports } from "../../../scripts/lib/cli-external-packages.ts"; import { resolveCatalogDependencies } from "../../../scripts/lib/resolve-catalog.ts"; import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; import { fromYaml } from "@t3tools/shared/schemaYaml"; @@ -24,6 +25,7 @@ import { ServerCliBuildAssetMissingError, ServerCliCommandExitError, ServerCliDevelopmentIconSourceMissingError, + ServerCliEagerBunImportError, ServerCliDevelopmentIconTargetMissingError, ServerCliPublishIconSourceMissingError, ServerCliPublishIconTargetMissingError, @@ -140,6 +142,53 @@ const applyDevelopmentIconOverrides = Effect.fn("applyDevelopmentIconOverrides") // build subcommand // --------------------------------------------------------------------------- +/** + * Fail the build if a chunk Node loads eagerly imports a Bun module. + * + * `@effect/platform-bun` and `@effect/sql-sqlite-bun` are inlined so that a + * Bun-hosted server shares the bundle's single `effect` instance — two + * instances silently broke CORS, compression and auth headers, because Effect + * keys per-request pre-response handlers off a module-level WeakMap. Inlining + * them also pulls `bun:sqlite` into the bundle, which is only safe while it + * sits in a chunk reached solely through `import()`. + * + * Rolldown merges a dynamic import into its importer's chunk with only an + * INEFFECTIVE_DYNAMIC_IMPORT warning and exit 0, so read the artifact instead + * of trusting the build to fail. This runs on every PR through + * `vp run build:desktop`, unlike the desktop self-containment probe. + */ +const assertNoEagerBunImports = Effect.fn("assertNoEagerBunImports")(function* (distDir: string) { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + + const chunks = new Map(); + for (const name of yield* fs.readDirectory(distDir)) { + if (!name.endsWith(".mjs")) continue; + chunks.set(name, yield* fs.readFileString(path.join(distDir, name))); + } + + // Both entries are packed separately into the same flat directory, so each + // owns a graph that has to be walked. + const entryChunks = ["bin.mjs", "service-launcher.mjs"]; + for (const entry of entryChunks) { + if (!chunks.has(entry)) { + return yield* new ServerCliBuildAssetMissingError({ assetPath: path.join(distDir, entry) }); + } + } + + const { reachable, violations } = findEagerBunRuntimeImports(chunks, entryChunks); + if (violations.length > 0) { + return yield* new ServerCliEagerBunImportError({ + imports: violations.map(({ chunk, specifier }) => `${chunk} -> ${specifier}`), + eagerChunkCount: reachable.length, + }); + } + + yield* Effect.log( + `[cli] Verified ${reachable.length} eagerly loaded chunks import no Bun modules`, + ); +}); + const buildCmd = Command.make( "build", { @@ -162,6 +211,8 @@ const buildCmd = Command.make( }), ); + yield* assertNoEagerBunImports(path.join(serverDir, "dist")); + const webDist = path.join(repoRoot, "apps/web/dist"); const clientTarget = path.join(serverDir, "dist/client"); diff --git a/apps/server/scripts/cliErrors.ts b/apps/server/scripts/cliErrors.ts index d384c745f293..3288c9924734 100644 --- a/apps/server/scripts/cliErrors.ts +++ b/apps/server/scripts/cliErrors.ts @@ -68,3 +68,15 @@ export class ServerCliBuildAssetMissingError extends Schema.TaggedErrorClass()( + "ServerCliEagerBunImportError", + { + imports: Schema.Array(Schema.String), + eagerChunkCount: Schema.Int, + }, +) { + override get message(): string { + return `The bundle imports Bun modules from chunks Node loads eagerly (${this.eagerChunkCount} scanned): ${this.imports.join(", ")}. Node cannot resolve a \`bun:\` specifier, so every \`node bin.mjs\` would fail with ERR_UNSUPPORTED_ESM_URL_SCHEME. @effect/platform-bun and @effect/sql-sqlite-bun are inlined so a Bun-hosted server shares one effect instance with the bundle; that requires every path into them to stay behind a runtime-conditional dynamic import. Something now imports one of them statically.`; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 262203a10002..ee458610ce96 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -476,18 +476,12 @@ importers: '@anthropic-ai/claude-agent-sdk': specifier: ^0.3.170 version: 0.3.170(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) - '@effect/platform-bun': - specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6) '@effect/platform-node': specifier: 4.0.0-beta.103 version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/platform-node-shared': specifier: 4.0.0-beta.103 version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6) - '@effect/sql-sqlite-bun': - specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@ff-labs/fff-node': specifier: 0.9.4 version: 0.9.4(patch_hash=ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368) @@ -510,6 +504,12 @@ importers: specifier: ^2.9.0 version: 2.9.0 devDependencies: + '@effect/platform-bun': + specifier: 4.0.0-beta.103 + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6) + '@effect/sql-sqlite-bun': + specifier: 4.0.0-beta.103 + version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@effect/vitest': specifier: 4.0.0-beta.103 version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 8709be2a2599..cc583e2fd6b2 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -1684,9 +1684,11 @@ const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSel // --version exercises the eagerly loaded module graph, which is where a // missing dependency shows up, without starting a server or touching disk // state. It does not cover lazily imported externals: node-pty is checked - // by the WSL preflight probe at runtime, while ffi-rs, @ff-labs/fff-node - // and the bun adapters are covered by the shared runtime-external closure - // and emitted-bundle checks. + // by the WSL preflight probe at runtime, while ffi-rs and @ff-labs/fff-node + // are covered by the shared runtime-external closure and the emitted-bundle + // checks above. The Bun adapters are not external at all any more, and this + // probe never takes the Bun branch; `assertNoEagerBunImports` in + // apps/server/scripts/cli.ts is what keeps them off the eager graph. yield* runCommand( ChildProcess.make( process.execPath, diff --git a/scripts/lib/cli-external-packages.test.ts b/scripts/lib/cli-external-packages.test.ts index 754cd646f17d..697c453091d6 100644 --- a/scripts/lib/cli-external-packages.test.ts +++ b/scripts/lib/cli-external-packages.test.ts @@ -11,6 +11,7 @@ import serverPackageJson from "../../apps/server/package.json" with { type: "jso import { CLI_RUNTIME_EXTERNAL_PREFIXES, + findEagerBunRuntimeImports, findInlinedExternalPackages, selectCliRuntimeExternalDependencies, shouldBundleCliDependency, @@ -55,9 +56,20 @@ describe("shouldBundleCliDependency", () => { } }); - it("leaves bun-only entry points external", () => { - assert.strictEqual(shouldBundleCliDependency("@effect/platform-bun"), false); - assert.strictEqual(shouldBundleCliDependency("@effect/sql-sqlite-bun"), false); + // Externalizing these packages instead of the `bun:*` specifiers they import + // gave a Bun-hosted server a second `effect` beside the bundle, and Effect + // keys its per-request pre-response handlers off a module-level WeakMap. The + // bundled copy wrote handlers the platform server's copy never read, so CORS, + // gzip and auth headers silently vanished from real responses. + it("bundles the Bun platform packages so one effect instance serves requests", () => { + assert.strictEqual(shouldBundleCliDependency("@effect/platform-bun"), true); + assert.strictEqual(shouldBundleCliDependency("@effect/sql-sqlite-bun"), true); + }); + + it("leaves Bun's own runtime modules external", () => { + for (const id of ["bun", "bun:sqlite", "bun:ffi"]) { + assert.strictEqual(shouldBundleCliDependency(id), false, id); + } }); // The real package is `node-gyp-build-optional-packages`, reached by prefix. @@ -72,7 +84,7 @@ describe("selectCliRuntimeExternalDependencies", () => { it("keeps only runtime-external dependency roots for the Windows sidecar", () => { assert.deepStrictEqual( selectCliRuntimeExternalDependencies({ - "@effect/platform-bun": "1.0.0", + "@effect/platform-node": "1.0.0", "@ff-labs/fff-node": "2.0.0", effect: "3.0.0", "node-pty": "4.0.0", @@ -148,8 +160,6 @@ it.layer(NodeServices.layer)("external package dependency closure", (it) => { return installed; }).pipe(Effect.cached, Effect.runSync); - // Runtime-external only. The build-only entries resolve `bun:*` and are never - // loaded by Node, so their closure genuinely does not need to be external. const isRuntimeExternal = (name: string) => CLI_RUNTIME_EXTERNAL_PREFIXES.some((prefix) => name.startsWith(prefix)); @@ -276,3 +286,62 @@ var x = 1; assert.deepStrictEqual(result.inlined, []); }); }); + +// The bundle now carries `bun:sqlite` itself. That only works while the chunk +// holding it is reached solely through `import()`; statically reachable, it +// kills every Node run with ERR_UNSUPPORTED_ESM_URL_SCHEME. +describe("findEagerBunRuntimeImports", () => { + const chunks = (entries: Record) => new Map(Object.entries(entries)); + + it("allows a bun specifier behind a dynamic import", () => { + const result = findEagerBunRuntimeImports( + chunks({ + "bin.mjs": `import { a } from "./shared-abc.mjs"; +const client = await import("./SqliteClient-def.mjs");`, + "shared-abc.mjs": "export const a = 1;", + "SqliteClient-def.mjs": 'import { Database } from "bun:sqlite";', + }), + ["bin.mjs"], + ); + + assert.deepStrictEqual(result.violations, []); + // The dynamically imported chunk must stay out of the eager graph, or the + // pass above would be vacuous for the wrong reason. + assert.deepStrictEqual(result.reachable, ["bin.mjs", "shared-abc.mjs"]); + }); + + it("flags a bun specifier a statically reachable chunk imports", () => { + const result = findEagerBunRuntimeImports( + chunks({ + "bin.mjs": 'import { a } from "./shared-abc.mjs";', + "shared-abc.mjs": `import { Database } from "bun:sqlite"; +export const a = Database;`, + }), + ["bin.mjs"], + ); + + assert.deepStrictEqual(result.violations, [ + { chunk: "shared-abc.mjs", specifier: "bun:sqlite" }, + ]); + }); + + it("follows re-exports and bare imports, and flags the bun package too", () => { + const result = findEagerBunRuntimeImports( + chunks({ + "bin.mjs": 'import "./side-effect.mjs";', + "side-effect.mjs": 'export * from "./redis-ghi.mjs";', + "redis-ghi.mjs": 'import { RedisClient } from "bun";', + }), + ["bin.mjs"], + ); + + assert.deepStrictEqual(result.violations, [{ chunk: "redis-ghi.mjs", specifier: "bun" }]); + }); + + it("reports nothing reachable when the entry chunk is missing", () => { + const result = findEagerBunRuntimeImports(chunks({}), ["bin.mjs"]); + + assert.deepStrictEqual(result.reachable, []); + assert.deepStrictEqual(result.violations, []); + }); +}); diff --git a/scripts/lib/cli-external-packages.ts b/scripts/lib/cli-external-packages.ts index d7a89bc408a4..b373f0de6168 100644 --- a/scripts/lib/cli-external-packages.ts +++ b/scripts/lib/cli-external-packages.ts @@ -50,26 +50,31 @@ export const CLI_RUNTIME_EXTERNAL_PREFIXES = [ "utf-8-validate", ] as const; +export function isRuntimeExternalCliDependency(id: string): boolean { + return CLI_RUNTIME_EXTERNAL_PREFIXES.some((prefix) => id.startsWith(prefix)); +} + /** - * External only so the bundler never has to resolve them. + * Bun's own module namespace, supplied by the Bun runtime rather than by disk. * - * These are reached through a runtime-conditional dynamic import that Node - * never takes, and they resolve `bun:*` specifiers that do not exist when - * bundling for Node. Because Node never loads them, their dependency closure - * does not need to be external — only the entry point must stay unbundled. + * `@effect/platform-bun` and `@effect/sql-sqlite-bun` used to be external for + * exactly one reason: they import `bun` and `bun:sqlite`, which cannot resolve + * while bundling for Node. Externalizing the packages to dodge that was wrong. + * Both import `effect`, so a Bun-hosted server loaded a *second* `effect` from + * node_modules beside the bundle. Effect hangs each request's pre-response + * handler off a module-level WeakMap, so the bundled copy wrote handlers the + * platform server's copy never read: CORS headers, gzip and auth headers all + * vanished from real responses while OPTIONS preflight (answered inline, no + * WeakMap) kept working. + * + * Externalizing the `bun:*` specifiers instead keeps a single `effect` graph. + * Nothing has to resolve them at build time, and nothing has to resolve them + * under Node either: every import of a Bun module sits behind a + * `typeof Bun !== "undefined"` dynamic import, so it lands in a chunk that only + * a Bun-hosted server loads. */ -export const CLI_BUILD_ONLY_EXTERNAL_PREFIXES = [ - "@effect/platform-bun", - "@effect/sql-sqlite-bun", -] as const; - -export const CLI_EXTERNAL_PACKAGE_PREFIXES = [ - ...CLI_RUNTIME_EXTERNAL_PREFIXES, - ...CLI_BUILD_ONLY_EXTERNAL_PREFIXES, -] as const; - -export function isRuntimeExternalCliDependency(id: string): boolean { - return CLI_RUNTIME_EXTERNAL_PREFIXES.some((prefix) => id.startsWith(prefix)); +export function isBunRuntimeModule(id: string): boolean { + return id === "bun" || id.startsWith("bun:"); } /** @@ -83,7 +88,7 @@ export function isRuntimeExternalCliDependency(id: string): boolean { * inlined while node-pty (a declared dependency) stayed external. */ export function isExternalCliDependency(id: string): boolean { - return CLI_EXTERNAL_PACKAGE_PREFIXES.some((prefix) => id.startsWith(prefix)); + return isBunRuntimeModule(id) || isRuntimeExternalCliDependency(id); } /** True when the CLI bundle should inline `id` rather than leave it external. */ @@ -150,3 +155,68 @@ export function findInlinedExternalPackages(source: string): { inlinedPackages: [...inlinedPackages].sort(), }; } + +/** + * Walk the emitted chunk graph and report Bun modules the Node runtime can reach. + * + * Bundling `@effect/platform-bun` and `@effect/sql-sqlite-bun` moved a + * `bun:sqlite` import from node_modules into the bundle. That is only safe + * because rolldown keeps it in a chunk reached solely through `import()`, which + * Node never evaluates. Nothing else enforces that: a stray static import of + * `@effect/sql-sqlite-bun/SqliteClient` from an eagerly loaded module would + * hoist `bun:sqlite` into the entry chunk and every `node bin.mjs` would die + * with ERR_UNSUPPORTED_ESM_URL_SCHEME. Rolldown only warns about the merge + * (INEFFECTIVE_DYNAMIC_IMPORT) and still exits 0. + * + * So this follows static edges only — `import`/`export ... from "./chunk.mjs"` + * — from the entry chunks, exactly the graph Node loads eagerly, and reports + * any Bun specifier inside it. Dynamic `import("./chunk.mjs")` is deliberately + * not an edge. + * + * `chunks` maps chunk file name to source; `reachable` is returned so a caller + * can tell "no violations" apart from "the walk never left the entry". + */ +export function findEagerBunRuntimeImports( + chunks: ReadonlyMap, + entryChunks: Iterable, +): { + readonly reachable: ReadonlyArray; + readonly violations: ReadonlyArray<{ readonly chunk: string; readonly specifier: string }>; +} { + // Anchored at a line start so `import(...)` and specifier strings inside + // bundled code cannot match. `[^;]*?` keeps a match inside one statement + // while still spanning the line breaks rolldown puts in long import lists. + const staticImportPattern = + /^[ \t]*(?:import|export)\s[^;]*?\bfrom\s*["']([^"']+)["']|^[ \t]*import\s*["']([^"']+)["']/gm; + + const seen = new Set(); + const queue = [...entryChunks]; + const violations: Array<{ chunk: string; specifier: string }> = []; + + for (const chunk of queue) { + if (seen.has(chunk)) continue; + seen.add(chunk); + + const source = chunks.get(chunk); + if (source === undefined) continue; + + for (const match of source.matchAll(staticImportPattern)) { + const specifier = match[1] ?? match[2]; + if (specifier === undefined) continue; + + if (specifier.startsWith(".")) { + // Chunks are emitted flat beside their entry, so the basename is the key. + const target = specifier.slice(specifier.lastIndexOf("/") + 1); + if (!seen.has(target)) queue.push(target); + continue; + } + + if (isBunRuntimeModule(specifier)) violations.push({ chunk, specifier }); + } + } + + return { + reachable: [...seen].filter((chunk) => chunks.has(chunk)).sort(), + violations, + }; +}