Skip to content

fix(transforms)!: run the browser server-exports strip before compile - #3855

Closed
kojiwakayama wants to merge 22 commits into
mainfrom
feat/issue-112-strip-before-compile
Closed

fix(transforms)!: run the browser server-exports strip before compile#3855
kojiwakayama wants to merge 22 commits into
mainfrom
feat/issue-112-strip-before-compile

Conversation

@kojiwakayama

@kojiwakayama kojiwakayama commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Draft. Do not mark ready or merge. The decision recorded at veryfront/veryfront-issue-inbox#112 (comment 5332914896), now governed by veryfront/veryfront-issue-inbox#605, is implemented in the third commit and corrected by the ones after it. The seventh commit makes both reference walkers exhaustive over the pinned @babel/types, the eighth pins every cell of that classification with a mutation-killed test, and the ninth closes the class above them: every predicate that stands in for a compiler or parser rule is now tested against the real compiler. The tenth fixes the one differential test that had compared against a second opinion rather than against the compiler, which CI caught on Bun. Read Behaviour change: a re-exported data hook now fails the browser build before reviewing anything else; it is the one user-facing break in the PR. The eleventh closes a leak this reorder introduced, an escaped hook export name the pre-parse gate could not see, and the twelfth adds the differential test that catches the class rather than the spelling. Both are described under The pre-parse gate. All measure clean on this branch (191234f9) and on main. Re-measured since against main at 8d0ce3c2f0, 41 commits later: same result, and this branch still merges into it with zero conflicts. See Re-measured against current main.

What this changes

This changes the pipeline ordering for the browser target. browserServerExportsStripPlugin now runs before compilePlugin instead of after it:

before: parse -> compile -> cssStrip -> browserServerExportsStrip -> browserNodeBuiltinImports -> resolveImports -> finalize
after:  parse -> browserServerExportsStrip -> compile -> cssStrip -> browserNodeBuiltinImports -> resolveImports -> finalize

Two edits were required, not one. src/transforms/pipeline/index.ts:334-348 uses array order while no custom plugin is registered, and re-sorts the whole pipeline by plugin.stage as soon as one is. So the array position moved and the stage changed from TransformStage.COMPILE + 0.6 to TransformStage.PARSE + 0.5. Each half has its own test, and reverting either half alone turns exactly one test red.

Behaviour change: a re-exported data hook now fails the browser build

This is the one user-facing break in the PR, and it is not a side effect of the reorder: it is the reorder doing its job loudly instead of quietly. Both of these authoring forms work today on main and stop the build on this branch.

// app/dashboard/page.tsx
export { getServerData } from "../server/loaders.ts";
export { loadDashboard as getServerData } from "../server/loaders.ts";

Measured through the real browser runPipeline, ssr: false, production output, re-measured on main (3a109046d0) and on this head (191234f9):

main this branch
export { getServerData } from "./loaders.ts" builds: import{getServerData as l}from"./loaders.js";…export{n as default,l as getServerData} ServerExportStripError
export { loadDashboard as getServerData } from "./loaders.ts" builds: import{loadDashboard as d}from"./loaders.js";…export{o as default,d as getServerData} ServerExportStripError

Read the main column carefully. The loader module is imported by the browser artifact, so it and its whole transitive graph execute in the browser, and the hook is not even stubbed: the artifact re-exports the real getServerData. That is the leak this stage exists to close, and on main it is silent.

The direction of the change is right, but it does turn a working authoring pattern into a build error, so it needs a migration note rather than a release-note footnote. The error message carries the supported form, and docs/guides/data-fetching.md carries it too, added in this PR:

// app/dashboard/page.tsx
import type { DataContext } from "veryfront";
import { loadDashboard } from "../server/loaders.ts";

export async function getServerData(ctx: DataContext) {
  return { props: await loadDashboard(ctx) };
}

A re-export binds no local declaration, so there is no body to empty. Emptying is what keeps the export clause valid while the loader is removed; without a local binding the pass has nothing to act on and the only alternatives are shipping the loader or failing. It fails.

Why

compile.ts sets keepNames: true, so the strip stage was spending its effort recognising __name(fn, "fn") registrations that Veryfront's own compile stage had injected one position earlier in the same pipeline. The comment justifying that recognition said release modules arrive compiled. Both release paths in src/release-assets/build-executor.ts transform uncompiled source, no framework module exports a server hook, and the other caller (src/build/bundler/code-splitter/esbuild-plugin.ts:63) already reads raw project files. Running the strip first means no keepNames helper is ever emitted for a declaration the pass removes, so the recognition stops being necessary rather than being solved.

What was deleted, and why it existed

The compile/strip sourcemap stash-and-restore handshake:

  • COMPILE_SOURCE_MAP_DIRECTIVE_METADATA and COMPILE_SOURCE_MAP_INPUT_METADATA, plus trailingSourceMapDirective and dropTrailingSourceMapDirective (31 lines in compile.ts).
  • The matching restore in the strip plugin's transform, plus appendSourceMapDirective (22 lines in the stage).
  • Six tests that existed only to defend the handshake, replaced by three that assert the post-reorder property directly.

It existed because compile ran first and embedded pre-strip source in its development sourcemap, so a stripped hook could still be recovered from sourcesContent. The stage had to drop that map, and the handshake was there to put it back when nothing was actually stripped. With the strip first, the compiler builds its map from already-stripped input, so there is nothing to defend and the map simply survives.

The keepNames recognition itself is not deleted here. That deletion is a separate change, and this PR keeps the diff to the ordering.

Second commit: comment preservation

@jsxImportSource preact on the line above a removed import was being dropped. Babel attaches a file's opening comments to its first statement, so removing that statement took the pragma with it and the page silently rendered through the configured default JSX factory. Legal banners and lint suppressions went the same way. The fix relocates a removed statement's leading comments onto the next surviving statement, and comments that trail the last surviving statement are reattached there.

Third commit: unrelated unused imports are left as authored

dropUnusedImportBindings reduced an unused import it does not own to a bare side-effect import, described in the code as being "for compatibility with the older conservative behavior". It no longer does. It removes only the specifiers the stripped hooks actually owned and leaves everything else exactly as authored.

The demotion was an artifact of the strip running last, like the sourcemap handshake above. With nothing downstream to clean up, reducing an unused named import to import "./x.ts" conservatively preserved the module's side effects. With compile running after the strip, the same reduction converts an erasable named import into a non-erasable bare side-effect import. Side-effect imports are preserved by design, so the demotion defeated the cleanup that would otherwise drop the module. That is the whole reason the shape leaked post-reorder and did not before, and why main is clean: there the compiler erases the import before the pass ever sees the module.

Two conditions the decision put on the change, both met:

  1. A genuine side-effect import is untouched. import "./analytics.ts" as authored is still a side-effect import in the artifact. Pinned by keeps a bare side-effect import untouched at the stage level and by keeps a genuine side-effect import in the artifact end to end through runPipeline.
  2. The correctness depends on compile running after the strip. A PIPELINE ORDERING DEPENDENCY note now sits at the call site in stripServerOnlyExports, naming TransformStage.PARSE + 0.5 and TransformStage.COMPILE and stating that a revert of the ordering has to bring the reduction back. The stage field carries a pointer to it, and the note also records how the second caller (createSplitterPlugin) meets the same requirement, by stripping inside an esbuild onLoad hook so the bundle step tree-shakes afterwards.

Eleven tests encoded the old contract. None were bulk-updated; each was classified first.

tests classification what was done
leaves an unrelated unused project import exactly as authored (renamed from keeps an unrelated unused project import as a side-effect import), deletes a mixed import whose surviving specifier nothing reads (renamed from keeps side effects for ordinary imports with mixed hook-only bindings) (a) encoded the demotion itself inverted: the first now asserts the import is left as authored, the second that a mixed import the hooks partly owned is deleted whole. A companion, keeps every specifier of a mixed import the browser still reads, pins the other side
the four keeps an unrelated import when ... shadows its name scope cases, does not keep an import binding whose name matches an enum member, does not keep an import shadowed by a hoisted namespace alias, does not keep an import shadowed by a namespace-local enum (b) about scope-aware liveness, observed through the demoted form expected output updated, subject unchanged. The scope cases still pin that a shadowing local does not make the import hook-owned, and the enum and namespace cases still pin that secretOnly is removed while the falsely-live binding is not
still erases an undecorated declared property, still deletes an import a same-scope enum genuinely shadows (renamed from still reduces ...) (b), but the observation vehicle collapsed fixture re-anchored, see below
keeps a bare side-effect import untouched (c) genuine side-effect import unchanged, still passing

The two re-anchored cases need calling out, because "update to the new expected output" would have made them vacuous. Both were paired contrasts (@audit declare id versus plain declare id; a block-scoped enum versus a same-scope enum) whose two halves were told apart only by "import demoted" versus "import intact". Once the demotion is gone, both halves produce an intact import and the pair stops discriminating. The fixtures now put the imported binding inside the hook's closure, so the contrast is between "import deleted" and "import intact" instead. Their partners (keeps a decorator on a declared property, which still emits a runtime call and does not hoist a block-scoped enum into the enclosing function scope) got the same fixture change for the same reason; without it they would have passed no matter what the walker classified.

Fourth commit: the same rule on every source dialect

The third commit left the specifiers the hooks did not own in place on a mixed import, on the reasoning that the compiler erases what is genuinely unused. That reasoning holds only for the ts and tsx loaders, where a specifier may name a type and elision is permitted. .js maps to the js loader, .jsx, .md and .mdx to jsx, and under those loaders esbuild must preserve the module for its side effects, so it rewrites the remainder into exactly the bare side-effect import this stage forbids. The stage emits byte-identical output for probe.tsx, probe.jsx and probe.js, so the loader alone decided the outcome and a .tsx-only fixture could not see it.

dropUnusedImportBindings now deletes the whole statement whenever the stripped hooks owned at least one specifier and no surviving specifier is read, on every dialect. An import the hooks own nothing of is still left exactly as authored, and a bare side-effect import is still untouched. Tests cross the dialect axis (ts, tsx, js, jsx, mdx) with the import shapes (mixed specifiers, a default-import sibling, both import-equals forms) in dev and prod; the JavaScript-dialect cells fail without the fix and the TypeScript ones pass either way.

Fifth commit: exported import-equals

moduleScopeDeclarations now sees TSImportEqualsDeclaration, and Babel represents export import A = require("./a.js") as that same node with isExport: true rather than wrapping it in an export declaration, so the "exported declarations are never candidates" rule needed the flag spelled out. Without it a hook-only exported alias would be pruned out of the module's contract.

Behaviour change worth being explicit about

For a mixed import such as import { initClient, loadSecret } from "./client-setup.ts" where the hook owned only loadSecret and nothing browser-side reads initClient, main keeps import "./client-setup.js" in the artifact and this branch deletes the import. That is the intended consequence of the decision, not an accident: the module was reachable only through server-only code. It is the one shape where the artifact differs from main in the other direction, and it is a removal, not a leak. A mixed import the browser still reads any specifier of keeps every specifier it was authored with.

Verification

Re-measured at this head. The table this replaces reported main d260ddf4 against 0e476a84, the third of twelve commits, at 4220 passed and 106 failed with tests/integration and tests/e2e unfinished. That row is stale in every column: the branch has moved six commits past it, main has moved, and the machine that produced those 106 failures was starved by orphaned processes from an unrelated session. Both trees below were built by git archive from a shared object store, run concurrently with a distinct VERYFRONT_BINARY each, on the same machine at load average 3, with the command and environment from AGENTS.md.

group main c4e93cd8 this branch 38a0a0be
cli docs extensions react storybook templates rfcs 530 passed, 4 failed 530 passed, 4 failed
src 3554 passed, 103 failed, 1 ignored 3554 passed, 102 failed, 1 ignored
tests/ minus e2e and integration 148 passed, 1 failed 147 passed, 1 failed
total 4232 passed, 108 failed, 1 ignored 4231 passed, 107 failed, 1 ignored

This table has not been re-run at the current SHAs, and the reason is worth stating rather than papering over. In the sandbox available now, running those roots together aborts 582 of 636 files at module load with The current server runtime does not expose complete node:util/types brand checks from src/platform/compat/native-brand-checks.ts:98. It does so identically on origin/main at 3a109046d0 with no branch content at all, in parallel and serial alike, while the same files pass when run in smaller groups. That is an environment fault, not a signal about either tree, and a number produced through it would say nothing. The table below stands as measured at c4e93cd8 against 38a0a0be. Hosted CI is the authority for the whole-repository column at the current head.

The counts are deno test's top-level test counts, not steps. They are not expected to be equal and were not equal in the previous measurement either: the branch forked at e37285b3 and main has gained tests since, so the two trees do not contain the same set of test files. What is comparable is which files fail.

19 test files fail across both trees. 18 fail on both. Nothing fails only on this branch.

files
fail on both cli/commands/deploy/command.integration.test.ts, cli/commands/schedule/handler.test.ts, cli/commands/up/up.integration.test.ts, cli/mcp/server.test.ts, src/cache/backend.test.ts, src/embedding/embedding.test.ts, src/oauth/handlers/callback-dispatcher.test.ts, src/oauth/providers/base.test.ts, src/oauth/providers/protocols.test.ts, src/provider/model-registry.test.ts, src/provider/veryfront-cloud/provider.test.ts, src/routing/api/module-loader/esbuild-plugin.test.ts, src/routing/api/openapi/mcp-tools.test.ts, src/routing/api/route-executor.test.ts, src/runs/runs-client.test.ts, src/server/handlers/request/agent-stream.handler.test.ts, src/workflow/blob/veryfront-cloud-storage.test.ts, tests/docs/guide-examples.test.ts
fail only on main src/transforms/mdx/esm-module-loader/module-fetcher/dependency-recovery.test.ts
fail only on this branch none

Every one of the 18 is a sandbox failure: this machine has no outbound network, so they fail with SOCKS connect failures, 401s from real API endpoints, or request timeouts. cli/mcp/server.test.ts additionally names different failing STEPS on each run, because it binds a port and which step loses the race varies; the file fails on both either way.

The single asymmetric one is not a main regression and not a branch improvement. It is the two concurrent runs colliding on a shared path outside both trees:

NotFound: No such file or directory (os error 2):
readfile '<HOME>/.cache/veryfront/veryfront-mdx-esm/project-a/preview-main/vfmod-grandchild.mjs'

Both trees write that fixture to the same absolute cache directory, so whichever run cleans up first fails the other. The file is byte-identical on the two trees (4160 bytes), and it passes on either tree run alone.

Two honest gaps, both unchanged from the previous measurement and both symmetric:

  • tests/integration and tests/e2e are excluded, not clean. They are server-backed and need the network this sandbox does not have. CI covers them: tests (integration), tests (binary e2e) and tests (rsc browser e2e) run on every push to this branch.
  • scripts/ is excluded. It aborts at module load on both trees with Import "#babel/parser" not a dependency and not in import map from scripts/build/dnt-meta-property-safety.ts, identically, so it contributes nothing to either column.

Merge-group pre-flight: origin/main at c4e93cd8 merged into a scratch clone, clean merge, deno check over the full typecheck entrypoint list clean on the merged tree (40 entrypoints, exit 0).

