Skip to content

fix(unplugin): exclude the bundler's output directory from discovery - #411

Merged
codecaaron merged 8 commits into
mainfrom
wc/unplugin-output-exclude
Oct 12, 2026
Merged

codecaaron merged 8 commits into
mainfrom
wc/unplugin-output-exclude

Conversation

@codecaaron

@codecaaron codecaaron commented Oct 11, 2026 •

Copy link
Copy Markdown
Owner

Gut-check item B1, ticket 12. Over-emission, not flagged first (board-ui's ruling, recorded on ticket 12); a full review all the same.

Problem

unplugin never set the Session's structuralExclude. Its output directory was excluded only through the default dist pattern, which a user exclude list replaces. So a project with its own exclude list, or with an output directory not named dist, had its previous build's output analysed on the next build.

On main, e2e/rollup-app (its exclude: ['fixtures/**']), built twice with a .js entry name: the second build analyses the bundle. Its createElement(target) turns whole-component removal off, and animus.css grows from 7,281 to 38,810 bytes with no source change. The build warns twice, naming the bundle. The lane's own bundle.mjs name hid it, because .mjs isn't a default extension.

Subset check (board-ui's condition). The first build's CSS is a subset of the second's with identical values: all 56 of its leaf rules are in the second build's 520, with identical declarations and the same relative order. A one-value mutation of the first sheet is caught by the same check. Run-and-discard; the script is not retained.

Fix

  • webpack, rspack, esbuild, Vite: they name the output directory before analysis (output.path, read per run after webpack applies its defaults; outdir or dirname(outfile); build.outDir). It goes into structuralExclude through the shared outputExclusion, as the Vite and Next plugins already do.
  • Rollup, Rolldown: they name it only at renderStart, after analysis. Later builds in the same process exclude it. A build whose analysis already read a file there warns, naming the files and the directory to add to exclude; under strict outside a watch, it fails. There is no cross-process record, by root's decision: a new process over stale output reports instead of excluding.
  • README: exclude was documented as merging with the defaults; a list replaces dist, .test. and .spec..

Proof

  • Red first: eefe7f2 adds e2e/rollup-app/scripts/assert-own-output.mjs to the lane's verify:assert. It pins the contract that a build never silently analyses its own output. On main it fails 4 of 5 checks: the second new build passes silently, and the watch rebuild's sheet grows from 7,281 to 38,810 bytes. On fbbe5fb it passes 5 of 5.
  • Run-and-discard host probe: two builds each into a non-dist directory, with the lane's exclude list.
    • On main: esbuild 7,281 → 38,810; Rolldown 7,281 → 38,810; unplugin's Vite host 7,281 → 7,281 (its output doesn't move the CSS, but it was read).
    • On the fix: esbuild 7,281 → 7,281; Vite 7,281 → 7,281; Rolldown's second build (a new plugin instance) fails: [animus] this build analysed its own earlier output: own-rolldown/bundle.js. Add 'own-rolldown' to the plugin's \exclude` option.`
  • macOS tiers green on the fix: build, build:ts, verify:compile, lint, fmt, unit:ts, canary, parity, integration, rollup-app, next-app, next16-app, Vite dependents.

Limits

  • webpack and rspack aren't installed in the repo, so their side is checked by types and by the model's unit rows only.
  • A new Rollup or Rolldown process over stale output reports and, under strict, fails; it doesn't exclude.

Review round (dx-codex at fbbe5fb), folded into this PR

One model of where a bundler writes, extract/pipeline/output-targets.ts: the directories it fills and the files it writes by exact name. The Session's structural exclusion and the Rollup family's render check both read it, and so does vite-plugin's own exclusion (config.ts), which had the same narrow build.outDir reader.

  • P1-1, Vite: every environment's outDir, and each rolldownOptions/rollupOptions output's dir (which environments inherit), as Vite's getResolvedOutDirs reads them. Applies to vite-plugin and unplugin's Vite host.
  • P1-2, webpack: output.path is cut at its first template placeholder segment (out/[fullhash] → out).
  • P1-3, root-level output files: output.file and outfile are excluded by their own name, at the root too. A directory that is the root excludes nothing.
  • P2, overlap: a file target the analysis read is reported as earlier output. Files inside an output directory are reported as earlier output or as sources beside the output, never a source as the build's own output.

Red first (b63ff26, against #411's head):

Green:

  • 36da902 (fix) and 33b4aa2 (model rows: webpack placeholder cut, a root-level file excluded by name, file vs directory classification).
  • macOS tiers all green on the round before the merge with main, parity included. After the merge: rollup-app verify, lint, fmt, tsc, and 477 unit tests (Session, vite-plugin, unplugin, core-options).

Merge note: main was merged in (no force-push). The only conflict was the host state's served field from #408, kept alongside analyzedFiles.

🤖 Generated with Claude Code

codecaaron and others added 8 commits October 11, 2026 18:34
The lane's exclude list replaces the default `dist` exclusion; with a
`.js` entry name the bundle is a discovered source. Two new builds: the
second must fail under strict, naming the file and the exclude fix. One
watch: its rebuild must emit the first build's stylesheet and read no
output. Red on main: the second build passes and the sheet grows from
7,281 to 38,810 bytes.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
unplugin never set the Session's structural exclusion, so an `exclude`
list (which replaces the default `dist`) or a non-`dist` output directory
let a build analyse the previous build's output.

webpack, rspack, esbuild and Vite name the output directory before
analysis; it is excluded exactly. Rollup and Rolldown name it only at
renderStart: later builds in the same process exclude it, and a build
whose analysis already read a file there warns, naming the files and the
directory to add to `exclude`, and fails under strict outside a watch.

The README stated that `exclude` merges with the defaults; a list
replaces `dist`, `.test.` and `.spec.`.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
…clude

# Conflicts:
#	packages/unplugin/src/core.ts
…tself

dx-codex's #411 review round. vite-plugin's exclusions row builds twice:
an `ssr` environment with its own `outDir`, and `rolldownOptions` outputs
`esm` and `cjs`; none of their files is analysed. rollup-app's own-output
leg writes `output.file` inside `src/`: the first build succeeds, and a
new build that read the bundle fails under strict naming that file alone.

Red on c8cafcaf: the per-environment row analyses `server-out/entry.js`,
and the first build beside sources fails naming every source in `src/`.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
dx-codex's #411 review. Output targets are the directories a bundler
fills and the files it writes by exact name (extract/pipeline
output-targets.ts); the Session's structural exclusion and the Rollup
family's render check both read them, and vite-plugin's exclusion now
does too instead of its own narrower reader.

- Vite: every environment's outDir, and each rolldownOptions/rollupOptions
  output's dir, as Vite's getResolvedOutDirs reads them (vite-plugin and
  unplugin's Vite host).
- webpack: output.path up to its first template placeholder segment.
- An output file (Rollup output.file, esbuild outfile) is excluded by its
  own name, at the root too, and never stands for its directory.
- The render check reports a file target the analysis read as earlier
  output, and files inside an output directory as earlier output or
  sources beside the output, never a source as the build's own output.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
webpack writes under its path's fixed prefix; an output file at the root
is excluded by its own name while a root directory excludes nothing; an
output file beside sources names itself alone, and a directory over
sources is reported as such.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
…clude

# Conflicts:
#	packages/extract/pipeline/index.ts
…clude

# Conflicts:
#	packages/unplugin/src/core.ts
#	packages/vite-plugin/src/config.ts
@codecaaron
codecaaron merged commit 4afa1fa into main Oct 12, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant