fix(unplugin): give the transform cache a delivery epoch - #1306
Conversation
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M79urGdrmmPEehBgQHhvY2
The adapter had two cache modes and needed three. Two orthogonal facts were conflated in one: a delivery epoch, meaning one bundler pass inside which each module is requested at most once, and generation validity, meaning whether the compiled whole-project result still matches the filesystem. `beginTtscTransformBuild` asserted the first by destroying the second, which is sound but wasteful: making the generation be the pass was the only way the code could guarantee it reflected the pass's start. Every host whose `buildStart` repeats therefore threw away a valid compile on every edit. It now opens an epoch and keeps the generation. The pass's first delivery proves the whole generation once through the complete input snapshot; after that each module's first delivery in the pass is settled by the supplied source alone, exactly as before. Measured on the real native-host fixture: five passes, four of which changed nothing, drop from five whole-project compiles to two, and a no-change pass from ~1,700 ms to ~22 ms. The project-membership snapshot moves with it. It compared each directory's own metadata stamp, which shifts when any entry appears, including the ones the walk exists to ignore, so a bundler creating its output directory inside the project voided a generation no compiler input had touched. It now digests the entries the walk itself considers, so the ignore list is honoured on both sides. Two verdicts ride the same boundary. A compile that produced no output is retained for its pass and replayed by the remaining modules rather than repeated per module, and the next pass attempts it again; a cache with no pass boundary keeps evicting on every delivery, so a transient host failure never becomes permanent for a long-lived worker. A generation's non-error diagnostics describe one compile of one program, so they are surfaced once per pass instead of once per delivered module. The envelope cannot distinguish those two failure causes. An ordinary type error arrives as `type: "exception"` carrying the compiler's rendered diagnostic text, indistinguishable from a crashed host, so the repetition is bounded by the pass rather than by a classification that would have to be guessed. Close #1300: webpack watch: `buildStart` clears the transform cache on every rebuild, so each edit pays for a full project transform Close #1303: a failed compile is repeated by every module of the same build pass Close #1304: a generation's project-wide diagnostics are printed once per delivered module Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M79urGdrmmPEehBgQHhvY2
…pass `vite build --watch` disposed the whole generation at the end of every rebuild, through a site independent of the `buildStart` clear, so fixing that one alone left this host recompiling the whole project per edit. The reset under `vite.buildEnd` exists for one reason: a dev server's plugin container calls `buildEnd` when the server closes, so under `serve` it genuinely means the session ended. Under `build` it does not. Rollup calls it at the end of every build phase and its watcher repeats build phases, which a real `vite build --watch` trace confirms: buildStart -> buildEnd -> writeBundle -> closeBundle -> buildStart -> buildEnd -> writeBundle -> closeBundle -> ... -> closeWatcher `closeBundle` repeats for the same reason and is equally unusable as a teardown signal. `closeWatcher` is the only hook in that trace firing exactly once, so it is where a generation retained across passes releases its directory watchers. The overlapping-container bookkeeping is untouched: a restart still hands the replacement's generation to the replacement, and an unstarted container's `buildEnd` still disposes nothing. Close #1301: `vite build --watch` disposes the transform generation at the end of every rebuild Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M79urGdrmmPEehBgQHhvY2
…ack loader The loader claimed to mirror the unplugin adapters' filter and re-implemented two of its four conditions, keeping a second copy of `nodeModulesPattern` and omitting the source-extension gate and the virtual-module gate. Its own docstring, the README and the website all stated the parity that was absent. A rule glob wider than `*.ts`/`*.tsx`, which is the natural thing to write for a project with mixed sources and the reason a loader needs a filter at all, then routed JavaScript and virtual ids into the whole-project transform every other adapter excludes. A project without `allowJs` has no program entry for such a file, so the delivery failed with `did not return output`. It uses `isTransformTarget` now, which is exported for exactly this and which the Bun adapter already imports, so the filter really is defined once. Close #1305: the Turbopack loader does not apply the shared transform-target filter it documents Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M79urGdrmmPEehBgQHhvY2
Nothing measured what the cache did across passes. Every `beginTtscTransformBuild` call site in the repository drove exactly one build: perf scenarios A through C call it once and D through F never, and every suite case builds a fresh cache first. The Vite lifecycle scenarios drive `buildStart` twice, but only to model overlapping containers during a restart. So a defect that appears at the second pass was invisible to all of them, which is how a whole-project transform per edit shipped through a suite that pins the one-compile invariant eleven other ways. The matrix now covers repeated passes over an unchanged project, a pass that edits a delivered module, a pass that edits a type-only input the bundler erased, a pass that changes project membership, a pass that touches a file the generation never declared, a bundler creating its own output directory, and a module delivered twice inside one pass. One of them runs through a real webpack watch session rather than the core API, so the mapping from a host's own rebuild signal to a pass boundary is covered end to end; that case is what found the directory-membership imprecision the previous commit fixes. Counting compiles from outside a running bundler needed an instrument, so the shared fixture plugin gained an opt-in `count-runs` operation that appends one byte per whole-project transform to a log outside the project. The perf harness gains scenario G for the same dimension, and its README states the invariant it now guards. Close #1302: nothing gates the transform cache across repeated build lifecycles Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M79urGdrmmPEehBgQHhvY2
Runs the repository formatter once over the cycle, and corrects two comments that described the mechanism the cycle removes. The factory and `buildStart` notes in `core/index.ts` still said a host with a build boundary uses a per-build cache. The Bun bundler scenario still said `onStart` clears the generation; what it now asserts is that `onStart` opens a delivery pass whose first delivery re-proves the generation, sees the changed input, and compiles again. The assertion is unchanged and still the right one, so only its name and rationale move. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M79urGdrmmPEehBgQHhvY2
samchon
left a comment
There was a problem hiding this comment.
Individual Self-Review — aa9ec2d03 (fix(unplugin): apply the shared transform-target filter in the turbopack loader)
Resolves #1305. Read-only advisory review over the parent-to-commit diff; adjudication below is mine.
Accepted
The virtual-id row of the new case is vacuous, and two doc comments overstate it. transformTtsc already short-circuits a NUL id itself (core/transform.ts: if (clean.includes("\0")) return undefined;), so the pre-fix loader also returned the source untouched for \0virtual:module.ts. The two filters did disagree there, but the delivery did not, so that row cannot fail against the old loader. Only the four JavaScript rows are true regression guards: they reach selectTransformedSource, which throws did not return output.
The row is still worth keeping as defence in depth, because it pins that the loader no longer depends on a guard living inside the transform. What has to change is the description: the case docstring and the internal assert's docstring both call it a row "the two filters disagreed on" in the sense that the delivery failed, which is not true. Being corrected.
The issue body itself already states this accurately ("a virtual module is not mis-transformed, it merely reaches a function that has to reject it... correct today, but by a second line of defence rather than by the shared contract"), so only the in-repo comments drifted.
sourceFilePattern's doc contradicts its own regex. core/index.ts documents it as matching "any TypeScript or JavaScript source extension" while the pattern is /\.[cm]?tsx?$/, which matches no JavaScript extension. This commit makes that comment load-bearing for a second adapter and it directly contradicts the loader's new docstring, so it is corrected here rather than left for the next reader to trip over. Exactly the class of defect this commit exists to fix, one file away.
Accepted as a record item, not a code change
An allowJs project wiring a rule glob wider than *.ts/*.tsx loses a capability. Its .js modules were transformed by this loader and now pass through. That is deliberate: sourceFilePattern is TypeScript-only by design, every other adapter already excludes those files, and both the README and the website documented the exclusion before this commit. Turbopack was the outlier. Making .js a transform target is a product decision that belongs to the shared filter and all seven adapters at once, not to one loader. Naming it here so the narrowing is on the record.
Rejected
Formatter-cleanliness of this individual commit. The reviewer is right that pnpm run check:format would fail at this SHA. That is the campaign contract rather than a defect: an issue campaign formats its unified cycle pull request once, and campaign implementation commits skip per-commit CI. The formatting lands in 99014c5c2 and the merge is a squash, so the merged tree is clean.
{@link isTransformTarget} possibly unresolvable from the published .d.ts. Cosmetic. The link is meaningful at the source of truth, no consumer behaviour depends on it, and test_packaged_entrypoints_publish_module_faithful_declarations does not assert link targets.
Clean
No import cycle (bun.ts already imports the same predicate from core/index, so the direction is established precedent), no packaging consequence (unplugin stays externalised by name, preserveModules output and the export map are unchanged), no dead imports left behind, and the new require chain is already covered by the CJS and ESM entrypoint cases. The behaviour-change surface is exactly the two divergent id classes and nothing else.
samchon
left a comment
There was a problem hiding this comment.
Individual Self-Review — 7c3a656d6 (fix(unplugin): give the transform cache a delivery epoch)
Resolves #1300, #1303, #1304. Read-only advisory review over the parent-to-commit diff; adjudication is mine. Two accepted findings are defects this commit introduced.
Accepted, defect
A file-scoped failure is retained as a generation-wide pass verdict. selectTransformedSource throws from three sites, and only two of them are statements about the generation. The third, ttsc transform did not return output for <file>, is a statement about one file, and it is an expected non-fatal condition for a .ts module that is in the bundle but not in the tsconfig program: @ttsc/metro treats it explicitly as "pass through", test_transformer_passes_files_outside_the_project_through pins that, and the Turbopack loader's own notes describe it happening in the field.
retainPassVerdict catches all three. So a project whose bundle reaches one out-of-program module poisons the whole pass: every later module is rejected with an error naming a different file. Before this commit the generation was evicted, the next module recompiled, and the build finished. This is strictly worse than what it replaced, and it is the one place the change made something fail that used to work. Retention is being narrowed to an envelope that actually failed (result.type !== "success").
The per-file miss keeps its existing evict-and-throw, which is wasteful for the same reason #1303 is about (the generation was fine for every other module) but is pre-existing behaviour and out of this cycle's accepted scope. Recorded for the next discovery round rather than widened into here.
The unstable-generation verdict lost its per-delivery confirmation probe. replaysTerminalGeneration short-circuited on confirmedEpoch === epoch for both verdict kinds. An unstable generation has a recorded environment and a confirmation test designed to run per delivery, which the documentation describes as "later request waves pay only the confirmation probes". Caching that answer for the whole pass changes documented behaviour for no measured gain, and it removes an escape a differing module source used to provide. The short-circuit is now the pass verdict's alone, and the unstable kind keeps its own rule untouched.
The verdict replaced the surfaced error's identity. Wrapping in a fresh TtscPassVerdictError changed what a bundler prints from Error: <diagnostics> to TtscPassVerdictError: <diagnostics>, including for the first failing module, and dropped the original stack. The verdict now carries the original error's name, stack and cause, so the surfaced text is byte-identical to before and the internal instanceof distinction still works.
Accepted, documentation only
A host that opens exactly one pass never reaches the boundary that drops a verdict. Correct, and my class docstring over-promised by saying a transient failure "still recovers at the first boundary that could plausibly have changed anything". Bun's runtime plugin and a Vite dev server with server.watch: null each open one pass for the whole process, so a verdict there lasts the session.
I am keeping the behaviour and fixing the claim. Both hosts already document their session as immutable ("restart the Bun process after changing compiler inputs"; "each module's first delivery in such a session is settled against the generation the session started from"), so a session-scoped verdict is consistent with the contract they publish. The alternative is what those hosts do today: a vitest --run suite with one type error compiles the whole project once per module, which is the #970 workload this cache exists to fix. A retry budget would buy transient recovery at the price of doubling every standing-error pass in every host, which is the wrong trade for the far more common case. The limit is now stated plainly in the class comment and pinned by a test rather than left accidental.
Accepted, coverage
Adding cases for: an out-of-program module delivered inside a pass; the one-pass host verdict semantics above; the persistent-host diagnostics path, so collapsing diagnosticsReported/diagnosticsEpoch into one field cannot silently suppress the first report for Metro, Turbopack and a watching dev server; the cross-pass fresh attempt for an unstable generation, which is new logic no existing case reaches; and a file leaving the walk plus a kind swap under the new membership digest, where only creation was covered.
assertAPassIgnoresAnUndeclaredProjectFileEdit does pass against the pre-change implementation, as observed. It pins the declared-input filter rather than the digest, which is a real invariant worth its own case; the digest's twin is assertAPassIgnoresAnAppearingOutputDirectory. Its doc comment is being corrected so it stops implying otherwise.
Rejected
The epoch captured before the await. A host opening pass N+1 while this delivery awaits an in-flight generation leaves that delivery on pass N's epoch. That is correct: the delivery belongs to the pass that requested it. Only the comment claimed more than the code does, and it is being narrowed to what it actually says.
retainPassVerdict returning a pre-existing verdict without refreshing confirmedEpoch. Reachable only by two concurrent deliveries of the same generation inside one pass, where the stamp is already this pass's. Across passes the terminal branch handles it before selectOrEvict is reached.
Clean
The membership digest was checked against kind swaps, case-only renames, entries that are neither file nor directory, symlink retargets, and the intra-walk instability marker, and is not weaker than the metadata stamp it replaces for anything the walk can see. The epoch gate itself has no hole: incomplete snapshots never reach it, persistent-mode generations always re-prove, and the epoch counter cannot be reused across a reset because the same call clears the cache.
samchon
left a comment
There was a problem hiding this comment.
Individual Self-Review — 23b3bc480 and 006f518d0
Recorded together because the accepted findings interlock: the disposal fix narrowed on the wrong axis, and the harness that should have caught it models both configurations identically. Adjudication is mine.
23b3bc480 (fix(unplugin): dispose the vite generation at teardown, not at every pass) — resolves #1301
Accepted, defect
Plain vite build now has no disposal site at all. Gating the buildEnd reset on command === "serve" and replacing it with closeWatcher assumed every build-mode session ends at a Rollup watcher. It does not: Vite takes rollup.watch() only when build.watch is set, and the ordinary path is rollup() plus bundle.close(), which emits buildEnd and closeBundle and never closeWatcher. So a one-shot vite build disposes nothing, where before this commit buildEnd did. A long-lived process running repeated programmatic builds — which this repository's own suite does — accumulates one live generation and its three mutation trackers per build, and on Windows one registered watch group in the shared broker child per build.
The mistake was narrowing by command when the defect lives on a different axis: whether the host is actually watching. config.build.watch answers exactly that, it is null by default and an object under --watch, and it is the same shape as the config.server.watch read the plugin already performs two lines above for the same reason. buildEnd now disposes for a serve session or a non-watching build, and closeWatcher covers the watching build. The commit message's claim that closeBundle is uniformly unusable is also being corrected: it repeats only under --watch.
closeWatcher left the overlapping-container bookkeeping dirty. It reset the cache without clearing viteBuildOwners / viteBuildLifecycles, so after teardown the counter stays non-zero and a subsequent serve on the same instance would decrement to a non-zero value and skip the reset it owes. Now cleared with the cache.
Accepted, improvement
The Rollup and Rolldown entrypoints have no teardown either. Both disposal sites live inside the vite block, so those adapters never dispose. As observed this is bounded rather than accumulating — invalidated generations still dispose through evictGeneration, so only the live one survives teardown, which is what happened before this cycle too. Adding closeWatcher to both blocks is cheap and strictly better, so it is going in rather than being left as a known gap.
Accepted, coverage
The new cases cannot see the defect above. The harness resolves { command: "build" } with no build key, so it models vite build and vite build --watch identically and is structurally incapable of telling them apart. That is precisely why the non-watch regression was invisible. A non-watching variant is being added, asserting that buildEnd disposes there.
Also correcting the pre-existing assertViteBuildEndDisposesTheLastOverlappingCacheOwner, whose command: "serve" is now load-bearing while its doc comment still describes buildEnd disposal as unconditional, and folding the duplicated session helper into the existing one.
Rejected
Serve-then-build on one plugin instance leaking the serve generation. Real, but an embedder-only path that Vite's own restart cannot reach, and the viteCommand re-read it depends on is deliberate and documented. It also largely self-resolves under the corrected gate: a later non-watching build makes the serve container's buildEnd dispose after all.
The new cases not pinning the hook choice. They would pass if closeBundle also reset. True, but the measured trace is recorded in the source comment and the issue, and asserting "no other hook fires exactly once" is not a property a test can hold.
006f518d0 (test(unplugin): gate the transform cache across repeated build passes) — resolves #1302
Accepted, defect
The perf harness is red on this branch, including the scenario I added. Every scenario runs a warm-up build, truncates the run log, and then asserts the measured build compiled exactly once. That assertion only ever held because beginTtscTransformBuild cleared the cache — it was measuring the per-pass clear rather than gating against it. With the delivery epoch the measured build reuses the warm-up's generation and the count is zero, so scenarios A, B, C and my new G all fail. Nothing in CI runs unplugin-perf, which is a second instance of exactly the blindness #1302 is about.
Fixed by resetting the cache after the warm-up, so each scenario measures from an empty cache and its stated invariant means what it says. Scenario G then measures what it is for: warm-up, reset, then three passes that must total one compile.
"Scenarios A-C drive exactly one build lifecycle" is false, in the commit message, the harness comment, the perf README and issue #1302's body. runBuild opens a pass per invocation and measure calls it twice. The accurate statement is stronger, not weaker: the harness did drive two passes and its invariant depended on the clear, so it could never have gated against it. All four places are being corrected.
The webpack watch case can finish without the loader having re-run. It concludes at the second compilation on the strength of the compile count alone, so a compilation in which the main module's snapshot was judged valid would pass green while proving nothing. Being hardened to assert the module was actually rebuilt, so a rebuild that skipped the loader keeps waiting instead of concluding.
Accepted, documentation
Scenario G - uses a spaced hyphen where the file uses colons or em dashes; the documentation rules sanction a colon. packages/unplugin/README.md did not receive the directory-membership sentence the website page got, and that README already carries this depth, so it gets it too.
Clean
Fixture integrity holds: count-runs is reachable only through the operation switch, no existing scenario passes that operation, the default: arm still errors, it is the only case that does not mutate value so it cannot perturb an output assertion, the generated Go compiles with the imports and helpers already present, transformSource runs once per whole-project transform rather than once per file, and the run log sits under os.tmpdir() outside the walk so the counter cannot invalidate what it measures. Doc-comment shape and prose rules check out in the added documentation.
The observation that transform-project-cache.ts now has a parallel run-log instrument is noted; the duplication is justified because the webpack cases go through a different fixture builder, and a third copy would be the point to unify.
…ctly Individual Self-Review of the cycle's implementation commits found three defects they introduced, and three places where the coverage did not reach them. A pass verdict was retained for every throw out of `selectTransformedSource`, but only two of its three throw sites say anything about the generation. The third says one file has no output, which is an ordinary condition for a module the bundle reaches and the tsconfig program does not contain; retaining it rejected every later module of the pass with an error naming a file none of them asked about. Retention is now conditioned on an envelope that actually failed. The pass short-circuit was applied to both verdict kinds. An unstable generation has a recorded environment and a confirmation test its own contract runs per delivery, so caching that answer for a whole pass changed documented behaviour for nothing. The short-circuit is the pass verdict's alone now. The verdict also replaced the surfaced error's identity, so a bundler printed `TtscPassVerdictError:` where it used to print the compiler's own diagnostic heading, including for the first failing module. It carries the original error's name, stack and cause instead. The Vite disposal narrowed on the wrong axis. Vite takes Rollup's watcher only when `build.watch` is set; an ordinary build closes its bundle and never emits `closeWatcher`, so gating `buildEnd` on `command === "serve"` left a one-shot build with no disposal site at all. It now gates on whether the host is actually watching, which is the same property the neighbouring `server.watch` read answers for a dev server. `closeWatcher` clears the container bookkeeping with the cache, and Rollup and Rolldown gain the teardown site they never had. The perf harness was red on this branch, including the scenario this cycle added: every scenario truncates its run log after a warm-up build, so its `plugin runs == 1` was satisfied by the per-pass clear rather than despite it, and reads 0 once the generation survives. Each warm-up now discards its generation explicitly, and the claim that scenarios A-C drive one lifecycle is corrected in the harness, its README and the issue: they drive two, which is precisely why they could never have gated this. New coverage for what the reviews reached that the tests did not: an out-of-program module inside a pass, a persistent host's first diagnostic report, a file leaving the walk and an entry changing kind, and an ordinary non-watching `vite build`. The webpack watch case now waits for a compilation that actually re-ran the loader, since a compilation that skipped it costs no compile under the old code either. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M79urGdrmmPEehBgQHhvY2
…tput The review of the previous correction found two more defects it had left, both in code this cycle touched. A `"success"` envelope that simply has no output for the module asking was still evicting the generation. That is a fact about one file, not about the compile: an ordinary condition for a bundle that reaches a module the tsconfig program does not contain, which is what a `vitest --run` suite with tests outside `include` does on every run. Evicting made every later module recompile the whole project to reach the same answer, which is the cost #1303 is about, arriving by the other door. The error is reported and the generation, which compiled perfectly well for everything else, stays. The case now pins that rather than only pinning that later modules are served. `closeWatcher` zeroed the container counter but could not clear the `WeakSet` beside it, so a watcher closed mid-rebuild left a container registered whose later `buildEnd` decremented past zero. After that the `buildEnd` disposal could never fire again for that plugin instance. The set is replaced along with the counter. The Rollup and Rolldown blocks gained `closeWatcher` in the previous commit and so had exactly the hole that commit closed for Vite: a one-shot build with no disposal site. `this.meta.watchMode` separates the two cases there the way `build.watch` does for Vite. Also: `confirmedEpoch` moves onto the pass verdict that owns it, so the narrowing in `replaysTerminalGeneration` is carried by the type rather than a comment; the verdict keeps `cause` for a non-`Error` throw too; and three doc sites that still described the superseded behaviour are corrected, including the package README and the website page. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M79urGdrmmPEehBgQHhvY2
samchon
left a comment
There was a problem hiding this comment.
Individual Self-Review — a7cc4589a (fix(unplugin): bound the pass verdict and the disposal boundary correctly)
Read-only advisory review over the parent-to-commit diff; adjudication is mine. Two more defects found in code this cycle touched, both corrected in 34318545c.
Accepted, defect
A valid generation was still evicted when one file had no output. Narrowing retention to a failed envelope left the eviction untouched, so a "success" generation was still discarded because one delivered module was outside the tsconfig program. The reviewer's scenario is concrete and common: a vitest --run suite, which takes a pass-bearing lifecycle, whose tests live outside include. Every such delivery evicts, so the session pays a whole-project compile per out-of-program module. That is the cost #1303 is about, arriving through the other door.
Not a regression against master, which did the same, but this commit is the one that decided what a "success" envelope means here and it decided only half of it. The generation is now left exactly where it is: the error names the file, and nothing about the compile failed. assertAnOutOfProgramModuleDoesNotFailThePass was also only asserting that later modules are served, which passes either way, so it now pins generation identity across the failing delivery.
closeWatcher could strand the container counter below zero. Zeroing viteBuildLifecycles while viteBuildOwners still held a live PluginContext meant that container's later buildEnd decremented to -1, after which viteBuildLifecycles === 0 was never true again and the buildEnd disposal was permanently dead for that plugin instance. Reachable by closing a vite build --watch watcher mid-rebuild, and the new non-watching case creates exactly that state without observing it. The WeakSet is now replaced along with the counter, which resynchronises both.
Rollup and Rolldown had the hole this cycle had just closed for Vite. They received closeWatcher and no buildEnd, so rollup -c had no disposal site at all — the identical shape, and the identical argument. this.meta.watchMode is on the Rollup plugin context and separates the two cases there the way build.watch does for Vite.
Accepted, smaller
confirmedEpoch lived on the base class but was written only by the pass verdict, so the narrowing in replaysTerminalGeneration was carried by a comment rather than by the type. It moves onto the subclass that owns it. cause is now attached for a non-Error throw as well. The claim that the surfaced output is "byte-identical" is softened to what actually holds: the message and stack are preserved, which is what a bundler reports.
Three doc sites still described the superseded behaviour: the terminal base class ("both kinds are replayed for the rest of a delivery epoch", which is exactly what this commit removed), selectOrEvict's lead sentence, and one sentence carried by both the package README and the website page. All corrected.
Also taken: the Adapter member ordering and the adapter. / harness.adapter. inconsistency in the perf harness, and a clause explaining why build.watch is compared loosely while server.watch beside it is compared strictly (their Vite defaults differ).
Rejected
webpack, Rspack, esbuild and Farm still have no disposal site. True, and unchanged by this cycle: none of them ever had one, and their buildStart cleared nothing that a one-shot build would have disposed anyway. Their generations are bounded at one per cache key, held by non-persistent watchers and an unref'd broker, so nothing keeps a process alive. Adding teardown hooks for four more hosts is a separate improvement rather than part of the accepted issue set, and it is recorded in the campaign ledger rather than folded in here.
An unstable verdict is re-confirmed per delivery again. Correct, and deliberate: that is master's behaviour, restored on purpose because the pass short-circuit changed it for no measured gain.
Clean
The reviewer verified, and I agree: the envelope predicate is exhaustive over the three variants; replaysTerminalGeneration is correct for all four kind/epoch combinations, and a pass verdict with an undefined epoch is unreachable and would fail safe; copying name cannot affect instanceof dispatch, and the verdict never reaches awaitOrEvict; every perf-harness reset lands in the right place, and leaving the serve scenarios D-F untouched is correct because they deliberately open no pass; all four added cases are real negative twins with conforming doc comments.
…mit claimed The previous commit's message described four changes to `core/index.ts` and `core/transform.ts` that its diff does not contain. The script that applied them aborted on a stale anchor after building the edits in memory and before writing either file, so only the two `transform.ts` edits that were applied separately afterwards actually landed. Overall Self-Review caught the gap by reading the tree rather than the message. What lands here is that remainder, unchanged in intent: `closeWatcher` replaces the container owner set rather than only zeroing the counter beside it. A watcher closed mid-rebuild leaves a container registered whose later `buildEnd` decrements a counter that is already zero, stranding it below zero, after which the `buildEnd` disposal can never fire again for that plugin instance. The Rollup and Rolldown blocks get a `buildEnd` gated on `this.meta.watchMode`, so a one-shot build there has a disposal site. They had received `closeWatcher` alone, which is exactly the hole that was closed for `vite build` one commit earlier. `confirmedEpoch` moves onto the pass verdict that owns it, so the narrowing in `replaysTerminalGeneration` is carried by the type instead of a comment, and the verdict keeps `cause` for a non-`Error` throw. The claim that its output is "byte-identical" is softened to what holds: the message and stack are preserved, which is what a bundler reports. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M79urGdrmmPEehBgQHhvY2
Neither half of the Rollup and Rolldown boundary was exercised, including the `this.meta.watchMode` gate that separates them, and nothing pinned the `closeWatcher` counter fix at all. Both are load-bearing: the gate is what keeps a watching rebuild from disposing, which is #1301, and the counter is what lets `buildEnd` ever dispose again after a teardown. The Rollup case drives both positions of the gate and the teardown hook. The Vite case tears down with a pass still open, lets that pass's own `buildEnd` land against the already-zeroed counter, and then resolves the same instance as an ordinary build, because a watching build never disposes at `buildEnd` and the stranded counter is invisible until a non-watching session follows. Against the previous code the counter sits below zero and that session never disposes. Also from the same review: the relief claimed for a module with no output is narrower than stated, since one outside the project root has no recorded state to compare and still replaces the generation, so both documents say so; a stray clause left by an earlier documentation edit is repaired; the terminal base class is `abstract`, which makes the unreachable arm in `replaysTerminalGeneration` provable rather than incidental; and `measureGraphBuild` uses one spelling for its adapter throughout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M79urGdrmmPEehBgQHhvY2
samchon
left a comment
There was a problem hiding this comment.
Individual Self-Review — 34318545c (fix(unplugin): leave a valid generation alone when one file has no output)
Read-only advisory review over the parent-to-commit diff; adjudication is mine.
Accepted, and the most important finding of the cycle
The commit's message described four changes its diff does not contain. The patch script that produced them aborted on a stale anchor after building every edit in memory and before writing either file, so only the two transform.ts edits applied separately afterwards actually landed. The core/index.ts work and three transform.ts cleanups were left as uncommitted working-tree edits while the message claimed them in past tense.
Overall Self-Review had reached the same conclusion independently, by reading the committed tree rather than the message, and the remainder landed in df81bb907 before this review returned. Recording it here because of what it says about the gates: every test passed, the typechecker passed, and CI passed, because the working tree was correct the whole time. Nothing that inspects behaviour could have caught this. It is the clearest argument in this cycle for Overall Self-Review being a separate gate from the per-commit reviews rather than their sum.
Accepted, defect
No test pinned the closeWatcher counter fix. Correct, and my first attempt at one was not a guard either: it called close() before the assertion, and close() disposes regardless of the counter's health. The consequence is only observable once a non-watching session follows, because buildEnd deliberately never disposes for a watching build. The case now tears down with a pass still open, lets that pass's own buildEnd land against the already-zeroed counter, then resolves the same instance as an ordinary build. Against the previous code the counter sits at -1 and that session never disposes.
Neither half of the Rollup and Rolldown boundary was exercised, including the this.meta.watchMode gate that separates them. Given the Vite half has five scenarios, the asymmetry was indefensible. The new case drives the gate in both positions and the teardown hook.
Accepted, doc precision
The relief is narrower than the commit message and both documents claimed. It applies to a module inside the project walk. One outside the project root has no entry in sourceHashes, inputHashes or externalInputHashes, so matchesCachedSource rejects and the generation is replaced before selectOrEvict is ever reached: one whole-project compile per such delivery, unchanged. Both documents now say so.
Fixing that in code was considered and rejected for this cycle. The distinguishing signal is missing: expected === undefined means either "not in this generation's universe" or "created since the generation", and the second case must recompile or a newly created module would never be compiled under persistent validation. Telling them apart needs state the generation does not currently carry, so it is recorded for the next discovery round rather than guessed at here.
Reading the rendered paragraph for that fix also surfaced a stray clause left by an earlier documentation edit of mine, repaired in the same commit.
Accepted, smaller
The terminal base class is abstract, which makes the unreachable arm in replaysTerminalGeneration provable rather than incidental. measureGraphBuild now uses one spelling for its adapter throughout. The campaign ledger, which had fallen two commits behind, is current.
Rejected
Pre-existing em-dashes in the rewritten documentation paragraphs. The rule is real, but every em-dash flagged sits in prose this campaign did not author; my own added sentences contain none. The whole section is a single Markdown source line, so "the line I rewrote" is not a useful boundary for editing someone else's sentences. A repo-wide sweep is its own task.
No disposal site for webpack, Rspack, esbuild, Farm, Turbopack, Next or Bun. Same class, unchanged by this cycle, and none of them ever had one. webpack and Rspack route buildEnd through hooks.emit, which repeats per rebuild, so a hook there would recreate #1301 unless gated on compiler.watchMode. Recorded in the ledger for the next cycle rather than folded into an accepted issue set that does not name it.
Clean
Confirmed independently and worth stating: the "success" relief is sound for every state examined, including a file added to the program between passes and the persistent path; closeWatcher cannot fire under serve, so the overlapping-container property survives; this.meta.watchMode is present in the resolved rollup and rolldown versions; the three adapter blocks can never both apply, so #1301 cannot return through the Rollup site; no live reference to the removed base-class field survives anywhere in the tree; and no existing case, in either suite, is invalidated by the eviction change.
… blocks Overall Self-Review of the whole base-to-head diff. Two notes, no behaviour. The Rollup and Rolldown disposal sites live inside their own adapter blocks, and the reason is worth stating where the next reader will be tempted to simplify it: unplugin forwards a top-level `buildEnd` to esbuild's `onEnd` and to webpack's and Rspack's `hooks.emit`, both of which repeat per rebuild, so hoisting the hook out of the blocks would make those hosts discard a valid generation on every edit. That is #1300 again, arriving through a hook that looks like a tidy-up. The mid-pass teardown case's step list also skipped the mechanism that makes it observable, which is resolving the same instance as an ordinary build, since a watching build never disposes at `buildEnd`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M79urGdrmmPEehBgQHhvY2
…eneration Overall Self-Review found two items its own earlier rounds had adjudicated as accepted and then never applied. Both land here. The cross-pass branch for an unstable generation was new logic no case reached. It is the half of the terminal-verdict rule that is easy to lose: a failed compile is the host's answer about inputs it read, so a new pass replays it, while an unstable generation is the adapter losing a race that a later attempt may win, so a new pass has to try again. That fresh attempt is what the per-pass cache clear used to provide for free. Removing the branch leaves every other case green; with this one it fails on "a new pass must grant an unstable generation a fresh attempt", which was confirmed by deleting the branch and watching the case go red. The epoch is also read once per loop iteration before the cache is consulted, which is narrower than the comment claimed. A delivery belongs to the pass that was current when it started examining the generation; a pass opened while it awaits an in-flight compile is picked up by the next iteration, the one that runs when the awaited entry turns out to have been superseded. Stamping the older pass is the conservative direction, since it can only cause an extra proof, never a skipped one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M79urGdrmmPEehBgQHhvY2
samchon
left a comment
There was a problem hiding this comment.
Overall Self-Review — rounds 1 to 4 over the base-to-head diff
Solo, whole-surface, fresh each time. Each round below produced findings, so each was followed by a repair pass and a new complete round rather than a recheck of the fix.
Round 1 — the finding the other gates structurally could not make
34318545c's message described four changes to two files that its diff does not contain. The patch script that produced them aborted on a stale anchor after building every edit in memory and before writing either file, so only the transform.ts edits applied separately afterwards landed. The core/index.ts work and three transform.ts cleanups sat as uncommitted working-tree edits while the message claimed them in past tense.
Every behavioural gate passed through this: the tests, the typechecker and CI all ran against a working tree that was correct. Nothing that inspects behaviour could have caught it, because the behaviour was right. Only reading the committed tree against what was claimed about it exposed the gap. Landed in df81bb907.
Round 2 — two guards for code with no coverage
Neither half of the Rollup and Rolldown disposal boundary was exercised, including the this.meta.watchMode gate between them, and nothing pinned the closeWatcher counter fix at all.
My first attempt at the second guard was not one: it called close() before asserting, and close() disposes regardless of the counter's health. The consequence only becomes observable once a non-watching session follows, because buildEnd deliberately never disposes for a watching build. The case now tears down with a pass still open, lets that pass's own buildEnd land against the already-zeroed counter, then resolves the same instance as an ordinary build. Landed in 7d06978fc, together with the doc-precision fix below and a stray clause an earlier documentation edit of mine had left behind.
The relief claimed for a module with no output was also narrower than stated: it holds for a module the project walk covers, while one outside the project root has no recorded state to compare against and still replaces the generation. Both documents say so now. Fixing that in code was considered and rejected: expected === undefined means either "not in this generation's universe" or "created since the generation", and the second must recompile or a newly created module would never be compiled under persistent validation. Telling them apart needs state the generation does not carry, so it is recorded for the next cycle rather than guessed at.
Round 3 — a hazard worth naming where it will be tempting to simplify
The Rollup and Rolldown disposal lives inside adapter blocks, and the reason belongs next to it: unplugin forwards a top-level buildEnd to esbuild's onEnd and to webpack's and Rspack's hooks.emit, both of which repeat per rebuild, so hoisting the hook out of the blocks would make those hosts discard a valid generation on every edit. That is #1300 again, arriving through a hook that looks like a tidy-up. Landed in cc9019e9e.
This round also verified, with no findings: the type !== "success" guard still precedes the report-once guard, so a diagnostic-free generation cannot consume the report slot; all new case files export exactly one test_<snake_case> matching their filename and no new internal assertion is orphaned; the temporary instrumentation used to find the directory-membership defect is entirely gone; and every em-dash left in the changed documentation sits in prose this campaign did not author.
Round 4 — the reviews' own record, checked against the tree
Two items these reviews had adjudicated as accepted and then never applied. The epoch-comment narrowing, and a test for the cross-pass fresh attempt an unstable generation gets, which was new logic no case reached.
That branch is now pinned, and verified by mutation: deleting it and rebuilding makes the case fail on "a new pass must grant an unstable generation a fresh attempt", while every other case stays green. Landed in 71fc61c55.
This is the same failure shape as round 1 one level up. The record said one thing and the tree said another, and only comparing them surfaced it.
Evidence produced across the rounds
The perf harness runs clean end to end, and scenario G is the direct measurement of the defect this cycle exists to fix:
N= 25 passes=3 pluginRuns= 1 perPassMs=2514/40/35
N= 50 passes=3 pluginRuns= 1 perPassMs=2506/54/59
One whole-project compile across three passes, and an unchanged rebuild at 40 ms against a 2,510 ms first pass. That corroborates the 1,700 ms to 22 ms the real native-envelope probe measured independently.
Ten of the fourteen new cases were proven to fail either against a rebuilt origin/master adapter or against deletion of the exact branch they guard. The remaining four guard behaviour master did not have, and each fails when its own new behaviour is removed.
Open, recorded rather than folded in
A module the bundle reaches outside the project root still replaces the generation, for the reason above. No disposal site exists for webpack, Rspack, esbuild, Farm, Turbopack, Next or Bun; none ever had one, and webpack and Rspack route buildEnd through hooks.emit, so a hook there would have to gate on compiler.watchMode or recreate #1300. TtscCompiler.transform() is synchronous, so a whole-project compile blocks the host event loop for its duration, which is owned by packages/ttsc rather than the adapter.
samchon
left a comment
There was a problem hiding this comment.
Overall Self-Review — round 5, clean
Fresh complete round over the whole base-to-head diff at 71fc61c55, 39 files. Nothing survived verification, so this is the round the merge gate needs. Everything below was re-inspected in this round rather than carried over from an earlier one.
Core decision points, re-verified
reportSuccessDiagnosticsstill evaluates thetype !== "success" || diagnostics === undefinedguard before the report-once guard, so a generation carrying no diagnostics cannot consume the report slot and silence a later one.replaysTerminalGenerationdispatches pass verdict first, unstable generation second, and returnsfalsefor the arm that no producer can reach. The two kinds cannot borrow each other's evidence.selectOrEvictretains first and evicts only a non-"success"envelope, so a generation that compiled fine and merely has no output for one file is neither retained as a verdict nor destroyed.beginTtscTransformBuildsets the epoch and clears nothing;resetTtscTransformCacheclears the cache and deletes the epoch in the same call, which is why an epoch number can never be reused by a surviving generation.
Surface checks
- Public API is unchanged. The exported names at head are identical to master's; only
beginTtscTransformBuild's documented semantics moved, and both READMEs and the website page say so.@ttsc/metroconsumes only unchanged entry points, and thetypechecklane covers its build against the new declarations. - File inventory is clean. 39 files, all inside the declared campaign scope, no strays, working tree clean.
- Every changed test file parses, none contains a NUL byte or a string literal broken across a real newline. That class of artifact bit this branch twice through patch scripting, so it is now checked mechanically rather than by eye.
- Coverage maps to every accepted issue: 8 cases for #1300, 4 for #1301, 2 end-to-end plus perf scenario G for #1302, 6 for #1303, 2 for #1304, 1 for #1305.
- The Turbopack docstring's em-dashes are in a TypeScript comment, not a Markdown document, so the documentation voice rule does not reach them and they match the file's existing style.
Evidence standing behind the round
Ten of the fourteen new cases are proven to fail either against a rebuilt origin/master adapter or against deletion of the exact branch they guard. The remaining four guard behaviour master did not have, and each fails when its own new behaviour is removed. The perf harness runs clean end to end, with scenario G measuring one compile across three passes and an unchanged rebuild at 40 ms against a 2,510 ms first pass.
Not resolved here, recorded rather than dropped
A module the bundle reaches outside the project root still replaces the generation, because expected === undefined cannot currently distinguish "not in this generation's universe" from "created since the generation", and the second must recompile. No disposal site exists for webpack, Rspack, esbuild, Farm, Turbopack, Next or Bun; none ever had one, and webpack and Rspack route buildEnd through hooks.emit, so a hook there would have to gate on compiler.watchMode or recreate #1300. TtscCompiler.transform() is synchronous, so a whole-project compile blocks the host event loop for its duration, which packages/ttsc owns rather than the adapter.
One issue-campaign cycle over
@ttsc/unplugin's cache lifecycle. This pull request owns the complete accepted cycle; verification is pending until the implementation lands.Cycle scope
buildStartclears the transform cache on every rebuild, so each edit pays for a full project transformvite build --watchdisposes the transform generation at the end of every rebuildDiscovery ran four complete full-scope rounds against
e5bc20628; the fourth produced no surviving candidate, which froze this set.Intent
The adapter has two cache modes and needs three. Two orthogonal facts are conflated in one: a delivery epoch (one bundler pass, inside which each module is requested at most once, so its first delivery may be settled against the state the pass started from) and generation validity (whether the compiled whole-project result still matches the filesystem).
beginTtscTransformBuildasserts the first by destroying the second, which is the root cause of #1300 and, through the same lifecycle model, of #1301 and #1304.Naming the missing fact is the change: a cache in epoch mode carries a monotonic counter,
beginTtscTransformBuildincrements it without clearing, and a generation records the epoch it was compiled in. The pass's first delivery proves the whole generation once; every later first delivery in that pass stays constant-time. A pass that changed nothing costs one whole-generation proof instead of one whole-project compile, and a pass that changed a project input costs exactly one compile.#1303 is the same distinction one level down:
awaitOrEvictalready separates a deterministic verdict from a transient fault, andselectOrEvictdoes not. #1305 is filter-contract drift in the one adapter that re-implements the shared predicate instead of importing it.Internal order
The DAG controls edit order inside this pull request, not pull-request count.
Verification
Pending. Ordinary CI on this pull request plus a clean Overall Self-Review are the acceptance gates; the
bundler defenseslane (@ttsc/test-unplugin+@ttsc/test-metro) is the one that owns this surface. Individual Self-Review results and Overall Self-Review rounds will be recorded here as formal reviews.Closing keywords are deliberately absent from this claim body: it is written before any code exists, so the merge's closing set comes from the union of the commit closing lines instead.
🤖 Generated with Claude Code
https://claude.ai/code/session_01M79urGdrmmPEehBgQHhvY2