That conflict is resolved. a4ec5b28 (#3861, fix(transforms): prune destructured server values) rewrote parts of the same file while this branch was in review. main tightened inClosure from .some to .every so a destructuring declaration is only removed when the hooks own every name it binds; this branch added && !pinned.has(name) to unused so a classic JSX pragma factory survives. The two are independent and the resolution keeps both. 51b31a52c4 merged it that way. git merge-tree --write-tree origin/main HEAD returns a tree with zero conflict entries, both at main 3a109046d0 and again at main 8d0ce3c2f0.

The pinned half is no longer untested either. Deleting && !pinned.has(name) and running browser-server-exports-strip.test.ts gives 0 passed, 1 failed, killed by keeps partially hook-owned destructuring and pinned JSX factory names.

deno task typecheck itself still exits non-zero on its first step, generate:manifests:check, with ./templates/manifest.generated.ts is stale. Re-measured this time on plain origin/main in a fresh tree with no branch content at all: same failure, same message, exit 1. Pre-existing and unrelated. The deno check half was run directly and is clean.

deno task lint:ci: clean, exit 0. deno fmt --check, deno lint, deno check on the touched files: clean. src/transforms/ at this head: 162 files, 2815 steps, 0 failed.

The head has moved well past 38a0a0be. It is now 191234f9, after a merge of main (51b31a52c4), two API-reference commits, and the two commits described under The pre-parse gate below. src/transforms/ was re-run at 191234f9: 162 files, 2838 steps, 0 failed, exit 0. deno check, deno lint and deno fmt --check on the touched files: exit 0 each.

Two CI checks failed on 89d59a5e and both are accounted for. tests (bun) is the JavaScriptCore table drift, fixed by 30f34a83. tests (rsc browser e2e) failed in its Install Chromium step before any test ran, twice with two different apt faults (exit 124 retrying azure.archive.ubuntu.com, then Could not get lock /var/lib/apt/lists/lock): runner infrastructure, unrelated to this branch. Re-running that job alone turned it green.

CI/CD at 30f34a83: 20 passed, 7 skipped, 0 failed, including tests (bun), tests (node), tests (integration), tests (binary e2e) and tests (rsc browser e2e).

Security property, measured through the real reordered runPipeline

For a module with a server hook, a hook-only helper, a server import and a secret, the browser artifact contains no server import, no secret initialiser, and a stubbed hook body. Asserted as a test on this branch.

The five pre-compile cases the spike's challengers found, each re-run end to end against current main and against this branch:

case main this branch (b0f7653b)
typeof KEY in a parameter type must not pin KEY clean clean
ReturnType<typeof schema.parse> must not drag the secret or the server module clean clean
import { hashOf, type Cfg } deleted, not demoted clean clean
unused import must not become a bare side-effect import clean clean, was leaking
@jsxImportSource above a removed import survives clean clean
a lowercase JSX member object (<motion.div>) keeps its binding clean clean, was deleting the binding
export { x } from "..." must not pin a hook-owned import of the same name clean clean, was leaking

The first three were closed by #3849. The fifth was fixed in this PR's second commit. The last two are defects 4A and 4B, closed by the seventh commit below as instances of one class rather than one at a time.

The fourth one, now closed

import { unusedThing } from "./lib/server-only-lib.ts";
export async function getServerData() { return { props: {} }; }
export default function Page() { return null; }

Measured artifact through the real browser pipeline, ssr: false, production output:

  • Before (30c64eeb, this branch's previous head): var n=Object.defineProperty;var r=(e,t)=>n(e,"name",{value:t,configurable:!0});import"./lib/server-only-lib.js";async function l(){throw new Error("server-only")}... The server module and its transitive graph execute in the browser.
  • After (this branch): var t=Object.defineProperty;var r=(e,n)=>t(e,"name",{value:n,configurable:!0});async function a(){throw new Error("server-only")}... No reference to server-only-lib at all.
  • main: no reference to server-only-lib either. Parity restored.

The end-to-end assertion lives in does not demote an unrelated unused import to a side-effect import, and its paired protection lives in keeps a genuine side-effect import in the artifact.

Sixth commit: development mode pinned

compilePlugin sets treeShaking: !ctx.dev, so the end-to-end probe above only covered the tree-shaken path. Measured across ts, tsx, js and jsx crossed with dev and prod, every cell matches main: the erasure is TypeScript unused-import elision, which both TypeScript loaders perform in either mode, not tree shaking. The development probe is pinned as a test anyway, and the ordering note now names the mechanism so nobody reads "esbuild erases it" as tree shaking.

Seventh commit: the reference walkers are exhaustive

Rounds three and four of review each found a real defect, and both fell through the same hole: a generic visitChildren fallback that descended into node types nobody had classified and counted their identifiers as runtime reads. TypeScript got an explicit classification in #3849 and has been stable since; everything else was ad hoc. This commit closes the class rather than the two remaining instances.

src/transforms/pipeline/stages/reference-classification.ts now classifies every node type the pinned @babel/types (npm:@babel/types@7.29.0, the specifier extensions/ext-parser-babel/deno.json resolves) defines, and both walkers read the same table.

The classification

Node types, excluding the 65 Flow nodes (the flow plugin is never enabled: pickPlugins always enables typescript, and the two are mutually exclusive) and the 4 deprecated builder-only aliases:

class count meaning
read 2 the node names a binding read at runtime: Identifier, JSXIdentifier
erased 52 neither the node nor anything under it emits runtime code, all of them TS-prefixed
structural 134 neither, but a child can be a read. Descend, subject to the position table
total 188 every non-Flow, non-deprecated type in the package

Of the 67 TS types, 52 are erased and 15 emit runtime code. That split is #3849's list, moved but unchanged.

Positions, not just node types, decide the answer. isReferenceChildKey records every child key that holds a fixed name, string-like text or a binding position, so both walkers skip the same subtrees instead of each keeping its own list. The corrected cells:

position before after direction of the old defect
JSXMemberExpression.object intrinsic-tag rule applied, so a lowercase object was string text always a read over-deletion, defect 4A
JSXOpeningElement.name / JSXClosingElement.name the rule was applied to every JSXIdentifier anywhere the rule applies here only scoping the rule is what closes 4A
ExportNamedDeclaration.specifiers when a source is present descended into, both halves of each specifier counted as free reads not a read leak, defect 4B
ExportAllDeclaration.* descended into not a read no identifier to leak, pinned for completeness
ExportSpecifier.exported walker 1 only both walkers
MemberExpression / ObjectProperty / class member keys, TSEnumDeclaration.id, TSEnumMember.id, TSQualifiedName.right, JSXAttribute.name, JSXNamespacedName.*, JSXMemberExpression.property two separate mechanisms, one per walker one table drift risk removed, behaviour unchanged
PrivateName.id, ClassPrivateProperty.key, ClassPrivateMethod.key descended into by the scope-aware walker not a read over-retention, newly closed
BreakStatement / ContinueStatement / LabeledStatement.label, MetaProperty.meta and .property, ImportAttribute.key and .value, Directive text descended into not a read over-retention, newly closed

ClassPrivateMethod also now gets a function scope in the scope-aware walker, like ClassMethod and ObjectMethod, instead of falling through the generic descent.

Two positions are deliberately left as reads: TSModuleDeclaration.id and TSImportEqualsDeclaration.id. They are binding positions, and the flat walker counts them on purpose, which is the over-retaining direction for module-declaration liveness. treats runtime TypeScript declaration names as bindings, not reads pins that. Moving them into the table would make the flat walker delete a module-scope declaration that shares the name, so it is a separate decision and not one this PR takes.

The default, and why it is asymmetric

An unrecognised node type defaults to structural: descend, and count the identifiers under it.

The two directions are not symmetric. Defaulting to a read over-retains, so a server-only import can survive into the artifact. Defaulting to "not a read" over-deletes, so live code goes and the page fails to load. structural is the over-retaining choice, and it is right for these walkers:

  • A reference walker answering "is this name read?" is only sound when it over-approximates. Under-approximating is the unsound direction for any liveness analysis, and it is where every regression in this pass has come from, 4A included.
  • Over-retention degrades to the behaviour before this pass existed. It never emits an artifact that throws, and compile still elides a genuinely unused import under ts and tsx while the bundler still tree-shakes.
  • Over-deletion produces a ReferenceError at module evaluation, which no later stage can undo.
  • New ECMAScript syntax is value syntax holding real expressions, so treating an unrecognised one as erased is nearly always wrong.

An unrecognised TS-prefixed node type keeps the opposite default, erased, unchanged from #3849. The TypeScript grammar inverts the argument: almost everything it adds is type syntax the compiler erases, and descending into an unknown type node would count p: typeof KEY as a use of KEY and pin the import it came from.

Both defaults are fallbacks, not policy. The guard test makes an unclassified node type a build failure, so in a checked build neither one fires.

The guard test

reference-classification.test.ts reads NODE_FIELDS out of the pinned @babel/types and asserts the classification names every type in it. A Babel upgrade that adds a node type breaks the build instead of silently leaking. A second case asserts the pinned specifier still equals the one the parser extension resolves, so a parser upgrade that leaves this test behind fails too, and a third asserts the guard reports a type the classification does not name, because a guard that cannot fail proves nothing. Two gaps in that guard, the unpinned parser and the unstated Flow assumption, are closed in the ninth commit below.

The two defects, measured through runPipeline

4A, over-deletion. isIntrinsicJsxName implements Babel's isCompatTag, which is valid only for a bare JSXIdentifier that is the element name. Run on the object of a member element name it classified motion, styled, ui and dialog as string tag text, so the pass deleted the binding the element reads. Three trigger paths, and the second needs no hook relationship at all:

trigger artifact before artifact after
the binding is read by the stripped hook (import { motion } from "./lib/motion.ts") function t(){return i(motion.div,{children:"x"})} with no import of motion: ReferenceError on render import kept, motion.div resolves
the source is node: or veryfront and hits the droppable-source branch (import { dialog } from "veryfront/ui") a(dialog.Root,...) with no import of dialog import kept
a hook-owned module-scope declaration (const styled = makeStyled()) declaration pruned, <styled.div> left dangling declaration kept

This repo uses the idiom in src/react/components/ui/{dialog,drawer,popover,tabs,toolbar,accordion,combobox,toast,toggle-group,alert-dialog,autocomplete}.tsx.

4B, leak. export { token as clientToken } from "./client-utils.js" reads no local binding: token names an export of the source module. Neither walker had a case for it, so the generic descent counted both halves of the specifier as free reads and kept a hook-owned import { token } from "./lib/server-only-lib.js" alive. Measured artifact on .jsx:

  • before: import"./lib/server-only-lib.js";import{token as p}from"./lib/client-utils.js";... The server module executes in the browser, and esbuild has already demoted it to the bare side-effect form this stage forbids.
  • after: no reference to server-only-lib at all, on .jsx and on .mdx, where the jsx loader elides nothing downstream.

The other side is pinned too: without a source, export { token as clientToken } really is this module's binding and the import that provides it stays.

Mutation testing

Every cell of the classification was reverted individually and the suite re-run. 41 distinct mutations, 39 killed:

mutation first test to fail
JSXMemberExpression.object back to a non-read (4A) keeps a lowercase member object a stripped hook also read
element-name intrinsic rule always off does not keep a hook-only import that shares an intrinsic tag name
ExportNamedDeclaration.specifiers read even with a source (4B) deletes a hook-owned import a re-export clause only looks like it reads on .jsx
ExportSpecifier.exported back to a read reads the local half of an export clause with no source
ExportDefaultSpecifier and ExportNamespaceSpecifier exported back to reads reads the exported name of neither default nor namespace re-export
ExportAllDeclaration keys back to reads reads nothing of an export-all declaration
JSXAttribute.name back to a read does not keep a hook-only import that shares a jsx attribute name
JSXNamespacedName halves back to reads does not read either half of a namespaced name
JSXMemberExpression.property back to a read does not keep a hook-only import that shares a member element property
MemberExpression and OptionalMemberExpression property back to reads does not count a matching property name as a reference
ObjectProperty.key back to a read does not read an object literal key
ObjectMethod.key back to a read does not read a fixed method or class member key
ClassMethod, ClassProperty and ClassAccessorProperty keys back to reads does not read a fixed method or class member key
computed key no longer a read (MemberExpression family) reads a computed member property but not a fixed one
computed key no longer a read (method and class member family) reads a computed method or class member key
PrivateName.id and private class keys back to reads does not read a class private name
label positions back to reads does not read a statement label
MetaProperty halves back to reads does not read either half of a meta property
ImportAttribute key and value back to reads reads neither half of an import attribute
ImportSpecifier local and imported back to reads reads neither half of an import specifier
ImportDefaultSpecifier.local back to a read reads neither half of an import specifier
ImportNamespaceSpecifier.local back to a read reads neither half of an import specifier
ImportDeclaration keys back to reads reads neither half of an import specifier
File.comments and File.tokens descended into reads neither the comments nor the tokens of a parsed file
Placeholder.name back to a read reads the name of neither placeholder form
Directive text back to reads reads nothing of a directive
TSEnumDeclaration.id and TSEnumMember.id back to reads treats runtime TypeScript declaration names as bindings, not reads
TSQualifiedName.right back to a read drops a hook-only binding that matches a qualified-name property
TSEnumDeclaration removed from the runtime list keeps an enum member initialiser
TSEnumMember removed from the runtime list keeps an enum member initialiser
TSParameterProperty removed from the runtime list keeps a parameter property default
TSTypeReference removed from the erased list the guard test
TSTypeQuery removed from the erased list the guard test
TupleExpression removed from the classification the guard test
JSXIdentifier no longer a read keeps a lowercase member object a stripped hook also read
Identifier no longer a read removes default parameter dependencies from a function hook
default class flipped to erased treats an unrecognised node as structural
TypeScript default class flipped to structural treats an unrecognised TypeScript node as erased
the guard fed a deliberately unclassified node type classifies every node type the pinned @babel/types defines

Nine of those cells survived the first pass and are the reason for the eighth commit below. None of them was wrong in the implementation: each was a cell the table classified and nothing asserted, so a later edit could have flipped it silently. Cases were added per cell and every one is killed now.

Two mutations survive and always will. V8IntrinsicIdentifier.name, DirectiveLiteral.value and InterpreterDirective.value are string fields in NODE_FIELDS, and referenceChildren collects only node-shaped values, so naming those keys in the table cannot change its output. They are equivalent mutants, not coverage gaps, and the entries stay because they document the answer.

Three cells (ExportAllDeclaration, ImportAttribute, Directive) hold no identifier a walker could report, so a misclassification there is invisible in an artifact. They are pinned by unit assertions on referenceChildren instead, which is why they are killed rather than surviving.

Suite

browser-server-exports-strip.test.ts goes from 182 to 202 steps and the new reference-classification.test.ts adds 22, all passing. Suite growth on its own proved nothing here: the 182-step suite passed with both 4A and 4B present, which is exactly why every new case is asserted through the real runPipeline and every classification cell is mutation-tested.

The positive cases stay pinned: <Card />, <UI.Item />, <UI.Item.Card />, <Table /> beside <table />, expression containers, spread attributes, both fragment spellings, <Card as={Bar} />, and <motion.ui.Panel /> two levels deep.

Eighth commit: every classification cell is pinned

Mutation testing the seventh commit found nine cells the table classified and no test asserted: the fixed key of an object method, a class method, a class property and an auto-accessor, the computed form of each of those, the comments and tokens a parsed file carries beside its program, every import specifier shape, the exported name of a default or namespace re-export, and the identifier-valued name of a placeholder.

None of them was wrong. Each was a cell a later edit could have flipped without a test noticing, which is the failure mode the classification exists to end. This commit adds one case per cell: the method and class member keys through both walkers, since a fixed key counted as a read pins the import it collides with, and the rest on referenceChildren, because they hold no identifier a walker could report. Implementation unchanged, tests only.

Ninth commit: the predicates are tested against the compiler, not against a second opinion

The seventh and eighth commits close the node-type class. They do not close the class above it, and a review of this branch named the reason exactly: the guard pins the node-type TABLE, it does not pin the PREDICATES that approximate compiler rules. A table entry says "JSXIdentifier is a read". It says nothing about the rule that decides which JSXIdentifier in an element-name position is tag text, and that rule was wrong.

The defect

isIntrinsicJsxName tested for an identifier with an ASCII-only regular expression:

if (/^[a-z]/.test(name)) return true;
return !/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name);

esbuild's rule is: the first character is ASCII a-z, or the name is not a valid ECMAScript identifier, and its identifier grammar is full Unicode. So every JSX element name that IS a valid identifier and contains a non-ASCII character was classified as intrinsic tag text. Both walkers then skipped JSXOpeningElement.name, and the import or declaration the element named was deleted while the compiled artifact still called it.

Measured through the real runPipeline, ssr: false:

source artifact before artifact after
import { Café } from "./ui/cafe.tsx", read by the hook and by <Café /> jsx(Café, {}) with no import of Café: ReferenceError on render import kept
const Café = () => null, read by the hook and by <Café /> declaration pruned, jsx(Café, {}) left dangling declaration kept

The rule now delegates to one Unicode-aware identifier test built from \p{ID_Start} and \p{ID_Continue}, with $ and _ spelled out because neither carries the Unicode property.

The first clause stays deliberately ASCII. esbuild compares the first character against a-z, so ωmega, привет and 日本語 are binding reads even though a reader would call their first letter lowercase, and widening that clause to \p{Ll} would delete exactly those imports.

The sibling, same predicate, same commit

pragmaRootBinding in browser-server-exports-strip.ts carried the identical ASCII-only expression. A @jsxRuntime classic module whose pragma names a non-ASCII factory root lost the pin, so dropUnusedImportBindings deleted the factory import while esbuild emitted Ħ.créate("div", null). Both now share isEcmaScriptIdentifier.

The actual work: differential tests

Fixing two instances would leave the class exactly where the node-type class was before the seventh commit. src/transforms/pipeline/stages/compiler-predicates.test.ts closes it: every predicate in this stage that stands in for a compiler or parser rule is now compared against the real compiler on a corpus, instead of against a second hand-written expectation of the same rule. A hand-written expectation restates the predicate and passes whether or not either one is right. Asking esbuild what it emitted fails when the two disagree, whoever moved.

predicate rule it approximates oracle
isIntrinsicJsxName esbuild's tag-text test for an element name compile <Name /> with a factory this test owns, then read the first argument off the parsed artifact: a StringLiteral is tag text, anything else is a binding read
isEcmaScriptIdentifier the ECMAScript identifier grammar the runtime evaluating the test, asked with function <name>() {}, over every code point up to U+2FFFF, with esbuild settling the disagreements (see below)
pragmaRootBinding, jsxPragmaBindings esbuild's classic JSX pragma parsing compile an intrinsic-only JSX module, collect the factory roots the artifact does not import, assert roots ⊆ pinned ⊆ roots ∪ {React}
the declare short-circuit, its nodeHasDecorators exception, the importKind/exportKind short-circuits, and the whole RUNTIME_TS_NODE_TYPES / erased split TypeScript erasure 31 fixtures each importing one Probe and using it in one position; esbuild's own import elision answers "is this a value read?", and both walkers must give the same answer
compilerNameHelperBindings esbuild's keepNames helper shape the real minified artifact, not a hand-written __name
SOURCE_MAP_SUFFIX esbuild's sourcemap comment the real inline map esbuild writes

The element-name corpus covers ASCII lowercase and uppercase, leading _ and $, digits, dashes, data- names, Latin-1 accents precomposed and decomposed, Greek, Cyrillic, CJK, ID_Start code points outside the letter categories (, , ), and both joiners in an ID_Continue position. Every one of the 36 names agrees with the artifact.

Two families where the parser and the compiler do not agree are recorded rather than hidden, because both directions are fail-safe and neither is obvious:

  • U+1C8A, added in Unicode 14, parses and is rejected by esbuild's older tables. The predicate answers "binding read", which retains the import, and the build then fails loudly at compile. The over-deleting answer would have removed the import first.
  • An astral element name such as 𝒞ard is a binding read for esbuild and is rejected by the parser, so the strip stage fails the build before the predicate is consulted.

Three rules in this stage are deliberately not differentially tested, and the test header says so rather than substituting a hand-written expectation and calling it verified:

  • freeReferencedIdentifiers and patternBoundNames approximate ECMAScript scoping. No compiler reports a scope resolution in its output: esbuild resolves scopes internally and the artifact never names the binding a reference resolved to, so there is no answer to compare against. They stay covered by behavioural fixtures.
  • retainLeadingComments approximates the parser's comment attachment. Its differential partner would be Babel, which is also the tool that produced the input, so the comparison would be circular.
  • SERVER_ONLY_EXPORTS and isKnownDroppableSource are framework policy. Nothing outside this repository owns the rule.

Guard hardening

Two gaps in the seventh commit's guard, both closed here.

The parser was not pinned. The guard's oracle is @babel/types@7.29.0. The nodes are emitted by @babel/parser@7.29.2, pinned separately in the same manifest, and that parser resolves its own @babel/types@7.29.7. The pins already drift. A parser-only bump would leave the guard green while a new TS-prefixed node type fell to DEFAULT_TS_REFERENCE_CLASS = "erased", which is the over-delete direction. The parser pin is now asserted too, so bumping either one fails the build and whoever bumps it re-runs the guard against the new package.

The Flow filter rested on an unstated assumption. parseableNodeTypes removes the 65 Flow types from the guard's scope, which is sound only while pickPlugins always enables typescript (Babel refuses to enable flow and typescript together). That is now an assertion over every path the parser extension takes (.tsx, .ts, .jsx, .js, .mjs, .cjs, .md, .mdx, and no path at all): each must parse TypeScript-only syntax and refuse Flow-only syntax. A second case asserts the filter is not vacuous. Enabling Flow later fails here instead of silently uncovering 65 unclassified types.

Mutation testing

Each fix and each new guard was reverted individually and the two suites re-run. 10 of 11 killed:

mutation tests that fail
isIntrinsicJsxName back to the ASCII-only regular expression 19 differential cases, including <Café />, <A‌b /> and <A‍b />
pragmaRootBinding back to the ASCII-only regular expression 5 pragma cases, including the Latin-1, Cyrillic and CJK member factories
drop the declare short-circuit agrees with the artifact about an ambient class heritage clause, plus the existing ignores declare forms and declared function signatures
drop the nodeHasDecorators exception agrees with the artifact about a decorator on an ambient member, plus keeps a decorator on a declared property, which still emits a runtime call
drop the importKind / exportKind short-circuits agrees with the artifact about a type-only export statement and about an inline type export specifier, plus ignores type-only import and export specifiers
move TSEnumDeclaration from the runtime list to the erased one agrees with the artifact about an enum initialiser, plus 5 existing enum and namespace cases
bump the @babel/parser pin in the extension manifest pins the @babel/parser the nodes are emitted by
enable the flow plugin in pickPlugins never enables the Flow plugin the guard's filter depends on
recognise the keepNames helper by binding name instead of by shape recognises the keepNames helper in real minified output, plus prunes hook-only helpers from compiled keepNames output
stop dropping the sourcemap suffix drops the sourcemap comment esbuild actually writes, plus removes an external source map reference after stripping
remove the two joiner escapes from the identifier expression survives

The survivor is an equivalent mutant and is reported as one rather than papered over. V8's \p{ID_Continue} already matches ZWNJ and ZWJ, so the two explicit escapes change no answer on this runtime. They stay because the ECMAScript grammar lists the joiners separately from the property, and the property is the engine's table rather than the specification's rule. The behaviour they exist for is pinned anyway: the code-point sweep asks the runtime about both joiners, and accepts both joiners inside an identifier and neither at the start pins the four answers directly. A comment in reference-classification.ts records this so a reader does not mistake redundancy for coverage.

The differential tests also have to be able to fail for the right reason, not only when a predicate is deleted. disagrees with the artifact when the identifier test is ASCII-only re-runs the whole element-name corpus against the old ASCII predicate inside the test and asserts it fails, so the corpus itself is proven to discriminate.

Tenth commit: the engines do not agree with themselves

The code-point sweep first asserted that the predicate agrees with the host runtime's own lexer. CI found that wrong within one push: it holds on V8 and fails on JavaScriptCore.

\p{ID_Start} and \p{ID_Continue} are the ENGINE's regular-expression tables. The engine's lexer is a different table, versioned independently, inside the same runtime. Bun on CI named about twenty code points where its two tables differ (U+088F, U+0897, U+0C5C, U+0CDC, U+1ACF through U+1ADB). A Bun of a different version on another machine names three entirely different ones (U+2FE1, U+AA51, U+E8C5). Deno names none.

Runtime agreement was never the property that matters, which is the point the failure made. The predicate is asked about names the COMPILER will meet, so the compiler decides whether a table disagreement can reach an artifact. The sweep now collects the disagreements and asserts esbuild refuses every one of them: the module does not build, so nothing can depend on which table was right. If esbuild ever accepts such a name the test fails, because then the two answers differ on a name that ships and one of them deletes a live import.

Checked in both directions against the real compiler: all twenty code points CI's Bun named and all three the other Bun names are rejected by esbuild.

This is the differential principle applied to the differential test itself. The first version compared the predicate against a second opinion that happened to be handy; the second compares it against the thing that decides.

The pre-parse gate reads the grammar, not the spelling

This is the one regression the reorder introduced, and it is closed by 55cd7972 and 191234f9.

stripServerOnlyExports decides whether to parse at all from raw source text. On main that text is esbuild's output, where an escaped export name has already been normalized to getServerData, so the substring test finds it and the module is parsed. Run before compile, the gate sees the authored text with the escape intact:

// app/dashboard/page.tsx
import { getEnv } from "veryfront";
const KEY = getEnv("SERVER_ONLY_HOOK_SOURCE");
async function loadIt() { return { props: { k: KEY } }; }
export { loadIt as get\u0053erverData };
export default function Page() { return null; }

The raw text holds neither the substring getServerData nor a quote after as, so the gate returned false, the module was never parsed, exportedHookBindings never ran, and the artifact shipped everything this stage exists to remove. Measured through the real runPipeline, ssr: false, production output, at e41b94476d:

var n=Object.defineProperty;var t=(r,e)=>n(r,"name",{value:e,configurable:!0});
import{getEnv as o}from"/_vf_modules/_veryfront/index.client.js";
const a=o("SERVER_ONLY_HOOK_SOURCE");
async function c(){return{props:{k:a}}}t(c,"loadIt");
function u(){return null}t(u,"Page");
export{u as default,c as getServerData};

The secret initialiser, the real hook body and the veryfront server import are all there, and esbuild has normalized the name back to getServerData, so the runtime lookup of mod.getServerData still finds it.

Widening the gate one spelling at a time does not close it. Six shapes were measured, all clean on main at 3a109046d0 and five of them leaking at e41b94476d:

source main 3a109046d0 e41b94476d 191234f9
export { loadIt as get\u0053erverData } on .tsx stubbed secret and hook body ship stubbed
export async function get\u0053erverData() stubbed secret and hook body ship stubbed
export { loadIt as get\u0053erverData } on .js stubbed secret and hook body ship stubbed
export{loadIt as"get\u0053erverData"}, no space after as stubbed secret and hook body ship stubbed
a string-literal name split by a line continuation, no space after as stubbed secret and hook body ship stubbed
export { loadIt as "getServerData" } stubbed stubbed stubbed

The last leaking row matters for how the fix was chosen. It carries no \u or \x escape at all, so an escape-specific regular expression closes the other four and leaves it open. The old quote clause required whitespace after as, which export{loadIt as"…"} does not have, and a line continuation spells no hook name in raw text.

So the gate now answers from the grammar instead. An exported name is an IdentifierName or a StringLiteral, and in both the only way to write a character as anything other than itself is an escape sequence, which always starts with a backslash. A module whose text holds no backslash therefore spells every name it exports verbatim, and the substring test settles it. Anything else parses, and exportName reads the name the way the runtime will. export itself cannot hide either, because a reserved word written with an escape is a syntax error.

function mayNameServerOnlyExport(code: string): boolean {
  if (!/\bexport\b/.test(code)) return false;
  if (SERVER_ONLY_EXPORTS.some((name) => code.includes(name))) return true;
  return code.includes("\\");
}

The gate is only a performance optimisation, so the alternative worth measuring was to drop it and parse every module that contains export. Over templates/files (60 modules, 54 KB, 20 runs averaged), whole tree: the old gate 1.05 ms, this gate 1.76 ms, parsing every exporting module 3.76 ms. Over src/transforms (282 modules, 2.5 MB, a pessimistic regex-heavy upper bound and not app-shaped code): 16.6 ms, 52.8 ms and 67.3 ms. Five of the 54 exporting modules in the app tree carry a backslash and now parse. Soundness costs 0.7 ms on a realistic tree and stays cheaper than parsing everything, so there is no reason to trade it away.

The test that closes the class

compiler-predicates.test.ts excused SERVER_ONLY_EXPORTS as framework policy with no differential partner. That conflated the SET of hook names, which is policy and has no oracle, with the test for whether a module exports one, which is a parser rule esbuild answers exactly. Every fixture in that file read a predicate's verdict off the artifact. None asked the artifact which names the module exports.

That case exists now. It compiles the module, reads the export names off the parsed artifact, and asserts the set of hook-named exports the pass emptied equals the set the artifact actually has. Set equality against the compiler fails for any spelling, so a sixth or seventh way to write the name needs no new fixture. Ten spellings drive it, and a guard case asserts the hidden ones really are hidden, so a \u the test file resolved at its own parse time cannot pass for the wrong reason.

Five of the ten fail against the gate 55cd7972 replaced.

Still open, unchanged by this PR

An unrelated unused import in an authored JavaScript page. The erasure above is TypeScript import elision. Under the js and jsx loaders nothing elides, so an import the stripped hooks own nothing of survives and esbuild rewrites it into a side-effect import itself. Measured on main, on 30c64eeb and on this branch, in dev and prod: identical in all of them, module kept. Contrast the shape 8714b733 closed, an import the hooks do own a binding of, which is now deleted outright on every loader. Pre-existing on main, no regression here, and out of scope for the recorded decision.

Destructuring shapes (nested object, array, rest, computed key and sibling default) leaked the secret identically before and after the reorder. #3861 closed the nested-object and array shapes on main, and that fix is merged into this branch by 51b31a52c4. Rest, computed key and sibling default still leak, identically on main at 3a109046d0, on main at 8d0ce3c2f0, and on this head. No regression in either direction, and out of scope here.

The same predicate shape in two SSR stages, pre-existing on main. Enumerating the compiler-approximating predicates in the browser strip turned up two more in the SSR pipeline, in ssr-css-strip.ts (lines 101, 109, 135) and ssr-http-stub.ts (lines 54, 59, 71). Each parses an import clause with the same ASCII-only identifier expression this commit replaced, and each falls through to a comment when the expression does not match. Measured on main and on this branch, identically:

import stylés from "./Button.module.css";
export default function Page() { return stylés.container; }

SSR artifact, both trees: /* css import: /project/pages/Button.module.css */; followed by return styl\u00E9s.container;. The proxy stub is never declared, so SSR throws a ReferenceError. Those stages run after compilePlugin, which escapes a non-ASCII identifier in its output, so the clause text the regular expression sees carries a backslash and cannot match the ASCII class either way.

This is the same class and a different stage, on the SSR target rather than the browser one, and it is untouched by this PR in either direction. Fixing it means moving those two stages off regular-expression rewriting of import clauses, which is its own change with its own tests. Recorded here rather than folded in.

Re-measured against current main

main has moved 41 commits since the tables above were taken, from 3a109046d0 to 8d0ce3c2f0, and
those 41 commits are merged in here.

That merge fixes a gate this branch was failing. deno task typecheck exited 1 at its first step,
generate:manifests:check, with ./templates/manifest.generated.ts is stale, while the same command exited
0 on plain origin/main at 8d0ce3c2f0 in a fresh tree. The earlier note in Verification calling that
failure pre-existing was true against main at c4e93cd8 and is no longer true: main regenerated the
manifest in the meantime. This branch never edits that file, so it still carried the merge-base bytes. The
two versions decompress to byte-identical content (990887 characters, same 48 templates and 467 files), so
only the committed gzip encoding was behind, and generate-templates-manifest.ts --check compares the
encoded string. Merging main takes its copy and the check passes. Nothing was hand-regenerated. That merge is 1f1d0c41b4,
and the head is now 1f1d0c41b4. The effective diff is unchanged by it: 9 files, +3543/-420 against the new
merge base.

Every
claim that compares the two trees was re-run at the new SHA, through the real runPipeline({ ssr: false })
with production output, on main 8d0ce3c2f0 and on this head 191234f9. Each artifact was grepped for the
secret, the hook body and the veryfront import.

shape main 8d0ce3c2f0 this head
export { loadIt as get\u0053erverData } on .tsx stubbed stubbed
export async function get\u0053erverData() stubbed stubbed
export { loadIt as get\u0053erverData } on .js stubbed stubbed
export{loadIt as"get\u0053erverData"}, no space after as stubbed stubbed
string-literal name split by a line continuation stubbed stubbed
export { loadIt as "getServerData" } stubbed stubbed
rest destructuring leaks the secret leaks the secret
computed-key destructuring leaks the secret leaks the secret
sibling-default destructuring leaks the secret leaks the secret
export * from "./lib/server-only-lib.ts" server module referenced server module referenced

No regression in either direction against current main. The three destructuring shapes and the export *
shape are pre-existing and out of scope, as Still open records.

Also re-run at the current head:

  • git merge-tree --write-tree origin/main HEAD with main at 8d0ce3c2f0: exit 0, zero conflict entries.
  • Reverting the gate at browser-server-exports-strip.ts:158 to the form e41b94476d shipped makes five of
    the six escaped spellings leak the secret, the hook body and the veryfront import through the real
    pipeline, and turns exactly those five steps of compiler-predicates.test.ts red. Restoring it makes all
    six clean again. The gate is load-bearing, and the differential test kills the defect and nothing else.
  • Deleting && !pinned.has(name) at browser-server-exports-strip.ts:1264 and running
    src/transforms/pipeline/stages/ fails on keeps partially hook-owned destructuring and pinned JSX factory names. That mutant stays dead.
  • Public copy rules: grep -n $'[\u2014\u2013]' over all nine touched files finds 14 dash characters, all of
    them present unchanged at the merge base and none on a line this PR adds.

Interaction with #3846

Re-checked against current main. Both PRs touch browser-server-exports-strip.ts, and there is no semantic
conflict, but the textual one decides the order.

  • git merge-tree --write-tree origin/main HEAD: exit 0, clean.
  • git merge-tree --write-tree origin/main pr-3846: exit 1, conflicts in
    browser-server-exports-strip.ts, browser-server-exports-strip.test.ts and templates/manifest.generated.ts.
  • git merge-tree --write-tree HEAD pr-3846: exit 1, the same three plus docs/guides/data-fetching.md.

#3846 is +9165/-584 against a merge base of c4e93cd8, which main left far behind. It does not merge with
main as it stands. This branch does. Land this one first and rebase #3846 onto it.

Refs veryfront/veryfront-issue-inbox#605. Supersedes the reference to closed veryfront/veryfront-issue-inbox#112.

…pile

Moves browserServerExportsStripPlugin ahead of compilePlugin in
BROWSER_PIPELINE and changes its stage from COMPILE + 0.6 to PARSE + 0.5.
Both had to change: array position decides order until a custom plugin
registers, at which point the pipeline is re-sorted by stage.

The strip stage recognised esbuild `__name(fn, "fn")` registrations that
Veryfront's own compile stage injected one position earlier, with
keepNames enabled. Running the strip first means no keepNames helper is
ever emitted for a declaration the pass removes.

Deletes the compile/strip sourcemap stash-and-restore handshake. It
existed only because compile ran first and embedded pre-strip source in
its development map. With the strip first, the compiler builds its map
from already-stripped input, so there is nothing to defend.

Refs veryfront/veryfront-issue-inbox#112
Babel attaches a file's opening comments to its first statement, so
removing that statement takes them with it. Running the server-exports
strip on authored source rather than on compiled output makes this
observable: a `@jsxImportSource` pragma above a hook-only import was
dropped and the page silently rendered through the configured default
JSX factory instead. Legal banners and lint suppressions went the same
way.

Relocates the leading comments of a removed statement onto the next
surviving one.

Refs veryfront/veryfront-issue-inbox#112
@coderabbitai

coderabbitai Bot commented Aug 18, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@kojiwakayama, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 53 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 9cf1ccde-f726-43be-9c70-847f0ed8ad14

📥 Commits

Reviewing files that changed from the base of the PR and between 8d0ce3c and 2262c26.

📒 Files selected for processing (9)
  • .gitignore
  • docs/guides/data-fetching.md
  • src/transforms/pipeline/index.ts
  • src/transforms/pipeline/stages/browser-server-exports-strip.test.ts
  • src/transforms/pipeline/stages/browser-server-exports-strip.ts
  • src/transforms/pipeline/stages/compile.ts
  • src/transforms/pipeline/stages/compiler-predicates.test.ts
  • src/transforms/pipeline/stages/reference-classification.test.ts
  • src/transforms/pipeline/stages/reference-classification.ts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown

📦 Client bundle boundary

Entrypoint Modules Source size Server leaks
src/index.client.ts 326 1949 KiB ✅ 0

A server module in a client graph aborts hydration in the browser. New leaks fail CI; known leaks are tracked in scripts/lint/client-bundle-baseline.json to burn down.

@kojiwakayama

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

Draft on purpose, but the review is wanted. This is a pipeline ordering change for the browser target plus the deletion of the compile/strip sourcemap handshake it made unnecessary. Please pay particular attention to retainLeadingComments in src/transforms/pipeline/stages/browser-server-exports-strip.ts and to whether the new stage number and the array position in BROWSER_PIPELINE can drift apart.

The known open issue on this branch is stated in the description and does not need re-reporting: an unused import that the stripped hooks do not own is still reduced to a bare side-effect import, which post-reorder puts a server module into the browser graph. The fix is a contract change posted as a yes/no on veryfront/veryfront-issue-inbox#112.

@coderabbitai

coderabbitai Bot commented Aug 18, 2026

Copy link
Copy Markdown
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '=== Changed files ==='
git diff --name-status HEAD^ HEAD

echo '=== Diff: pipeline and browser strip stage ==='
git diff --find-renames --unified=80 HEAD^ HEAD -- \
  src/transforms/pipeline/index.ts \
  src/transforms/pipeline/stages/browser-server-exports-strip.ts

echo '=== Outline: pipeline index ==='
ast-grep outline src/transforms/pipeline/index.ts --items all --view expanded || true

echo '=== Outline: browser strip stage ==='
ast-grep outline src/transforms/pipeline/stages/browser-server-exports-strip.ts --items all --view expanded || true

echo '=== Focused tests and references ==='
rg -n -C 4 \
  'retainLeadingComments|BROWSER_PIPELINE|browserServerExportsStripPlugin|runs before compile|compile map|jsx pragma|source[Mm]ap' \
  src tests 2>/dev/null || true

Length of output: 164198


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '=== Pipeline declaration and custom-plugin sorting ==='
sed -n '58,88p;320,380p' src/transforms/pipeline/index.ts

echo '=== Transform stage values ==='
sed -n '1,130p' src/transforms/pipeline/types.ts

echo '=== Complete PR diff for the focused test ==='
git diff --find-renames --unified=30 HEAD^ HEAD -- \
  src/transforms/pipeline/stages/browser-server-exports-strip.test.ts

echo '=== Ordering and source-map test implementations ==='
sed -n '1020,1175p' src/transforms/pipeline/stages/browser-server-exports-strip.test.ts

echo '=== Comment-relocation test implementation and nearby cases ==='
sed -n '1215,1285p' src/transforms/pipeline/stages/browser-server-exports-strip.test.ts

echo '=== Comment mutation sites and parser/generator contracts ==='
rg -n -C 5 \
  'leadingComments|trailingComments|interface CodeParser|generate\(' \
  src/transforms/pipeline/stages/browser-server-exports-strip.ts \
  src/extensions/parser/code-parser.ts \
  src/extensions/parser

Length of output: 30676


@kojiwakayama I found one issue in retainLeadingComments.

  • Line 1305: retainLeadingComments drops comments when the removed statement is the last statement. The function retains orphaned comments only by attaching them to a later surviving statement. It discards a non-empty orphaned array when the loop ends.

    This can occur when a server hook appears before a hook-only declaration:

    export async function getServerData() {
      return { props: { secret: SECRET } };
    }
    
    /*! package license */
    const SECRET = getEnv("SECRET");

    The strip stage empties the hook and prunes SECRET. There is no later statement. The generated browser module loses the license comment. The same case can lose a trailing lint directive. Preserve the remaining comments after the loop, for example by attaching them to the last surviving statement, or add them to the program-level comments. Add a test for a removed final declaration with a leading legal comment.

The pipeline order and stage value agree. BROWSER_PIPELINE places the strip stage between parse and compile. TransformStage.PARSE + 0.5 preserves this order when custom plugins trigger sorting. The two ordering tests cover both paths.

⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@kojiwakayama

Copy link
Copy Markdown
Contributor Author

Decision on the open leak is yes: dropUnusedImportBindings will stop reducing an unrelated unused import to a bare side-effect import and leave it exactly as authored. Reasoning recorded at veryfront/veryfront-issue-inbox#112.

Short version: the demotion is an artifact of the strip running last, like the sourcemap handshake this PR already deletes. With compile now running after the strip, esbuild erases a genuinely unused import itself, which is why main is clean. The demotion converts an erasable named import into a non-erasable side-effect import and so defeats that cleanup.

Implementing now. Two conditions: genuine side-effect imports (import "./analytics.ts" as authored) stay untouched, and the dependency on the ordering gets a comment at the call site so a future revert of the ordering does not silently reintroduce the leak.

`retainLeadingComments` relocated a removed statement's comments onto the
next surviving statement, and dropped them when no surviving statement
followed. A legal banner between two removed trailing declarations was
lost. Attach the remainder to the last surviving statement instead.

Reported by CodeRabbit on #3855.

Refs veryfront/veryfront-issue-inbox#112
@kojiwakayama

Copy link
Copy Markdown
Contributor Author

@coderabbitai Confirmed and fixed in 30c64eeb.

Your repro as written does not lose the banner, because Babel attaches a comment between two statements to both of them: it is leadingComments of the removed const SECRET and trailingComments of the surviving hook, so the trailing attachment carries it through. The defect is real one shape over, when the statement before the comment is removed too and nothing survives after it:

import { getEnv } from "veryfront";
export async function getServerData() { return { props: { s: SECRET, r: RAW } }; }
const RAW = getEnv("SECRET_VALUE");
/*! package license */
const SECRET = RAW + "x";

Both tail declarations are pruned, the only surviving statement precedes the banner, and retainLeadingComments discarded a non-empty orphaned array at the end of the loop. Measured before the fix: banner absent.

The fix tracks the last surviving statement and appends the remainder to its trailingComments rather than dropping them, which is the correct position since those comments follow it in source order. Test added as keeps a legal banner between two removed trailing declarations, and it fails if the new block is removed.

On your second point, agreed, and that agreement is now enforced rather than asserted: emits a compile map built from already-stripped input runs with no custom plugin and so pins the array position, and runs before compile even when a custom plugin re-sorts the pipeline registers one and so pins the stage number. Reverting either half alone turns exactly one of those red.

@kojiwakayama

Copy link
Copy Markdown
Contributor Author

Noting for coordination, since the comment above says the import-demotion change is being implemented now: this session is not implementing it, so there is no duplicate work in flight from here. This branch stops at the ordering change, the handshake deletion, and the two comment-preservation fixes, and it still carries the leak described in the description.

Two things that will help whoever lands the demotion change on top:

  • The probe is import { unusedThing } from "./lib/server-only-lib.ts" in a module that also exports a hook, run through runPipeline({ ssr: false }). main emits nothing for that module, this branch emits import "./lib/server-only-lib.js".
  • Removing only the hook-owned specifiers and leaving the rest of the statement as authored turns exactly 11 tests red in browser-server-exports-strip.test.ts. Nine of them assert shadowing or liveness and use the reduction only as their observable, so they can assert the fuller import text instead. The other two, still erases an undecorated declared property and does not keep an import binding whose name matches an enum member, lose their observable outright, because with no reduction a used and an unused non-hook-owned binding both leave the import exactly as written. Those two need moduleReferenceWalkers, which is already exported for that purpose, or a restatement with a hook-owned binding.

This PR stays a draft until that change lands and the probe goes clean.

@coderabbitai

coderabbitai Bot commented Aug 18, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@kojiwakayama Confirmed. The corrected repro identifies the actual end-of-program case. Appending the remaining comments to the last surviving statement’s trailingComments preserves source order.

The new test covers the defect. The two ordering tests also independently protect the array position and TransformStage value.

🐇 ✅


✏️ Learnings added
Learnt from: kojiwakayama
URL: https://github.com/veryfront/veryfront-code/pull/3855

Timestamp: 2026-08-18T19:21:55.863Z
Learning: In `src/transforms/pipeline/stages/browser-server-exports-strip.ts`, Babel can attach an inter-statement comment as both `leadingComments` on the following statement and `trailingComments` on the preceding statement. When `retainLeadingComments` removes trailing statements and no later statement survives, it must append remaining orphaned comments to `trailingComments` on the last surviving statement to preserve source order and legal banners.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

You are interacting with an AI system.

`dropUnusedImportBindings` reduced an unused import it does not own to a
bare side-effect import, described as compatibility with the older
conservative behavior. That demotion is an artifact of the strip running
last: with nothing downstream to clean up, a bare import conservatively
preserved the module's side effects.

Now that compile runs after the strip, the demotion converts an ERASABLE
named import into a NON-ERASABLE one. Side-effect imports are preserved
by design, so it defeats the cleanup that would otherwise drop the
module, and a server-only module reachable only through an unused import
ships to the browser. `main` is clean because the compiler erases the
import before the pass ever sees it.

The pass now removes only the specifiers the stripped hooks owned and
leaves everything else exactly as authored. A genuine side-effect import
is still untouched, and a hook-owned import is still deleted outright.

The correctness of this depends on the pipeline ordering, so the call
site carries an explicit note: compile must keep running after this
stage, or the demotion has to come back with the ordering.

Refs veryfront/veryfront-issue-inbox#112
@kojiwakayama

Copy link
Copy Markdown
Contributor Author

The decision at veryfront/veryfront-issue-inbox#112 (comment 5332914896) is now implemented in 7c1028a6. The open leak in the description is closed and the PR body has the measured before and after.

What changed in dropUnusedImportBindings:

  • It no longer reduces an unused import it does not own to a bare side-effect import. It removes only the specifiers the stripped hooks actually owned, through a new dropOwnedSpecifiers helper that can never empty a specifier list, and leaves everything else exactly as authored.
  • Deletion of the whole statement is unchanged: a node: or veryfront source, or a project-relative source whose every binding the hook owned, still goes.
  • A genuine side-effect import is untouched, pinned at the stage level and end to end through runPipeline.

Measured through the real reordered pipeline for the blocking case in the description:

  • before (30c64eeb): the artifact contains import"./lib/server-only-lib.js"
  • after: no reference to server-only-lib at all, which is what main emits

Two things worth your attention specifically:

  1. The correctness of leaving an unused import as authored depends on compile running after this stage. There is a PIPELINE ORDERING DEPENDENCY note at the call site in stripServerOnlyExports naming the stage numbers, and a pointer to it on the stage field. Please check that the note is where a future reverter of the ordering would actually read it.
  2. Eleven tests encoded the old contract. Nine were straightforward. Two were paired contrasts whose halves were told apart only by "import demoted" versus "import intact" (@audit declare id versus plain declare id, and a block-scoped enum versus a same-scope enum). With the demotion gone both halves produce an intact import, so those fixtures were re-anchored to put the imported binding inside the hook closure, making the contrast "import deleted" versus "import intact". Their partner cases got the same fixture change. Please check that the re-anchored pairs still discriminate the property they were written for, and that nothing that protected a genuine side-effect import was weakened.

@coderabbitai review

Still a draft on purpose. Do not mark it ready.

@coderabbitai

coderabbitai Bot commented Aug 18, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '=== PR commits and changed files since the prior reviewed commit ==='
git log --oneline --decorate -12
git diff --name-status 30c64eeb..HEAD

echo '=== Focused diff ==='
git diff --find-renames --unified=55 30c64eeb..HEAD -- \
  src/transforms/pipeline/stages/browser-server-exports-strip.ts \
  src/transforms/pipeline/stages/browser-server-exports-strip.test.ts \
  src/transforms/pipeline/index.ts

echo '=== Source outlines ==='
ast-grep outline src/transforms/pipeline/stages/browser-server-exports-strip.ts --items all --view expanded || true
ast-grep outline src/transforms/pipeline/stages/browser-server-exports-strip.test.ts --items all --view expanded || true
ast-grep outline src/transforms/pipeline/index.ts --items all --view expanded || true

echo '=== Import-removal implementation and ordering declarations ==='
rg -n -C 12 \
  'dropUnusedImportBindings|dropOwnedSpecifiers|PIPELINE ORDERING DEPENDENCY|stripServerOnlyExports|browserServerExportsStripPlugin|BROWSER_PIPELINE|stage:' \
  src/transforms/pipeline/stages/browser-server-exports-strip.ts \
  src/transforms/pipeline/index.ts

echo '=== Targeted test references and side-effect import coverage ==='
rg -n -C 10 \
  '`@audit`|declare id|enum member|same-scope enum|block-scoped enum|side-effect import|side effect import|server-only-lib|dropOwnedSpecifiers|dropUnusedImportBindings' \
  src/transforms/pipeline/stages/browser-server-exports-strip.test.ts

Length of output: 127099


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '=== Direct stripServerOnlyExports call sites ==='
rg -n -C 8 '\bstripServerOnlyExports\s*\(' src

echo '=== Compile stage configuration ==='
ast-grep outline src/transforms/pipeline/stages --items all --type function,variable --match 'compilePlugin|compile' --view expanded || true
rg -n -C 18 'compilePlugin|treeShaking|treeShaking:|minify|esbuild.*transform|transform\(' \
  src/transforms/pipeline/stages \
  src/transforms/pipeline

echo '=== Splitter integration ==='
fd -a 'esbuild-plugin.ts' src/build/bundler/code-splitter -x sh -c \
  'echo "=== $1 ==="; ast-grep outline "$1" --items all --view expanded || true; rg -n -C 16 "\bstripServerOnlyExports\s*\(|onLoad|esbuild|treeShaking" "$1"' sh {}

echo '=== All changed test names and assertions related to import output ==='
git diff --unified=4 30c64eeb..HEAD -- src/transforms/pipeline/stages/browser-server-exports-strip.test.ts \
  | rg -n -C 5 '^[+-].*(it\(|assertStringIncludes|assertNotIncludes|assertEquals|import )' || true

echo '=== Existing tests that protect authored side-effect imports ==='
rg -n -C 8 'import "\./|import "\.\./|side-effect import|side effect import|polyfill|analytics' \
  src/transforms/pipeline/stages/browser-server-exports-strip.test.ts

Length of output: 201355


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '=== runPipeline options and dev propagation ==='
sed -n '189,380p' src/transforms/pipeline/index.ts
sed -n '1,180p' src/transforms/pipeline/context.ts

echo '=== Focused test calls and any development-mode pipeline coverage ==='
sed -n '1048,1165p;1235,1305p' src/transforms/pipeline/stages/browser-server-exports-strip.test.ts
rg -n -C 6 \
  'dev:\s*true|dev:\s*false|pre-compile-unused-import-demotion|server-only-lib' \
  src/transforms/pipeline/stages/browser-server-exports-strip.test.ts \
  src/transforms/pipeline/index.ts \
  src/transforms/pipeline/context.ts

echo '=== Compile tree-shaking configuration ==='
sed -n '66,88p' src/transforms/pipeline/stages/compile.ts

Length of output: 35042


@kojiwakayama I found one issue.

  • src/transforms/pipeline/stages/browser-server-exports-strip.ts Line 1294: The new behavior still leaks the unrelated import in development browser transforms. dropUnusedImportBindings leaves import { unusedThing } from "./lib/server-only-lib.ts" authored. However, compilePlugin sets treeShaking: !ctx.dev at src/transforms/pipeline/stages/compile.ts Line 84. When dev: true, esbuild preserves that static import. The browser then evaluates server-only-lib.

    The new blocking-case test omits dev, so createTransformContext defaults it to production and the test only verifies the tree-shaken path. Add the same runPipeline probe with dev: true. It should contain no reference to server-only-lib.

    The fix needs a development-mode removal path for unrelated unused imports, or another later stage that removes the unused import without synthesizing a side-effect import. The PIPELINE ORDERING DEPENDENCY note should also state the treeShaking: !ctx.dev condition if the behavior remains production-only.

The ordering note itself is in the correct location. It is immediately before the dependent call, and the stage comment points to it.

The re-anchored paired tests still discriminate their intended cases. The decorated-property pair distinguishes a runtime decorator read from an erased declared property. The enum pair distinguishes block scope from same-scope shadowing. The stage-level and end-to-end side-effect-import tests also preserve coverage for authored bare side-effect imports.


🧠 Learnings used
Learnt from: kojiwakayama
Repo: veryfront/veryfront-code PR: 0
File: :0-0
Timestamp: 2026-08-18T19:21:55.863Z
Learning: In `src/transforms/pipeline/stages/browser-server-exports-strip.ts`, Babel can attach an inter-statement comment as both `leadingComments` on the following statement and `trailingComments` on the preceding statement. When `retainLeadingComments` removes trailing statements and no later statement survives, it must append remaining orphaned comments to `trailingComments` on the last surviving statement to preserve source order and legal banners.

You are interacting with an AI system.

@kojiwakayama

Copy link
Copy Markdown
Contributor Author

Blocker cleared. Verified independently at 7c1028a6, not just taken on trust.

The probe that was leaking now comes back clean: import { unusedThing } from "./lib/server-only-lib.ts" in a module with a hook emits nothing for that module through the real runPipeline({ ssr: false }), matching main. All five of the challengers' pre-compile cases are clean, and B through F (nested object, array, rest, computed key, sibling default) still leak the secret identically on both sides, so no regression there either.

Four import shapes checked end to end, since the change is to which specifiers survive:

shape result
authored bare import "./lib/analytics.ts" untouched
import { hookOnly, clientUsed } becomes import { clientUsed }, hookOnly gone
import { unusedThing }, hooks never touched it erased by the compiler
import the client still reads untouched

Restoring statement.specifiers = [] in place of dropOwnedSpecifiers turns 8 tests red, including the dedicated does not demote an unrelated unused import to a side-effect import, so the property is guarded rather than asserted.

Full repo suite at this head: 4508 passed / 106 failed, against 4503 / 111 on main from an identical scratch tree. Excluding tests/e2e/ the two runs fail on exactly the same 73 tests, set difference empty in both directions; those are this sandbox's no-network failures. Inside tests/e2e/ there are zero failures unique to this branch.

Merge-group pre-flight at this head: main at d260ddf4 merged into a scratch copy, no conflicts, deno task typecheck clean, deno task lint:ci clean, src/transforms/ green on the merged tree. PR CI green.

Leaving the draft flag alone rather than flipping it, since the branch has more than one author tonight and marking it ready makes it queueable.

The previous fix left an unused specifier the stripped hooks did not own
in place, on the reasoning that compile now runs after the strip and
esbuild erases a genuinely unused import. That reasoning holds only for
the `ts` and `tsx` loaders, where a specifier may name a type and
elision is permitted. `getLoaderFromPath` maps `.js` to `js`, `.jsx` to
`jsx`, and `.md` and `.mdx` to `jsx`, and under those loaders esbuild
must preserve the module for its side effects, so it rewrites the
remainder into exactly the bare side-effect import this stage forbids.
The stage emits byte-identical output for `probe.tsx`, `probe.jsx` and
`probe.js`, so the loader alone decided the outcome and the leak was
invisible on a `.tsx` fixture.

`dropUnusedImportBindings` now deletes the whole statement whenever the
stripped hooks owned at least one specifier and no surviving specifier
is read, which is what the pass did when it ran after compile. An import
with a surviving reader still keeps every specifier it was authored
with, and an import the hooks own nothing of is still left exactly as
authored for compile to erase.

`import srv = require("./lib.js")` and `import srv = ns.member` leaked
the same way for a different reason: pre-compile the pass meets a
TypeScript node shape esbuild used to lower before it ever ran, and
neither the module-scope pruner nor the import pass recognised it. The
alias survived as `const srv = require("./lib.js")`, which keeps the
server-only module in the browser graph and throws a ReferenceError on
`require`. `moduleScopeDeclarations` now treats a value-emitting
`TSImportEqualsDeclaration` as the module-scope binding it is, so a
hook-only one is pruned and the closure grows through its right-hand
side to the namespace import behind it.

Tests run the real browser pipeline over the dialect axis (`ts`, `tsx`,
`js`, `jsx`, `mdx`) crossed with the import shapes (mixed specifiers, a
default-import sibling, both import-equals forms) in dev and prod. Every
cell now matches `main`, and the JavaScript-dialect cells fail without
the fix while the TypeScript ones pass either way, which is the gap the
single `.tsx` guard left open.

Refs veryfront/veryfront-issue-inbox#112
`moduleScopeDeclarations` now sees `TSImportEqualsDeclaration`, and Babel
represents `export import A = require("./a.js")` as that same node with
`isExport: true` rather than wrapping it in an export declaration. Every
other candidate reaches the collector already unwrapped, so the existing
"exported declarations are never candidates" rule needed the flag spelled
out or a hook-only exported alias would be pruned out of the module's
contract.

Refs veryfront/veryfront-issue-inbox#112
@kojiwakayama

Copy link
Copy Markdown
Contributor Author

Coordination note, because two sessions were on this at once.

I rewrote the PR description at a point where the head was 7c1028a6, then 8714b733 and d6e744c6 landed. The body now covers all five commits, but if you had edited the description between those pushes my rewrite overwrote it. Say so and I will fold it back in.

Verified against the current head d6e744c6, end to end through the real reordered runPipeline:

  • All five challenger cases from the description are clean, and the genuine side-effect import is still preserved.
  • The dialect gap 8714b733 closes is real and I missed it. My third commit left the non-owned specifiers of a mixed import in place and I only checked a .tsx fixture, where TypeScript elision hides it. Under the js and jsx loaders esbuild rewrites the remainder into exactly the bare side-effect import the stage forbids.

One shape is still open and is unchanged by any of this, on main and here alike: an import the hooks own nothing of, unused as authored, in a .js or .jsx page. TypeScript elision is what erases it, so the JavaScript dialects keep the module. Measured module-kept on main, on 30c64eeb and on d6e744c6. Pre-existing, no regression, noted in the description under "Still open".

Full-suite numbers in the description are honest about what did not run. This machine is pinned by twelve orphaned CPU-burner processes from an unrelated session (load average 13 for over eleven hours), so every server-backed test in tests/integration and tests/e2e hits its 60 s timeout and neither directory finished. Everything that did run is identical between the two trees, failing test name sets included. CI is green on the head and does cover those two directories on a machine that is not starved.

`compilePlugin` sets `treeShaking: !ctx.dev`, so the existing end-to-end
probe for the closed leak only covered the tree-shaken path. What erases
an unrelated unused import is not tree shaking, it is TypeScript
unused-import elision, which the `ts` and `tsx` loaders perform in both
modes. Measured across `ts`, `tsx`, `js` and `jsx` crossed with dev and
prod, this branch matches `main` in every cell.

The development probe is added anyway, so a future change to either
setting cannot quietly make development ship the module, and the ordering
note now names the mechanism instead of leaving "esbuild erases it" open
to being read as tree shaking.

Refs veryfront/veryfront-issue-inbox#112
@kojiwakayama

Copy link
Copy Markdown
Contributor Author

@coderabbitai The development-mode finding does not reproduce. Measured, not reasoned about.

treeShaking: !ctx.dev is real, but it is not what erases this import. TypeScript unused-import elision is, and the ts and tsx loaders perform it in both modes. Full matrix through the real runPipeline, import { unusedThing } from "./lib/server-only-lib" in a module with a getServerData, asking only whether the artifact still references the module:

loader dev main d6e744c6
ts true erased erased
ts false erased erased
tsx true erased erased
tsx false erased erased
js true module kept module kept
js false module kept module kept
jsx true module kept module kept
jsx false module kept module kept

Every cell matches main, so there is no development-only leak and no regression in either mode. For reference, 30c64eeb (this branch before the fix) is MODULE-KEPT for tsx in both dev and prod, which is the leak that was closed.

The js and jsx cells are the residual gap already recorded under "Still open" in the description: nothing elides under those loaders, so an import the hooks own nothing of survives. It is identical on main and predates this PR. Note the contrast with an import the hooks do own a binding of, which 8714b733 made unconditional precisely because those loaders do not elide.

Your suggestion to pin the development path is a good one and is now in b0f7653b as does not demote an unrelated unused import in development either, with the correct expectation. The ordering note in stripServerOnlyExports now names elision as the mechanism and states explicitly that treeShaking: !ctx.dev does not gate it, so the next reader does not have to redo this measurement.

Two of your other conclusions match what I found independently: the ordering note is at the dependent call with the stage field pointing to it, and the re-anchored paired tests still discriminate. Thanks for checking the second one specifically, that was the risky part of the test update.

@coderabbitai

coderabbitai Bot commented Aug 19, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '=== Current branch and relevant commits ==='
git log --oneline -10

echo '=== Focused changes after d6e744c6 ==='
git diff --find-renames --unified=35 d6e744c6..HEAD -- \
  src/transforms/pipeline/stages/browser-server-exports-strip.ts \
  src/transforms/pipeline/stages/browser-server-exports-strip.test.ts

echo '=== Ordering dependency note and dependent call ==='
rg -n -C 18 \
  'PIPELINE ORDERING DEPENDENCY|treeShaking|elision|stripServerOnlyExports|dropUnusedImportBindings' \
  src/transforms/pipeline/stages/browser-server-exports-strip.ts \
  src/transforms/pipeline/stages/compile.ts

echo '=== Development-mode regression test and its runPipeline call ==='
rg -n -C 22 \
  'does not demote an unrelated unused import in development either|server-only-lib|dev:\s*true' \
  src/transforms/pipeline/stages/browser-server-exports-strip.test.ts

Length of output: 57573


@kojiwakayama The development-mode finding was incorrect.

I verified b0f7653b. The new test uses the .tsx loader with dev: true and asserts that the browser artifact contains neither server-only-lib nor unusedThing.

The ordering note now identifies TypeScript unused-import elision as the removal mechanism. It also correctly states that treeShaking: !ctx.dev does not control this case.

The TypeScript/TSX limitation is explicit. The remaining JavaScript/JSX behavior matches main and is already documented as pre-existing. The previous finding should be withdrawn.


✏️ Learnings added
Learnt from: kojiwakayama
URL: https://github.com/veryfront/veryfront-code/pull/3855

Timestamp: 2026-08-19T00:29:10.484Z
Learning: In `src/transforms/pipeline/stages/browser-server-exports-strip.ts`, removal of an unrelated unused named import that the stripped hooks do not own depends on TypeScript unused-import elision in the `ts` and `tsx` esbuild loaders, not on `treeShaking`. The elision occurs in both development and production. JavaScript-family loaders do not elide these imports, so the pre-existing JavaScript/JSX behavior must be evaluated separately.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: kojiwakayama
Repo: veryfront/veryfront-code PR: 0
File: :0-0
Timestamp: 2026-08-18T19:21:55.863Z
Learning: In `src/transforms/pipeline/stages/browser-server-exports-strip.ts`, Babel can attach an inter-statement comment as both `leadingComments` on the following statement and `trailingComments` on the preceding statement. When `retainLeadingComments` removes trailing statements and no later statement survives, it must append remaining orphaned comments to `trailingComments` on the last surviving statement to preserve source order and legal banners.

You are interacting with an AI system.

Running the browser server-exports strip before compile hands it JSX the
walkers never used to see. Post-compile every JSX name had already been
lowered to a factory call, so both reference walkers counted every
JSXIdentifier as a runtime read. Three of those positions are not reads:

- an intrinsic element name (`<table>`, `<section>`, `<my-widget>`), which
  is string text to the factory
- an attribute name (`placeholder=`, `title=`, `href=`), which is a
  property key
- the property of a member element name (the `Item` in `<UI.Item />`),
  where only the leftmost object is read

Both halves of a namespaced name (`<svg:circle xlink:href="x" />`) are
string text too, matching what esbuild emits for them.

The misclassification leaked and deleted in both directions. An import
the hooks owned survived into the browser artifact when its binding
shared a tag or attribute name, and an unrelated module-scope
declaration that shared one was pulled into the hook closure and deleted
along with the import it was the last user of.

A classic `@jsxRuntime classic` module is a second pre-compile surface:
esbuild writes the `@jsx` and `@jsxFrag` factory calls in the stage that
now runs after this one, so nothing read the factory yet and its import
was dropped while still called. Pin the pragma targets, gated on the
classic runtime because esbuild ignores the pragmas otherwise, and pin
esbuild's default `React` factory when a classic module names none.

Also document the re-export build break the reorder introduces:
`export { getServerData } from "./loaders.ts"` binds no local
declaration, so the build now stops instead of shipping the loader.
The previous commit added a tracked `node_modules` symlink pointing at its
own absolute path. Any checkout of this branch then failed every Deno
command with `Too many levels of symbolic links (os error 62)`, including
`deno test` and `deno task typecheck`.

The ignore rule reads `node_modules/`, and a trailing slash matches
directories only, so a symlink of that name is not ignored and a
`git add -A` picks it up. Add the slash-less form beside it.
@kojiwakayama

Copy link
Copy Markdown
Contributor Author

Owning a mistake of mine, since another session had to clean it up.

b0f7653b committed a tracked node_modules symlink pointing at a local absolute path. I had linked node_modules into a scratch checkout to run the tests there and then used git add -A; the ignore rule is node_modules/ and a trailing slash matches directories only, so the symlink was not ignored. That broke every Deno command on a fresh checkout of the branch, and it also put a local machine path into the repository, which AGENTS.md forbids outright. afc3eb70 removes it and adds the slash-less ignore form. Thanks for catching it.

I also misread the first CI failure on that commit as runner flake. failed to create directory node_modules/.deno/node_modules: File exists was the symlink, not infrastructure. Re-running the jobs made it green, which made the wrong diagnosis look right.

Re-verified on afc3eb70, end to end through the real runPipeline: all five challenger cases clean, the genuine side-effect import still preserved, and the dialect matrix (ts, tsx, js, jsx crossed with dev and prod) unchanged and matching main in every cell.

Both reference walkers in the server-exports strip fell back to a generic
descent for any node type nobody had classified, and counted the identifiers
under it as runtime reads. Four review rounds on the strip-before-compile
reorder each found one instance of that. Two were still open.

The object of a JSX member element name was run through the intrinsic-tag
rule, so `<motion.div>`, `<styled.div>` and `<dialog.Root>` classified their
object as string text and the pass deleted the binding they read. An export
clause with a source reads no local binding, but neither walker had a case for
it, so `export { token } from "./client-utils.js"` counted `token` as a free
read and kept a hook-owned `import { token } from "./server-only.js"` alive.

Replace the ad hoc handling with one classification, derived from the pinned
@babel/types and shared by both walkers: every node type is a read, an erased
node or a structural one, and every child position that holds a fixed name is
recorded once. A guard test fails when the package defines a node type the
classification does not name, so a Babel upgrade breaks the build instead of
leaking a server-only module into the browser artifact.

An unrecognised node defaults to structural, which over-retains rather than
over-deletes. An unrecognised TypeScript node stays erased, because the
TypeScript grammar inverts that argument.
…d case

Mutation testing the shared reference classification found nine cells whose
revert no test caught: the fixed-name key of an object method, a class method,
a class property and an auto-accessor, the computed form of each of those, the
comments and tokens a parsed file carries beside its program, every import
specifier shape, the exported name of a default or namespace re-export, and the
identifier-valued name of a placeholder.

None of them was wrong in the implementation. Each was a cell the table
classified and nothing asserted, so a future edit could flip it silently, which
is the failure mode this classification exists to end.

Add a case per cell. The method and class member keys are asserted through both
walkers, since a fixed key counted as a read pins the import it collides with.
The rest are asserted on `referenceChildren`, because they hold no identifier a
walker could report.

Three map entries stay unkillable by construction: `V8IntrinsicIdentifier.name`,
`DirectiveLiteral.value` and `InterpreterDirective.value` are string fields in
`NODE_FIELDS`, so `referenceChildren` never collects them whether the table
names the key or not.
@kojiwakayama

Copy link
Copy Markdown
Contributor Author

Triage note (2026-08-19): this PR is the timeboxed spike that #3846 is waiting on.

#3846's description parks the second half of #3825 — the intrinsic-tampering analysis, which is undecidable, has 21 known module shapes that defeat it, and carries six unresolved threads including a P1 build-time CPU blowup — pending:

a timeboxed spike testing whether running this stage before compilePlugin removes the need for the analysis entirely

That is exactly what this PR does. It is MERGEABLE with no failing checks and has been sitting in draft.

Getting a yes/no out of it is disproportionately valuable: a "yes" retires that entire undecidable analysis permanently instead of leaving it parked. Suggest taking it out of draft and reviewing it on its own merits, independently of #3846.

Note this PR's description references veryfront/veryfront-issue-inbox#112, which is now closed — replaced by #605. Full plan there.

The node-type guard pins the classification TABLE. It cannot pin the
PREDICATES that approximate compiler rules, and one of them was wrong:
`isIntrinsicJsxName` tested for an identifier with an ASCII-only regular
expression, while esbuild's rule is "first character is ASCII a-z, or the
name is not a valid ECMAScript identifier" over the full Unicode grammar.
Any JSX element name that is a valid identifier and contains a non-ASCII
character was classified as intrinsic tag text, both walkers skipped
`JSXOpeningElement.name`, and the import or declaration it named was
deleted while the artifact still called it. Measured through the real
`runPipeline`: `<Café />` compiles to `jsx(Café, {})` with its import
gone, so the page dies on a ReferenceError.

`pragmaRootBinding` in the strip carried the identical regular
expression, so a `@jsxRuntime classic` module whose pragma named a
non-ASCII factory root lost the factory import the emitted call needs.
Both now share one Unicode-aware identifier test.

The fix alone would leave the class open, so every predicate in this
stage that stands in for a compiler or parser rule now has a
differential test: it compares the predicate against the real compiler on
a corpus of inputs instead of against a second hand-written expectation.
`compiler-predicates.test.ts` covers the JSX tag-text test, the classic
pragma pins, the `declare` short-circuit and its decorator exception, the
`importKind`/`exportKind` short-circuits, the whole TypeScript erasure
split, esbuild's `keepNames` helper shape, and the sourcemap comment. Its
header records the three rules that are deliberately not there and why.

The guard is hardened at the same time. It pinned `@babel/types` but not
`@babel/parser`, which the same manifest pins separately, so a
parser-only bump would leave the guard green while a new TS-prefixed node
type fell to the erased default. It also filtered the Flow family out of
its scope on an unstated assumption; that assumption is now an assertion
over every path the parser extension takes.
The code-point sweep asserted that `isEcmaScriptIdentifier` agrees with the
host runtime's own lexer. That holds on V8 and fails on JavaScriptCore,
which CI found: `\p{ID_Start}` and `\p{ID_Continue}` are the engine's
regular-expression tables, the lexer is a separate table, and the two are
versioned independently inside the same runtime. Bun named about twenty
code points where they differ, and a local Bun of another version names
three entirely different ones.

Runtime agreement was never the property that matters. The predicate is
asked about names the COMPILER will meet, so the compiler decides whether
a disagreement can reach an artifact. The sweep now collects the
disagreements and asserts esbuild refuses every one of them: the module
does not build, so no artifact can depend on which table was right. Where
esbuild would accept such a name the test fails, because then the two
answers differ on a name that ships and one of them deletes a live
import.

Verified in both directions: the code points CI's Bun named and the three
this machine's Bun names are all rejected by esbuild.
@kojiwakayama

kojiwakayama commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

Handover: six review rounds, six defect classes, and why I am stopping here

This PR should not leave draft on an agent's judgement. Recording the full state so a human can decide.

What the reorder achieves

browserServerExportsStripPlugin moves ahead of compilePlugin, so the pass stops reverse-engineering __name registrations that Veryfront's own compile stage injected one position earlier. The premise for that recognition was traced and is factually wrong: both release paths transform uncompiled source, no framework module exports a server hook, and the code splitter already runs pre-compile. Full-suite failing-test-name set difference against main is EMPTY in both directions.

Why it is still a draft

Each review round found a real defect class the previous round's own tests passed through:

round class outcome
1 TypeScript type nodes closed in #3849
2 TypeScript runtime nodes: enum scope, declare + decorators, block scoping closed
3 JSX identifiers: intrinsic names, attribute names, member property names closed
4 JSX member-expression objects, re-export clauses with a source closed
5 node-type coverage closed structurally: 188 types classified, guard fails on an unclassified type (verified by feeding it one)
6 predicate fidelity: hand-written approximations of compiler rules partially closed, and it found class 7

The open blocker: export-name normalization

The pass matches a hook by AUTHORED SPELLING, in two places:

  • browser-server-exports-strip.ts:1411, SERVER_ONLY_EXPORTS.some((name) => code.includes(name)), a substring test on source text
  • :232, nodeName(specifier.exported), which reads .name and returns null for an ES2022 StringLiteral ModuleExportName

ECMAScript gives one export several spellings that all produce the same runtime property: an IdentifierName with unicode escapes, a StringLiteral export name, an escaped StringLiteral. esbuild normalises them. This pass does not, so five authored spellings are never recognised as hooks at all. The hook body, the secret it closes over, its node: import, and in development the authored source inside the sourcemap sourcesContent all ship to the browser. main leaks none of them, so this is a regression introduced by the reorder, verified end to end through the real runPipeline on the branch and on the branch merged with current main.

It also fails OPEN, which nothing else in this pass does. export { x as getServerData } from ..., export const { getServerData } = ... and export * as getServerData from ... all raise ServerExportStripError. The escaped and StringLiteral forms exit by a different route: they are never recognised, so the documented fail-closed contract never engages. The contract is enforced by an enumerated list of known-bad shapes rather than by a check that the hooks the pass acted on match the hook-named exports the artifact actually has.

Why round 6 did not catch it

compiler-predicates.test.ts explicitly excuses SERVER_ONLY_EXPORTS as "framework policy, nothing to be differential against". That conflates the SET of hook names, which is policy, with the TEST for whether a module exports one, which is a parser rule esbuild is a perfect oracle for. Every fixture reads a predicate's answer off the artifact; none asks the artifact WHICH NAMES the module exports.

The suggested closing test, which would also have caught this: compile the module, read the export names off the artifact, and assert that the set of hook-named exports the pass acted on equals the set the artifact actually exports.

Also open

  • Merge conflict, and it is semantic. main's fix(transforms): prune destructured server values #3861 tightened inClosure from .some to .every; this branch added && !pinned.has(name) to unused. A verifier measured the resolution (main's .every plus the branch's pinned term): typecheck clean across all 40 entrypoints, src/transforms/ 162 files / 2822 steps / 0 failed. The .every half is forced by main's tests. Update 2026-08-21: the merge landed in 51b31a52c4 keeping both terms, git merge-tree --write-tree origin/main HEAD is clean at 191234f9, and the pinned half is forced now too. Deleting && !pinned.has(name) and running browser-server-exports-strip.test.ts gives 0 passed, 1 failed, killed by keeps partially hook-owned destructuring and pinned JSX factory names. That mutant item is closed and removed from this list.
  • Undocumented build break. export { getServerData } from "./loaders.ts" now fails the build. main builds it and ships the loader plus its transitive graph to the browser with the hook not even stubbed, so the direction is right, but it needs a release note and a migration line.
  • Stale PR body. The verification table cites c4e93cd8 for main and b0f7653b for the branch (commit 8 of 14). Conclusions still reproduce at the head, but the labels need re-measuring after the rebase. Note commits b0f7653b through e52fe75f0 carry a tracked node_modules symlink and cannot be checked out at all, so bisect over that range is broken. The head is clean.
  • Pre-existing, correctly scoped out: ssr-css-strip.ts (101, 109, 135) and ssr-http-stub.ts (54, 59, 71) parse import clauses with the same ASCII-only identifier expression this PR replaced. Byte-identical on both trees. export * from "./server-loaders.ts" leaks a hook on both trees.

Recommendation

The architecture is right and the evidence for it is strong. But six rounds have each found a class the prior round's tests passed through, the newest is a silent leak in the direction that matters most, and the machinery built to prevent exactly that structurally excused the predicate that broke. The remaining work is bounded and known, but it needs an owner who can make the semantic merge call and sign off on a user-facing build break.

Not marking ready. Refs veryfront/veryfront-issue-inbox#112.

Server-only hook export names can be authored as string literals with escapes, so raw source substring checks are not a safe guard for deciding whether to parse or strip a module. The transform now uses an export-shaped candidate check and normalizes AST export names before matching hook names.

Constraint: Server-only export stripping must fail closed for unstrippable hook exports
Rejected: Parse every module unconditionally | existing behavior avoids parsing modules with no plausible hook export and preserves invalid non-hook fixture behavior
Confidence: high
Scope-risk: narrow
Directive: Match server-only hooks on normalized exported names, not raw source spellings or local declaration names
Tested: deno test --preload=src/testing/preload.ts --no-check --allow-all src/transforms/pipeline/stages/browser-server-exports-strip.test.ts
Not-tested: Full repository test suite
The branch needed current main to clear conflicts while preserving both transform hardening lines: main’s destructuring closure rule and this branch’s pinned-name/import-equals guard. The conflict was resolved by requiring every binding in a destructuring declaration to be hook-owned before pruning while still treating pinned JSX factory names as live.

Constraint: Merge must be non-force and preserve origin/main history
Rejected: Prefer either side wholesale | each side carried a distinct regression fix
Confidence: high
Scope-risk: moderate
Directive: Do not relax the destructuring closure check from every() to some(), and do not drop pinned-name liveness from unused checks
Tested: deno test --preload=src/testing/preload.ts --no-check --allow-all src/transforms/pipeline/stages/browser-server-exports-strip.test.ts
Not-tested: Full repository test suite after merge
Merging current main changed public surfaces enough that the generated API reference check failed. This commit contains the generated output from the repository generator so the branch does not carry stale generated docs.

Constraint: Generated artifacts must be regenerated, not hand-edited
Rejected: Leave docs stale | docs:api-reference:check failed after the main merge
Confidence: high
Scope-risk: narrow
Directive: Regenerate docs/api-reference with deno task docs when exported public surfaces change
Tested: deno task docs
Not-tested: docs:api-reference:check after this commit
The transform fix does not change public API output. The earlier generated reference update came from a local generator/runtime mismatch, so keep the PR focused by returning API reference files to the origin/main state verified with the pinned Deno 2.7.7 binary.

Constraint: Parent lane requires no broad API-doc churn in this transform-only PR

Rejected: Force-push away the generated docs commit | non-force history is required for this PR lane

Confidence: high

Scope-risk: narrow

Directive: Regenerate API reference for this PR only with the pinned Deno 2.7.7 binary unless public exports change

Tested: PATH=/private/tmp/veryfront-deno-2.7.7-aarch64-apple-darwin:$PATH deno task docs:api-reference:check

Not-tested: Full docs suite after this cleanup commit
@kojiwakayama

Copy link
Copy Markdown
Contributor Author

Draft readiness audit at e41b944: hosted CI is green, the branch is mergeable, review threads are clear, escaped and string-literal export names are covered, the semantic #3861 merge keeps both the all-bindings closure rule and pinned JSX-factory guard, and unrelated API-reference churn is gone. This should still not be marked Ready by automation. The current governing issue is veryfront/veryfront-issue-inbox#605, which requires an explicit maintainer disposition: close this overgrown spike as recommended there, or name a human reviewer and freeze this SHA. If retained, the PR description must also be refreshed from closed #112 and stale 30f34a8 measurements to #605 and the current head before review.

@kwakayama

Copy link
Copy Markdown
Contributor

Review: 61/100. Request changes (real defects, fixable in place)

This is a draft and you have asked repeatedly that it not be marked ready, so treat this as a review of the code, not a merge signal. The architecture is right, the evidence behind it is the strongest I have seen on this repo, and I independently reproduced your central claims: both halves of the ordering change are independently pinned, the sourcemap now survives with no secret in sourcesContent, and the aliased re-export that main silently ships to the browser now fails the build. One thing blocks it: moving the strip ahead of compile makes the pre-parse gate read raw authored source instead of esbuild's normalized output, and a unicode-escaped hook export name now slips through it. The hook body, the secret it closes over and its server import all reach the browser artifact, silently, where main strips all three. That is a fail-open regression in the one direction this stage exists to prevent.

Score breakdown

Axis Score Note
Correctness 18/30 Not inert: I reverted each half of the ordering change separately and each turned exactly one test red. Closes four real leaks. Introduces one silent leak that main does not have.
Test quality 16/20 Outstanding: 41-cell mutation matrix, differential tests against the real compiler, both ordering halves independently pinned. The one missing test is the one that would have caught the blocker, and your own handover named it.
Scope discipline 9/15 3265 additions, and I confirmed they are real: about 2455 test lines, about 1200 code lines, zero regenerated snapshots. But one ordering slice grew into ten commits carrying a new 629-line module, a new differential suite, a docs change, a .gitignore fix and a user-facing build break.
Design fit 12/15 The classification is extracted so both walkers read one table, and the PIPELINE ORDERING DEPENDENCY note sits at the dependent call site with the stage numbers spelled out. The premise checks out: esbuild-plugin.ts:61-63 already strips raw source in onLoad.
Security & safety 0/10 Zeroed by one verified leak with a repro below. The PR closes several other leaks; that credit is in Correctness, not here.
Docs & hygiene 6/10 docs/guides/data-fetching.md covers the break and follows the copy rules. deno fmt --check and deno lint are clean. The commit type is wrong and the PR body is measured four commits behind the head.

Blocking issues

1. A unicode-escaped hook export name leaks the hook, the secret and the server import. src/transforms/pipeline/stages/browser-server-exports-strip.ts:136-146, reached from :1465.

mayNameServerOnlyExport decides whether to parse at all, and it decides from raw source text. c4e4fd7e widened it for string-literal export names, so export { loadIt as "getServerData" } is now handled. It does not handle the plain IdentifierName spelling, where the escape carries no quotes:

// app/dashboard/page.tsx
import { getEnv } from "veryfront";
const KEY = getEnv("SERVER_ONLY_HOOK_SOURCE");
async function loadIt() { return { props: { k: KEY } }; }
export { loadIt as get\u0053erverData };
export default function Page() { return null; }

The raw source contains neither the substring getServerData nor a quote after as, so both clauses of the gate return false, the module is never parsed, and exportedHookBindings never runs. Measured end to end through the real runPipeline({ ssr: false }), production output, on this head:

var n=Object.defineProperty;var t=(r,e)=>n(r,"name",{value:e,configurable:!0});
import{getEnv as o}from"/_vf_modules/_veryfront/index.client.js";
const a=o("SERVER_ONLY_HOOK_SOURCE");
async function c(){return{props:{k:a}}}t(c,"loadIt");
function u(){return null}t(u,"Page");
export{u as default,c as getServerData};

The secret initialiser is there, the real hook body is there instead of the server-only stub, the veryfront server import is there, and esbuild has normalized the escaped spelling back to getServerData, so the runtime lookup that reads mod.getServerData still finds it. On origin/main at 3a109046d0 the same input produces the stubbed artifact with none of the three.

Three spellings reproduce, all clean on main and all leaking here:

source main this head
export { loadIt as get\u0053erverData } on .tsx stubbed, secret gone secret and hook body ship
export async function get\u0053erverData() stubbed, secret gone secret and hook body ship
export { loadIt as get\u0053erverData } on .js stubbed, secret gone secret and hook body ship

I proved the gate is the sole cause: replacing :1465 with if (false) return code; makes all three artifacts clean without touching anything else, so exportName and exportedHookBindings already normalize correctly once they run.

This is caused by the reorder, and it is worth stating why, because it is the general risk in moving a stage ahead of compile. On main this gate runs on esbuild's output, where the escaped spelling has already been normalized to a plain getServerData, so the substring test passes and the module is parsed. Pre-compile it runs on raw authored text, where the escape is intact. Anything else in this stage that reads source text rather than the AST is exposed the same way. Note the splitter route (src/build/bundler/code-splitter/esbuild-plugin.ts:61-63 here, :61-63 on main too) already read raw source on main, so that route already had this hole. What this PR does is extend it to the primary browser pipeline.

It also fails open, which nothing else in this pass does. export { x as getServerData } from …, export const { getServerData } = … and export * as getServerData from … all raise ServerExportStripError. This shape exits by never being recognised, so the fail-closed contract at :1490-1493 never engages.

Fix, minimal:

function mayNameServerOnlyExport(code: string): boolean {
  if (!/\bexport\b/.test(code)) return false;
  if (SERVER_ONLY_EXPORTS.some((name) => code.includes(name))) return true;
  if (/\bexport\s*(?:\*\s+as|{[\s\S]*?\bas)\s+["']/.test(code)) return true;
  // A unicode or hex escape can spell any character of a hook name inside a
  // plain IdentifierName: `export { loadIt as get\u0053erverData }`. Parse and
  // let `exportName` normalize, rather than trusting raw source text.
  return /\\u|\\x/.test(code);
}

2. The test that would have caught issue 1 is the one compiler-predicates.test.ts excuses. src/transforms/pipeline/stages/compiler-predicates.test.ts:44.

The header records SERVER_ONLY_EXPORTS as "framework policy" with no differential partner. That conflates the set of hook names, which is policy, with the test for whether a module exports one, which is a parser rule esbuild answers perfectly. Every fixture in that file reads a predicate's answer off the artifact. None asks the artifact which names the module exports.

Add the closing case: compile the module, read the export names off the parsed artifact, and assert that the set of hook-named exports the pass acted on equals the set of hook-named exports the artifact actually has. That one assertion fails on all three spellings above and on any future spelling, which is exactly the differential principle your ninth commit argues for. The two cases c4e4fd7e added assert a specific spelling, so they cannot generalize.

Non-blocking

  1. The commit type is wrong. refactor(transforms): describes a change with no behavior change. This one turns a working authoring pattern into a hard build error (export { getServerData } from "./loaders.ts"), changes artifact contents on several shapes, and restores a sourcemap that main drops. fix(transforms)!: or feat(transforms)!: is accurate. The body already documents the break well, so this is only the header.

  2. The PR body is measured behind the head. The verification table names 30f34a83 for the branch and c4e93cd8 for main. The head is e41b94476d and main is 3a109046d0. Your own audit comment says this needs refreshing before review, and it does. The body also still refs closed veryfront/veryfront-issue-inbox#112 rather than the governing fix: remove unused vars in pipeline.behavior.test.ts #605.

  3. The body's merge-conflict section is stale in the useful direction. It says the fix(transforms): prune destructured server values #3861 merge is "left for the rebase rather than guessed at here". 51b31a52c4 did resolve it, keeping main's .every closure rule and your pinned term, and git merge-tree origin/main HEAD is clean today with zero conflicts. Say so.

  4. The surviving mutant you flagged at :1194 is dead. Your handover called && !pinned.has(name) load-bearing and untested. It is tested now. I deleted the term at :1248 and ran src/transforms/pipeline/stages/: 23 passed, 1 failed, killed by keeps partially hook-owned destructuring and pinned JSX factory names. Close that item.

  5. .gitignore:38 is unrelated churn in the final diff. Adding the slash-less node_modules form is right, and it fixes a mistake made on this branch, but it does not belong to a transforms ordering PR. AGENTS.md:15 asks for the smallest viable diff. Split it if you decompose.

  6. The residual gaps you record are accurate. I re-measured the five destructuring shapes on main at 3a109046d0 and on this head. Nested object and array are clean on both (fix(transforms): prune destructured server values #3861 closed them). Rest, computed key and sibling default leak the secret identically on both. export * from "./lib/server-only-lib.ts" references the server module on both. No regression in any of these, and your "Still open" section states it correctly.

  7. Credit where the body undersells you. On main the strip re-serializes through Babel after compile, so a module that gets stripped loses esbuild's minification: main's production artifact for the security fixture is pretty-printed, and this head's is minified. The reorder fixes a bundle-size regression nobody had noticed. Worth a line in the body.

Interaction with #3846

No semantic conflict, a large textual one, and #3855 has to go first.

  • git merge-tree --write-tree origin/main HEAD on this branch: clean, zero conflicts.
  • git merge-tree --write-tree origin/main pr-3846: conflicts in browser-server-exports-strip.ts, browser-server-exports-strip.test.ts and templates/manifest.generated.ts. fix(transforms): close four residual destructuring leak shapes (tracked by #605) #3846 does not merge with main as it stands.
  • git merge-tree --write-tree HEAD pr-3846: conflicts in the same three files.

#3846 is +4265/-582 on browser-server-exports-strip.ts alone against a merge base of c4e93cd8, four weeks of main behind. This PR is +543/-419 on the same file plus a new reference-classification.ts that #3846 has no knowledge of. Whichever lands second rewrites most of its own diff. This one is mergeable with main today and #3846 is not, so land this one first and rebase #3846 onto it.

One overlap worth telling the #3846 reviewer: #3846's headline is the destructured server-value leak, and #3861 already closed two of the five shapes on main (nested object and array, verified above). #3846 should be re-scoped against current main before its diff is judged, because part of what it claims to fix is already fixed.

Both PRs also sit under veryfront/veryfront-issue-inbox#605, which names them as the decomposed slices and says nothing further should be stacked on #3825 until a maintainer picks A, B or C. That decision gates readiness here more than the code does.

Verification performed

Worktree at e41b94476d, merge base f16c1c47609edf73bd869f077a650a5274ff5256, compared against origin/main at 3a109046d0.

Is the change inert? No. Each half pinned independently.

# revert the array position in src/transforms/pipeline/index.ts only
deno test --preload=src/testing/preload.ts --no-check --allow-all \
  src/transforms/pipeline/stages/browser-server-exports-strip.test.ts
exit 1, FAILED | 0 passed (211 steps) | 1 failed
  -> "emits a compile map built from already-stripped input"

# restore, then revert only the stage number at browser-server-exports-strip.ts:1555
# (TransformStage.PARSE + 0.5 -> TransformStage.COMPILE + 0.6)
exit 1, FAILED | 0 passed (211 steps) | 1 failed
  -> "runs before compile even when a custom plugin re-sorts the pipeline"

Exactly the claim in your body, and each half fails one and only one test.

Mutation check on the pinned term.

# delete "&& !pinned.has(name)" at browser-server-exports-strip.ts:1248
deno test --preload=src/testing/preload.ts --no-check --allow-all src/transforms/pipeline/stages/
exit 1, FAILED | 23 passed (527 steps) | 1 failed (2 steps)
  -> "keeps partially hook-owned destructuring and pinned JSX factory names"

Leak fixtures. I wrote a probe running ten authoring shapes through the real runPipeline({ ssr: false }) on this head and on origin/main, then deleted it. Shapes: direct import, export *, aliased re-export, namespace import, dynamic import(), destructured value, side-effect-only import, string-literal export name, escaped IdentifierName export clause, escaped IdentifierName declaration.

shape main this head
escaped IdentifierName in an export clause stubbed leaks secret and hook body
escaped IdentifierName on the declaration stubbed leaks secret and hook body
escaped IdentifierName on .js stubbed leaks secret and hook body
string-literal export name stubbed stubbed
export { loadDashboard as getServerData } from … ships the loader import ServerExportStripError, correct
namespace import read only by the hook stubbed, no server module stubbed, no server module
dynamic import() inside the hook stubbed, no server module stubbed, no server module
export * from "./lib/server-only-lib.ts" server module referenced server module referenced (unchanged)
authored side-effect import preserved by design preserved by design
destructured const { value: KEY } stubbed stubbed

Sourcemap fidelity, dev crossed with production. On main the dev artifact carries no sourcemap at all, because the stage drops the compile map and only restores it when nothing was stripped. On this head the dev artifact carries an inline map built from stripped input: 332 bytes decoded, sourcesContent present, and the secret appears in neither the code nor the map. Deleting the handshake improves this rather than costing it.

Diff composition. git diff --stat against the merge base: 1268 lines in browser-server-exports-strip.test.ts, 769 in the new compiler-predicates.test.ts, 418 in the new reference-classification.test.ts, against 543 in the stage, 629 in the new reference-classification.ts, 31 in compile.ts, 2 in index.ts, 23 in docs, 1 in .gitignore. No generated snapshots anywhere in the diff, so the 419 deletions are hand-classified, and I found no dropped case: the deleted handshake is genuinely unreachable once the strip runs first.

Suites.

deno test --preload=src/testing/preload.ts --no-check --allow-all \
  src/transforms/pipeline/stages/browser-server-exports-strip.test.ts \
  src/transforms/pipeline/stages/reference-classification.test.ts \
  src/transforms/pipeline/stages/compiler-predicates.test.ts
exit 0, ok | 3 passed (337 steps) | 0 failed

deno test --preload=src/testing/preload.ts --no-check --allow-all src/transforms/
exit 0, ok | 162 passed (2826 steps) | 0 failed

deno fmt --check <touched files>   exit 0
deno lint src/transforms/pipeline/stages/   exit 0, 33 files

What I did not verify. I did not run tests/integration, tests/e2e or the full repository suite. I did not re-derive your 41-cell mutation matrix; I spot-checked one cell. I did not measure the SSR stages you scope out at ssr-css-strip.ts and ssr-http-stub.ts. Green CI is not part of my evidence.

…llings

Running the strip before compile means its pre-parse gate reads authored
source instead of esbuild's normalized output, so an escaped export name
survives into the text the gate inspects. `export { loadIt as getServerData }`
contains neither the substring `getServerData` nor a quote after `as`, so
the gate returned false, the module was never parsed, and the hook body,
the secret it closes over and its `veryfront` server import all reached
the browser artifact. `main` strips all three, because after compile the
escape is already gone.

Widening the gate one spelling at a time does not close it. Two more
shapes leaked the same way and neither is a unicode escape the eye
catches: `export{loadIt as"getServerData"}` misses the old quote
clause because it has no space after `as`, and a string-literal name
split by a line continuation spells no hook name at all in raw text.

Decide it from the grammar instead. An exported name is an
IdentifierName or a StringLiteral, and in both the only way to write a
character as anything but itself is an escape sequence, which always
starts with a backslash. A module with no backslash spells every name it
exports verbatim, so the substring test settles it; anything else parses.
That is sound for every spelling, including ones nobody has written yet.

Measured over `templates/files` (60 modules, 54 KB, 20 runs): the old
gate 1.05 ms per tree, this gate 1.76 ms, parsing every exporting module
3.76 ms. Over `src/transforms` (282 modules, 2.5 MB, a pessimistic
regex-heavy upper bound): 16.6 ms, 52.8 ms and 67.3 ms. Soundness costs
0.7 ms on a realistic tree and stays cheaper than parsing everything.

`SERVER_ONLY_EXPORTS` becomes exported so the test reads the real policy
list rather than a copy that can drift from it.
The header excused `SERVER_ONLY_EXPORTS` as framework policy with no
differential partner. That conflated two questions. The SET of hook names
is policy and has no oracle. Deciding whether a module exports one of
them is a parser rule, and esbuild answers it exactly. Every other
fixture here reads a predicate's verdict off the artifact; none asked the
artifact which names the module exports, which is why a gate that could
not see an escaped export name went unnoticed.

This adds that case. It compiles the module, reads the export names off
the parsed artifact, and asserts the set of hook-named exports the pass
emptied equals the set the artifact actually has. Set equality against
the compiler fails for any spelling, so a fourth or fifth way to write
the name needs no new fixture.

Ten spellings drive it, including a plain declaration, a plain clause, a
string-literal name, three escaped IdentifierName forms, an escaped
string-literal name with no space after `as`, a name split by a line
continuation, two hooks at once and a module with no hook at all. A guard
case asserts the hidden spellings really are hidden, so a `\u` this file
resolved at its own parse time cannot pass for the wrong reason.

Five of the ten fail against the gate the previous commit replaced.
@kwakayama kwakayama changed the title refactor(transforms): run the browser server-exports strip before compile fix(transforms)!: run the browser server-exports strip before compile Aug 21, 2026
@kwakayama

Copy link
Copy Markdown
Contributor

Blocker closed: the pre-parse gate now decides from the grammar

Both blocking findings are fixed. The PR stays a draft.

  • 55cd7972a2 fix(transforms): decide the strip gate from the grammar, not from spellings
  • 191234f979 test(transforms): ask the artifact which names the module exports

Head is now 191234f9793cae05d1a8c8a688621388124588d5.

What was wrong, and what the fix is

You had it exactly right: mayNameServerOnlyExport was the sole cause, and it read raw source. I reproduced your repro end to end and then found the fix you proposed does not close the whole class.

Two more shapes leak the same way, and the second one no escape-specific regex can reach:

  • export{loadIt as"HOOK"} with no space after as. The old quote clause spells \s+, so it never matched. Your /\\u|\\x/ clause does catch this one, because the name is escaped.
  • export{loadIt as"get\ + a line continuation + ServerData"}. No \u, no \x, no substring, no whitespace before the quote. Nothing in either the old gate or the proposed patch sees it.

So instead of adding a spelling, the gate now answers from the grammar. An exported name is an IdentifierName or a StringLiteral. In both, the only way to write a character as anything other than itself is an escape sequence, and every escape sequence starts with a backslash. A module whose text holds no backslash therefore spells every name it exports verbatim, and the substring test settles it. Anything else parses and lets exportName read the normalized name. export itself cannot hide, because a reserved word written with an escape is a syntax error.

function mayNameServerOnlyExport(code: string): boolean {
  if (!/\bexport\b/.test(code)) return false;
  if (SERVER_ONLY_EXPORTS.some((name) => code.includes(name))) return true;
  return code.includes("\\");
}

That is one predicate covering every spelling, including the two your patch leaves open.

Narrow regex or fail closed: measured, then neither

You asked me to measure the build-time cost of biasing toward parsing. I did, over two trees, 20 runs averaged, whole tree per run.

tree old gate (leaking) this gate parse every exporting module
templates/files, 60 modules, 54 KB, app-shaped 1.05 ms 1.76 ms 3.76 ms
src/transforms, 282 modules, 2.5 MB, regex-heavy framework code 16.6 ms 52.8 ms 67.3 ms

On the app tree, 5 of the 54 exporting modules carry a backslash and now parse. Soundness costs 0.7 ms there and stays cheaper than parsing everything, so there was no reason to trade it away. The grammar argument is what makes that possible: the set of spellings is closed by the language, so a sound gate covers exactly the same ground as fail-closed at a fraction of the cost. Parsing everything would also turn any module the parser chokes on into a hard build error, which the current gate does not do.

Fail-first proof

Mandatory for a security fix, so here it is. The differential test was written and run before the gate changed.

RED, at e41b94476d, compiler-predicates.test.ts:

FAILED | 0 passed (105 steps) | 1 failed (6 steps)
  acts on exactly the hook exports the artifact has, given an escaped identifier in an export clause        FAILED
  acts on exactly the hook exports the artifact has, given an escaped identifier on the declaration         FAILED
  acts on exactly the hook exports the artifact has, given an escaped identifier in an export clause, .js   FAILED
  acts on exactly the hook exports the artifact has, given an escaped string-literal name, no space after as FAILED
  acts on exactly the hook exports the artifact has, given a string-literal name split by a line continuation FAILED

with, for the first:

[Diff] Actual / Expected
+   [ "getServerData" ]
-   []

GREEN at 191234f9: ok | 1 passed (111 steps) | 0 failed.

Artifact evidence, through the real runPipeline({ ssr: false }), production output

Not a unit assertion on the predicate. Each artifact was grepped for the secret, the hook body and the veryfront import.

shape main 3a109046d0 e41b94476d 191234f9
export { loadIt as get\u0053erverData } on .tsx stubbed leaks all three stubbed
export async function get\u0053erverData() stubbed leaks all three stubbed
export { loadIt as get\u0053erverData } on .js stubbed leaks all three stubbed
export{loadIt as"get\u0053erverData"}, no space after as stubbed leaks all three stubbed
string-literal name split by a line continuation stubbed leaks all three stubbed
export { loadIt as "getServerData" } stubbed stubbed stubbed

Before, at e41b94476d, spelling 1:

var n=Object.defineProperty;var t=(r,e)=>n(r,"name",{value:e,configurable:!0});
import{getEnv as o}from"/_vf_modules/_veryfront/index.client.js";
const a=o("SERVER_ONLY_HOOK_SOURCE");
async function c(){return{props:{k:a}}}t(c,"loadIt");
function u(){return null}t(u,"Page");
export{u as default,c as getServerData};

After, at 191234f9, same input:

var n=Object.defineProperty;var r=(e,t)=>n(e,"name",{value:t,configurable:!0});
async function l(){throw new Error("server-only")}r(l,"loadIt");
function o(){return null}r(o,"Page");
export{o as default,l as getServerData};

Secret gone, hook body stubbed, veryfront import gone. Byte-equivalent in effect to main.

The differential test

compiler-predicates.test.ts no longer excuses this. It compiles the module, reads the export names off the parsed artifact, and asserts the set of hook-named exports the pass emptied equals the set the artifact actually has. Set equality against the compiler fails for any spelling, so a seventh way to write the name needs no new fixture. Ten spellings drive it, plus a guard case asserting the hidden ones really are hidden, so a \u the test file resolved at its own parse time cannot pass for the wrong reason. SERVER_ONLY_EXPORTS is exported from the stage now so the test reads the real policy list rather than a copy that can drift.

The header comment keeps the policy/rule distinction rather than deleting the note, because conflating them is what hid this.

Non-blocking items

  1. Commit type fixed. Title is now fix(transforms)!: run the browser server-exports strip before compile.
  2. PR body refreshed. SHAs corrected to 3a109046d0 and 191234f9, the governing issue changed from closed feat: File-based SDLC conventions (issue #102) #112 to fix: remove unused vars in pipeline.behavior.test.ts #605, and a new section documents the gate.
  3. Merge-conflict section updated. 51b31a52c4 resolved it keeping main's .every and the pinned term, and git merge-tree --write-tree origin/main HEAD at 191234f9 returns a tree with zero conflict entries.
  4. Mutant item closed. Verified independently: deleting && !pinned.has(name) and running browser-server-exports-strip.test.ts gives 0 passed, 1 failed, killed by keeps partially hook-owned destructuring and pinned JSX factory names. Removed from the handover.
  5. .gitignore split. Already done. That line lives alone in afc3eb7056 fix(repo): remove the stray node_modules symlink committed by mistake, which touches only .gitignore and the stray symlink, so it cherry-picks out as it stands. No new commit needed.

Still open, unchanged

Rest, computed key and sibling default destructuring, and export * from. Re-measured: they leak identically on main at 3a109046d0 and on this head, so they are pre-existing and out of scope. The two SSR stages are untouched.

Gates

deno test --preload=src/testing/preload.ts --no-check --allow-all src/transforms/   EXIT=0
  ok | 162 passed (2838 steps) | 0 failed

deno check  <2 touched files>       EXIT=0
deno lint   <2 touched files>       EXIT=0
deno fmt --check <2 touched files>  EXIT=0

The full src/transforms/ suite was re-run after the last edit, at the pushed HEAD. The 41-cell mutation matrix and the differential suite are intact: nothing in them broke.

One thing I could not re-measure, stated plainly

The whole-repository verification table in the body was measured at main c4e93cd8 against branch 38a0a0be. I could not re-run it here. In this sandbox, running those roots together aborts 582 of 636 files at module load with The current server runtime does not expose complete node:util/types brand checks from src/platform/compat/native-brand-checks.ts:98. It does the same on origin/main at 3a109046d0 with no branch content at all, in parallel and serial alike, and the same files pass when run in smaller groups. That is an environment fault and a number produced through it would say nothing, so the table stands as previously measured and the body now says so. Hosted CI is the authority for that column.

Still a draft.

Takes in 41 commits of `main`, including the regenerated
`templates/manifest.generated.ts`. Without it `deno task typecheck` failed on
this branch at its first step, `generate:manifests:check`, with
`./templates/manifest.generated.ts is stale`, while the same command passed on
plain `origin/main`. The manifest decompresses to identical content on both
trees, so only the committed gzip bytes were behind; the merge takes `main`'s.

No conflicts. The strip stage, the pipeline ordering and the pre-parse gate are
unchanged by the merge.
@kwakayama

Copy link
Copy Markdown
Contributor

Review: 94/100 - Approve, and it stays a draft

The blocker from the last pass is closed and I verified it end to end myself rather than reading the fix comment: reverting the gate on the current head makes five escaped spellings ship the secret, the hook body and the veryfront import through the real runPipeline, and restoring it makes all six clean. This is a draft under veryfront/veryfront-issue-inbox#605 and I have left it a draft. One thing changed under the PR while it sat: main regenerated templates/manifest.generated.ts, which turned deno task typecheck red on this branch and green on main, so I merged main in (1f1d0c41b4) and typecheck is back to exit 0.

Score breakdown

Axis Score Note
Correctness 29/30 Not inert: traced from module-transform-cache.ts:135 to the reordered BROWSER_PIPELINE. Closes the reorder's own regression and four earlier leaks. One point off for the keepNames handling no production route can now reach.
Test quality 20/20 The differential oracle is the real thing: it asks esbuild which names the module exports and compares sets, so a seventh spelling needs no fixture. Fail-first proven on the current tree, and the anti-vacuity guard case is there.
Scope discipline 11/15 9 files, +3543/-420 for an ordering change. .gitignore is isolated as asked. The classification module is separable and I say how below.
Design fit 14/15 The gate now answers from the grammar, which is where the rule is owned. Ordering note sits at the dependent call site with the stage numbers spelled out.
Security & safety 10/10 Re-verified against current main 8d0ce3c2f0, not the stale SHA. Fails closed. The grammar argument holds.
Docs & hygiene 10/10 Body re-measured at the real head and the real main. Zero em or en dashes on any line this PR adds.

Changes made in this pass

1. Merged main to fix deno task typecheck. 1f1d0c41b4.

deno task typecheck exited 1 at its first step, generate:manifests:check:

./templates/manifest.generated.ts is stale. Run deno task generate.

The same command on plain origin/main at 8d0ce3c2f0, fresh tree, no branch content: exit 0. So the earlier note in the body calling that failure pre-existing was true at main c4e93cd8 and had stopped being true. This branch never edits that file, so it still carried the merge-base bytes while main had regenerated them.

Worth recording why, because it looks like a content drift and is not one. I decompressed both copies: byte-identical, 990887 characters, the same 48 templates and 467 files. generate-templates-manifest.ts:227-236 compares the encoded base64 string, not the content, so only the committed gzip encoding was behind. I did not hand-regenerate anything. git merge origin/main takes main's copy, the check passes, and the effective diff is unchanged: still 9 files, +3543/-420 against the new merge base.

2. Re-measured the PR body against the real main. main had moved 41 commits, from 3a109046d0 to 8d0ce3c2f0. The body now carries a Re-measured against current main section with the artifact table at the new SHA, plus an Interaction with #3846 section. The #605 reference and the merge-conflict paragraph were already correct.

Two items the last review raised needed no work, and I confirmed both rather than taking them on trust:

  • .gitignore is already split. The line lives alone in afc3eb7056, which touches .gitignore and the stray symlink and nothing else. Net cost against the merge base is 1 line, because the symlink is added and removed inside the branch. It cherry-picks out as it stands.
  • The pinned handover item is dead and already removed. Deleting && !pinned.has(name) at browser-server-exports-strip.ts:1264 and running src/transforms/pipeline/stages/ fails on keeps partially hook-owned destructuring and pinned JSX factory names. The body records it at line 146.

Security fix, re-verified

Not read off the fix comment. Measured through the real runPipeline({ ssr: false }), production output, on current main 8d0ce3c2f0 and on this head, each artifact grepped for the secret, the hook body and the veryfront import.

shape main 8d0ce3c2f0 this head gate reverted to e41b94476d's
export { loadIt as get\u0053erverData } on .tsx stubbed stubbed leaks all three
export async function get\u0053erverData() stubbed stubbed leaks all three
export { loadIt as get\u0053erverData } on .js stubbed stubbed leaks all three
export{loadIt as"get\u0053erverData"}, no space after as stubbed stubbed leaks all three
string-literal name split by a line continuation stubbed stubbed leaks all three
export { loadIt as "getServerData" } stubbed stubbed stubbed

The third column is the fail-first proof, taken on the current tree rather than on the old head: I replaced the gate at browser-server-exports-strip.ts:158-162 with the exact body e41b94476d shipped, changed nothing else, and re-ran. Artifact for the first shape with the old gate:

var n=Object.defineProperty;var t=(r,e)=>n(r,"name",{value:e,configurable:!0});
import{getEnv as o}from"/_vf_modules/_veryfront/index.client.js";
const a=o("SERVER_ONLY_HOOK_SOURCE");
async function c(){return{props:{k:a}}}t(c,"loadIt");
function u(){return null}t(u,"Page");
export{u as default,c as getServerData};

and with the gate restored, same input, after the main merge:

var n=Object.defineProperty;var r=(e,t)=>n(e,"name",{value:t,configurable:!0});
async function l(){throw new Error("server-only")}r(l,"loadIt");
function o(){return null}r(o,"Page");
export{o as default,l as getServerData};

compiler-predicates.test.ts under the old gate: exactly five of the ten spelling steps red, the other five green, and nothing else in the file moved. So the test kills the defect and only the defect.

I checked the grammar argument rather than assuming it. export is a reserved word, and a reserved word written with an escape is a syntax error, so the \bexport\b clause cannot be dodged. An exported name is an IdentifierName or a StringLiteral, and in both the only way to write a character as anything but itself is an escape sequence beginning with a backslash. A module whose text holds no backslash therefore spells every name it exports verbatim, and the substring test settles it. The predicate is sound, not a wider net.

Interaction with #3846

Re-checked at the current SHAs. #3855 goes first, and the evidence is the merge, not a preference.

  • git merge-tree --write-tree origin/main HEAD: exit 0, clean.
  • git merge-tree --write-tree origin/main pr-3846: exit 1, conflicts in browser-server-exports-strip.ts, browser-server-exports-strip.test.ts and templates/manifest.generated.ts.
  • git merge-tree --write-tree HEAD pr-3846: exit 1, the same three plus docs/guides/data-fetching.md.

#3846 is +9165/-584 across 4 files against a merge base of c4e93cd8, which main left far behind, and it no longer merges with main at all. This branch does. Land this one first and rebase #3846 onto it.

One correction to the framing I was handed. It is not true that this PR deletes the keepNames subsystem #3846 builds: both branches still carry compilerNameHelperBindings and compilerNameRegistrations, here at :1073 and :1123, on #3846 at :1802 and :1852. What is true, and is the stronger argument, is that this PR makes that subsystem unreachable in production. stripServerOnlyExports has exactly two callers, the plugin at browser-server-exports-strip.ts:1573 and the splitter at src/build/bundler/code-splitter/esbuild-plugin.ts:63, and after the reorder both hand it raw authored source, so no esbuild __name helper ever reaches it. #3846 is investing in a code path that this PR retires. That is the reason to settle the order before #3846's diff is judged, and it is also my one point off Correctness: the retained path is tested and defensible as a guard for a future caller, but the PR does not say it is now unreachable.

Scope, assessed honestly

Two commits are genuinely separable and would cut about 1050 lines from this review: 9df87a2215 (the 629-line reference-classification.ts extraction plus the exhaustive walker fix) and 8db5dbcb61 (its 418-line mutation suite). They are already isolated commits touching only the new module and its test, so the split is a cherry-pick onto main, not a rewrite. They are coupled to the reorder by cause, because moving the stage ahead of compile is what exposed the classification gaps, but not by code: the new module compiles and tests on main as it stands.

The rest does not split. The docs change describes the break this PR creates, the differential suite exists because of the gate this PR moved, and compile.ts is the other half of the ordering. I am not asking for the split. I am recording that it is available if a maintainer wants #605's slice A smaller.

Verification performed

Isolated worktree, head 1f1d0c41b4, merge base f16c1c4760, compared against origin/main at 8d0ce3c2f0. Every gate redirected to a file, exit code echoed, nothing piped.

Before the main merge:

deno task typecheck    EXIT=1   ./templates/manifest.generated.ts is stale
deno task lint:ci      EXIT=0   ok | 5 passed (32 steps) | 0 failed
deno fmt --check <9 touched files>   EXIT=0   Checked 8 files
deno test --preload=src/testing/preload.ts --no-check --allow-all src/transforms/
                       EXIT=1   161 passed (2836 steps) | 1 failed

After the main merge, at the pushed head:

deno task typecheck    EXIT=0
deno task lint:ci      EXIT=0
deno fmt --check <9 touched files>   EXIT=0
deno test --preload=src/testing/preload.ts --no-check --allow-all src/transforms/
                       EXIT=1   161 passed (2838 steps) | 1 failed

The one failure is pre-existing and I proved it on main. It is transforms/mdx/esm-module-loader/utils/source-spans ... keeps line-broken division scans within a bounded runtime, a wall-clock budget:

AssertionError: Expected a 31 KB line-broken division scan to finish within 1500 ms, got 5213.5 ms

Run alone on this branch: exit 1, 5213.5 ms. Run alone on plain origin/main at 8d0ce3c2f0 in a separate tree: exit 1, 6433.0 ms. Same assertion, same file, worse on main. This PR touches nothing under src/transforms/mdx/. It is a loaded-machine timing failure, not a signal about either tree, and hosted CI is the authority.

Other checks:

  • Not inert. stripServerOnlyExports is reached from src/rendering/orchestrator/module-loader/module-transform-cache.ts:135 through runPipeline and the BROWSER_PIPELINE array, and from the splitter at src/build/bundler/code-splitter/esbuild-plugin.ts:63. Both halves of the ordering change, the array position at src/transforms/pipeline/index.ts:71 and the stage number at browser-server-exports-strip.ts:1571, remain independently pinned.
  • Residual gaps, re-measured against current main. Rest, computed-key and sibling-default destructuring leak the secret identically on main 8d0ce3c2f0 and on this head. export * from "./lib/server-only-lib.ts" references the server module on both. Pre-existing, no regression in either direction, and the Still open section states it correctly. Not touched here.
  • Public copy rules. grep -n $'[\u2014\u2013]' over all nine touched files finds 14 dash characters. Every one is present unchanged at the merge base, and the added-line diff has none. No new violation. I did not churn the pre-existing ones.
  • Threads. Zero review threads, resolved or unresolved, and zero submitted reviews.

What I did not verify. tests/integration, tests/e2e and the full repository suite. I did not re-derive the 41-cell mutation matrix; I killed one cell. I did not measure the two SSR stages the body scopes out. Green CI is not part of my evidence.

Still a draft. Not marked ready, not merged.

@kwakayama
kwakayama marked this pull request as ready for review August 21, 2026 13:30
@kwakayama
kwakayama self-requested a review as a code owner August 21, 2026 13:30
@kwakayama

Copy link
Copy Markdown
Contributor

Marked ready for review

I flipped this out of draft. Your body says "Draft. Do not mark ready or merge.", so recording plainly that this overrides that line, on the repo owner's instruction rather than on my own judgement. Convert it back if you disagree, nothing here is irreversible.

What the review pass established, at 94/100:

  • The leak this reorder introduced is closed, and I proved it on the current tree rather than reading the fix commit. Replacing the gate at browser-server-exports-strip.ts:158-162 with the body e41b94476d shipped, changing nothing else, makes five escaped spellings ship the secret, the hook body and the veryfront import through the real runPipeline. Restoring it makes all six clean. That includes two spellings no escape-specific regex would have caught: as" with no space, and a string-literal name split by a line continuation.
  • The gate now answers from the grammar, not from a list of spellings. An exported name is an IdentifierName or a StringLiteral, and every escape starts with a backslash, so a module whose text holds no backslash spells every exported name verbatim. One predicate, every spelling, including ones nobody has thought of yet.
  • The differential oracle is the part worth keeping. It asks esbuild which names the module exports and compares sets, so a seventh spelling needs no new fixture.
  • Re-measured against current main at 8d0ce3c2f0, 41 commits on from the SHA in the original body. Still merges with zero conflicts.
  • Zero unresolved review threads. Zero em or en dash characters on any line this PR adds.

Two things a reviewer should still weigh, and neither is a defect:

  1. This is a breaking change, correctly retitled fix(transforms)!:. A re-exported data hook now fails the browser build. The migration note is in docs/guides/data-fetching.md and the behaviour section is linked from the top of the body. That break needs to reach release notes.
  2. Scope is the weakest axis, at 11/15. 9 files and +3543/-420 for what is described as an ordering change. .gitignore is already isolated in afc3eb7056 and cherry-picks out cleanly. The reference-classification module is separable if you want a smaller diff to review.

Landing order still matters. This should land before #3846. Moving the strip ahead of compile removes the need for the keepNames subsystem that #3846 builds, so the other order means writing that machinery twice. #3846 is separately blocked on a policy decision in veryfront/veryfront-issue-inbox#605 and is not competing for this slot.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1f1d0c41b4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/transforms/pipeline/index.ts
A page can bind a server-only hook through a TypeScript import-equals
alias:

    import Loader = require("./server");
    export { Loader as getServerData };

`exportedHookBindings` records `Loader` as a hook local, but
`emptyServerOnlyHooks` only stubs function and variable declarations. A
`TSImportEqualsDeclaration` is neither, so it reported no change and
`stripServerOnlyExports` returned the source untouched -- shipping
`require("./server")`, and with it the server module's whole graph, into
the browser artifact. Verified against the real pass: no throw, source
byte-identical, `require("./server")` still present.

This is reachable only because the pass now runs ahead of
`compilePlugin`. Running after compile, esbuild had already lowered the
alias to a `const` that the variable-declaration path stubbed.

Rejecting rather than stubbing, which is the same fail-closed answer the
other shapes this pass cannot take apart already get -- a re-export from
another module, a destructured `export const { … } = …`. Silently
passing the source through is the one outcome that must not happen,
because it defeats the pass entirely.

Covers the exported form (`export import getServerData = require(…)`)
as well, and is scoped to bindings a hook actually resolves to, so an
unrelated import-equals in the same module still strips normally.
@kojiwakayama

Copy link
Copy Markdown
Contributor Author

Addressed the P1 import-equals finding in 2262c2643 — probed first, and it reproduced as a fully silent pass-through (threw=false unchanged=true keepsServerRequire=true), so the server graph was reaching the browser artifact untouched. Fixed by rejecting, matching how this pass already treats the other shapes it cannot take apart. Three regression tests, two of which fail without the change. Thread resolved.

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

if (node.type === "ClassDeclaration" || node.type === "ClassExpression") {
if (node.type === "ClassDeclaration") bindPatternNames(scopes[0] ?? rootScope, node.id);
visitDecorators(node, scopes);
const body = node.body;
if (isNode(body)) visitChildren(body, scopes);
if (isNode(node.superClass)) visit(node.superClass, scopes);

P2 Badge Bind named class expressions in their class scope

When a hook-only helper contains a named class expression such as const Factory = class Inner { static make() { return Inner; } }, the class name is scoped within the class body, but this branch binds the ID only for ClassDeclaration. The walker therefore reports Inner as a free module reference; after pruning Factory, the hook closure expands to Inner and can incorrectly remove an unrelated top-level declaration such as const Inner = initClient(), including its client-side initialization. Traverse a named class expression with its ID bound in a class-local scope.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@kojiwakayama

Copy link
Copy Markdown
Contributor Author

Closed in favor of the bounded replacement #3956.

I reviewed the full comment and review history before closing. The valid findings and behavior are preserved in #3956:

  • strip-before-compile ordering, pinned by both array-order and custom-plugin tests
  • legal and leading comment retention
  • the JS, JSX, MDX, TS, and TSX unused-import artifact matrix
  • escaped export names
  • import-equals fail-closed behavior
  • named class-expression scope, from the final P2 review
  • development source maps built only from stripped input

The replacement also preserves the direct code-splitter helper contract and has an independent approval plus a green full local pre-push gate.

The original PR is not a good merge unit: it is thousands of added lines, carries stale history, and mixes the ordering fix with broad reference-classification and mutation corpora. Those broad corpora are intentionally not imported. The security and compatibility behavior that still matters is covered directly at the real pipeline seam in #3956.

Superseded, not rejected in principle. Continue review and landing on #3956.

kojiwakayama added a commit that referenced this pull request Aug 21, 2026
The browser artifact should never feed server-only hook bodies into custom pre-compile plugins, esbuild keepNames output, or development sourcemaps. Moving the browser strip pass ahead of compile makes the ordering explicit and removes the compile-stage metadata handoff.

The strip pass now classifies only value references while preserving authored JSX pragmas. Direct helper callers keep the legacy behavior for unrelated unused imports by demoting them to side-effect imports. The browser pipeline passes an explicit TypeScript-only option so TS and TSX inputs leave unrelated named imports authored for the following compile stage to erase.

Hook-owned project imports are still deleted when no surviving browser code reads any binding from that statement, so their transitive server graph is not kept alive. JS, JSX, and generated MDX browser artifacts retain only a bare side-effect import for unrelated unused project imports.

Precompile parsing now accepts the compiler-supported decorator placements before and after export. Authored parser source diagnostics are surfaced with the existing compilation-error tenant-build-failure shape, while missing parser and parser-internal failures remain framework owned.

Constraint: Browser pipeline custom plugins are sorted by numeric stage, so the plugin stage and pipeline array order must both place stripping between parse and compile.

Constraint: Re-exported and import-equals server hooks have no local body that the strip pass can safely empty.

Rejected: Cherry-pick PR #3855 wholesale | it carries unrelated broad predicate/corpus work beyond the independently landable strip-before-compile fix.

Rejected: Keep compile sourcemap metadata restoration | stripping before compile makes the browser compile map naturally originate from stripped input.

Confidence: medium

Scope-risk: moderate

Directive: Keep browser-server-exports-strip before compile unless unused-import, parse-diagnostic ownership, and source-map behavior are redesigned together.

Tested: deno task test:file extensions/ext-parser-babel/src/index.test.ts src/transforms/pipeline/stages/browser-server-exports-strip.test.ts src/transforms/pipeline/stages/compile.test.ts src/transforms/pipeline/index.test.ts src/build/bundler/code-splitter/esbuild-plugin.test.ts

Tested: deno fmt --check extensions/ext-parser-babel/src/index.test.ts extensions/ext-parser-babel/src/parser-only.ts src/transforms/esm/transform-utils.ts src/transforms/pipeline/stages/compile.ts src/transforms/pipeline/stages/browser-server-exports-strip.ts src/transforms/pipeline/stages/browser-server-exports-strip.test.ts

Tested: deno lint extensions/ext-parser-babel/src/index.test.ts extensions/ext-parser-babel/src/parser-only.ts src/transforms/esm/transform-utils.ts src/transforms/pipeline/stages/compile.ts src/transforms/pipeline/stages/browser-server-exports-strip.ts src/transforms/pipeline/stages/browser-server-exports-strip.test.ts

Tested: deno check extensions/ext-parser-babel/src/index.test.ts extensions/ext-parser-babel/src/parser-only.ts src/transforms/esm/transform-utils.ts src/transforms/pipeline/stages/compile.ts src/transforms/pipeline/stages/browser-server-exports-strip.ts src/transforms/pipeline/stages/browser-server-exports-strip.test.ts

Tested: git diff --check

Tested: rg -n Unicode dash scan across touched files, no matches

Tested: rg -n console.log, empty catch, and hardcoded apiKey patterns across touched files, no matches

Not-tested: Full repository test suite

Related: #3855
kojiwakayama added a commit that referenced this pull request Aug 21, 2026
The browser artifact should never feed server-only hook bodies into custom pre-compile plugins, esbuild keepNames output, or development sourcemaps. Moving the browser strip pass ahead of compile makes the ordering explicit and removes the compile-stage metadata handoff.

The strip pass now classifies only value references while preserving authored JSX pragmas. Direct helper callers keep the legacy behavior for unrelated unused imports by demoting them to side-effect imports. The browser pipeline passes an explicit TypeScript-only option so TS and TSX inputs leave unrelated named imports authored for the following compile stage to erase.

Hook-owned project imports are still deleted when no surviving browser code reads any binding from that statement, so their transitive server graph is not kept alive. JS, JSX, and generated MDX browser artifacts retain only a bare side-effect import for unrelated unused project imports.

Precompile parsing now accepts the compiler-supported decorator placements before and after export. Authored parser source diagnostics are surfaced with the existing compilation-error tenant-build-failure shape, while missing parser and parser-internal failures remain framework owned.

Constraint: Browser pipeline custom plugins are sorted by numeric stage, so the plugin stage and pipeline array order must both place stripping between parse and compile.

Constraint: Re-exported and import-equals server hooks have no local body that the strip pass can safely empty.

Rejected: Cherry-pick PR #3855 wholesale | it carries unrelated broad predicate/corpus work beyond the independently landable strip-before-compile fix.

Rejected: Keep compile sourcemap metadata restoration | stripping before compile makes the browser compile map naturally originate from stripped input.

Confidence: medium

Scope-risk: moderate

Directive: Keep browser-server-exports-strip before compile unless unused-import, parse-diagnostic ownership, and source-map behavior are redesigned together.

Tested: deno task test:file extensions/ext-parser-babel/src/index.test.ts src/transforms/pipeline/stages/browser-server-exports-strip.test.ts src/transforms/pipeline/stages/compile.test.ts src/transforms/pipeline/index.test.ts src/build/bundler/code-splitter/esbuild-plugin.test.ts

Tested: deno fmt --check extensions/ext-parser-babel/src/index.test.ts extensions/ext-parser-babel/src/parser-only.ts src/transforms/esm/transform-utils.ts src/transforms/pipeline/stages/compile.ts src/transforms/pipeline/stages/browser-server-exports-strip.ts src/transforms/pipeline/stages/browser-server-exports-strip.test.ts

Tested: deno lint extensions/ext-parser-babel/src/index.test.ts extensions/ext-parser-babel/src/parser-only.ts src/transforms/esm/transform-utils.ts src/transforms/pipeline/stages/compile.ts src/transforms/pipeline/stages/browser-server-exports-strip.ts src/transforms/pipeline/stages/browser-server-exports-strip.test.ts

Tested: deno check extensions/ext-parser-babel/src/index.test.ts extensions/ext-parser-babel/src/parser-only.ts src/transforms/esm/transform-utils.ts src/transforms/pipeline/stages/compile.ts src/transforms/pipeline/stages/browser-server-exports-strip.ts src/transforms/pipeline/stages/browser-server-exports-strip.test.ts

Tested: git diff --check

Tested: rg -n Unicode dash scan across touched files, no matches

Tested: rg -n console.log, empty catch, and hardcoded apiKey patterns across touched files, no matches

Not-tested: Full repository test suite

Related: #3855
kojiwakayama added a commit that referenced this pull request Aug 21, 2026
The browser artifact should never feed server-only hook bodies into custom pre-compile plugins, esbuild keepNames output, or development sourcemaps. Moving the browser strip pass ahead of compile makes the ordering explicit and removes the compile-stage metadata handoff.

The strip pass now classifies only value references while preserving authored JSX pragmas. Direct helper callers keep the legacy behavior for unrelated unused imports by demoting them to side-effect imports. The browser pipeline passes an explicit TypeScript-only option so TS and TSX inputs leave unrelated named imports authored for the following compile stage to erase.

Hook-owned project imports are still deleted when no surviving browser code reads any binding from that statement, so their transitive server graph is not kept alive. JS, JSX, and generated MDX browser artifacts retain only a bare side-effect import for unrelated unused project imports.

Precompile parsing now accepts the compiler-supported decorator placements before and after export. Authored parser source diagnostics are surfaced with the existing compilation-error tenant-build-failure shape, while missing parser and parser-internal failures remain framework owned.

Constraint: Browser pipeline custom plugins are sorted by numeric stage, so the plugin stage and pipeline array order must both place stripping between parse and compile.

Constraint: Re-exported and import-equals server hooks have no local body that the strip pass can safely empty.

Rejected: Cherry-pick PR #3855 wholesale | it carries unrelated broad predicate/corpus work beyond the independently landable strip-before-compile fix.

Rejected: Keep compile sourcemap metadata restoration | stripping before compile makes the browser compile map naturally originate from stripped input.

Confidence: medium

Scope-risk: moderate

Directive: Keep browser-server-exports-strip before compile unless unused-import, parse-diagnostic ownership, and source-map behavior are redesigned together.

Tested: deno task test:file extensions/ext-parser-babel/src/index.test.ts src/transforms/pipeline/stages/browser-server-exports-strip.test.ts src/transforms/pipeline/stages/compile.test.ts src/transforms/pipeline/index.test.ts src/build/bundler/code-splitter/esbuild-plugin.test.ts

Tested: deno fmt --check extensions/ext-parser-babel/src/index.test.ts extensions/ext-parser-babel/src/parser-only.ts src/transforms/esm/transform-utils.ts src/transforms/pipeline/stages/compile.ts src/transforms/pipeline/stages/browser-server-exports-strip.ts src/transforms/pipeline/stages/browser-server-exports-strip.test.ts

Tested: deno lint extensions/ext-parser-babel/src/index.test.ts extensions/ext-parser-babel/src/parser-only.ts src/transforms/esm/transform-utils.ts src/transforms/pipeline/stages/compile.ts src/transforms/pipeline/stages/browser-server-exports-strip.ts src/transforms/pipeline/stages/browser-server-exports-strip.test.ts

Tested: deno check extensions/ext-parser-babel/src/index.test.ts extensions/ext-parser-babel/src/parser-only.ts src/transforms/esm/transform-utils.ts src/transforms/pipeline/stages/compile.ts src/transforms/pipeline/stages/browser-server-exports-strip.ts src/transforms/pipeline/stages/browser-server-exports-strip.test.ts

Tested: git diff --check

Tested: rg -n Unicode dash scan across touched files, no matches

Tested: rg -n console.log, empty catch, and hardcoded apiKey patterns across touched files, no matches

Not-tested: Full repository test suite

Related: #3855
@kojiwakayama
kojiwakayama deleted the feat/issue-112-strip-before-compile branch August 30, 2026 10:27
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