Skip to content

Full import/export handling, query variant modules, and raw TypeScript - #68

Draft
nstepien wants to merge 20 commits into
mainfrom
full-import-export-support
Draft

nstepien wants to merge 20 commits into
mainfrom
full-import-export-support

Conversation

@nstepien

@nstepien nstepien commented Jun 9, 2026 •

Copy link
Copy Markdown
Owner

Summary

Removes the last big TODO: interpolations resolve across modules through every ES module import and export form. Query variants and modules without a file get their own identity, and raw TypeScript is handled. The static resolution model this builds on (const-only, lazily resolved values, scoping) landed in main through #79, which started out as the first slice of this PR.

Supported import and export forms

  • Default and namespace imports (import d from, import * as ns from), including ${ns.member} and nested ${ns.inner.member}
  • Re-exports: export { x } from, export { default as x } from, export { x as default } from, export * from (except default), export * as ns from, and chains through barrel files. This includes the export of an imported binding, like import d from 'mod'; export { d };, which re-exports it, as in the spec
  • export default of css templates and of static expressions (literals, signed numbers, and their parenthesized and type-asserted forms)
  • Type-only imports and exports are ignored in all their forms
  • Values imported with a query resolve from that variant module's own code: ?raw is the file's text, and ?url its URL

Resolution semantics

  • An export resolves to the binding it comes from, like ECMAScript's ResolveExport, and that binding is evaluated like main's const bindings: on demand, cached for the transform
  • Explicit exports take precedence over export *, even when their value is not static
  • A name that several export * provide through different bindings is ambiguous and not resolved, and the ambiguity propagates through further hops. Two paths to the same binding are fine
  • export * never provides default
  • Each import is resolved and loaded once per transform, and external modules are skipped instead of loaded
  • The stylesheet a value brings along is the one of the module that declares it, so probing export * sources that do not provide a name never imports theirs

Raw TypeScript (plain Rolldown): an enum binds its name, and ambient enums bind nothing. TypeScript namespaces are not supported, beyond the scope of their own that main gives them (#130).

Module identity: a module's identity is its id without Vite's marker queries (?v=, ?t=, …). Query variants (?raw, ?url, an HTML page's inline <script type="module"> served as index.html?html-proxy&index=N.js, framework sub-modules) keep their own parse, class names, class hashes, and stylesheet. On main, a ?raw request of a module replaces the module's parse. watchChange forgets the variants of an edited file too. Modules that no file holds, like virtual modules and query variants, are read from the module graph, or in Vite's dev server, which does not keep the code of the modules loaded through this.load(), through their load hooks.

Output: stylesheet imports are injected after a hashbang and the directive prologue ('use client'), instead of before them, which is a parse error on main.

Behavior changes vs main

  • Namespace imports, export … from, export *, and export * as ns resolve, with ESM precedence and ambiguity
  • Parentheses and TypeScript type assertions are ignored, like in 'blue' as const
  • Type-only imports and exports bind and export nothing
  • External modules are skipped, and modules that no file holds are read from the module graph
  • An unresolved member expression, like ${theme.color}, warns with UNRESOLVED_INTERPOLATION and its path, instead of COMPLEX_INTERPOLATION
  • Query variants are modules of their own
  • Stylesheet imports follow the hashbang and the directive prologue

Merging main (2026-10-17)

main solved several of this PR's problems differently since August, so the merge ported the rest of this branch onto main's design instead of keeping both:

Dropped with them:

  • The guard against inlining the class names of templates whose extraction failed. It relied on the imported modules' transforms, which Vite's dev server does not run, and the runtime css call of such a template still throws
  • Per-declaration stylesheet dependencies, as dependencies are collected per transform like on main
  • UNREADABLE_MODULE, as a module whose code cannot be read now exports nothing, and UNPARSEABLE_MODULE, as modules are parsed as the language Rolldown assigned them

Known limitations

  • The css tag must be a named import from 'ecij'. A namespace import or a re-export of the tag leaves its templates untransformed
  • A wait cycle closed through another plugin's this.load cannot be detected and would hang the build

Merging main (2026-11-04)

main reshaped the code this PR builds on since the previous merge, so the merge ports it onto main's design again:

The inline snapshots of this PR's tests follow Rolldown's new module comments (// path instead of //#region path and //#endregion), with no other change. Vite builds of react-data-grid's src and of a token-heavy project are byte-for-byte identical to main's, source maps included.

Merging main (2026-11-07)

main reworked the import handling this PR builds on, so the merge ports it onto main's design once more:

main's import-forms test now extracts the namespace member and the export … from re-export it skipped. New fixture cases cover the export of a namespace import and a member of a local object. Vite builds of react-data-grid's src and of two token-heavy projects are byte-for-byte identical to main's, source maps included. The 400-component token project builds in 182 ms against 172 ms on main (+6%), as each imported value goes through resolveExport.

Test plan

  • 41 tests: main's, this PR's tests of the kept features, and a Vite dev server test of a virtual module and a ?raw variant. The kept tests cover the default, namespace, and re-export matrix, barrels with explicit-over-star precedence, namespace edge cases, a self-referencing barrel, external modules, query variants, explicit exports shadowing export *, ambiguous names, raw TypeScript through plain Rolldown, concurrent siblings, failed export * probes, an HTML entry's inline script, and the prologue
  • The CSS and warnings of every kept test match this PR's previous snapshots, and only the class names changed format (IIFEs)
  • New cases cover a default-exported function, direct and re-exported imports of external modules, and a member of a value that is not a namespace
  • The tests of the dropped behavior, and the cycle test main already has, are removed. basic.input.ts is restored for main's inlineConst tests, and this PR's barrel.ts is now barrel-index.ts
  • node --run test (locally and with CI=true against dist/), lint, typecheck, format:check, and build pass. src/index.ts statement coverage is 99.7%, and its one uncovered line (TypeScript parameter properties) is uncovered on main too
  • Vite builds of scratch projects: a const cycle across two modules and a re-export cycle terminate unresolved, a chain of aliases across three modules resolves with the stylesheets in dependency order, modules interpolating each other's aliased class names build, and a binding exported under two names resolves under both

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Documentation

    • Added "Limitations" section describing css tag constraints and interpolation resolution requirements.
  • New Features

    • Enhanced ESM module resolution with improved namespace, default export, and re-export chain handling.
    • Added cycle detection to prevent deadlock during concurrent module transforms.
    • Better static resolution of string and number interpolations in CSS templates.
  • Bug Fixes

    • Improved handling of complex import/export patterns and edge cases.

Resolve interpolation values across modules through every ES module
import/export form:

- default and namespace imports (`import d from`, `import * as ns from`),
  including `${ns.member}` and nested `${ns.inner.member}` access
- re-exports (`export { x } from`, default-as-name, name-as-default),
  `export * from` aggregations, `export * as ns from`, and transitive
  chains through barrel files
- `export default` of css tagged templates and static expressions
  (literals, signed numbers, parenthesized/TS-assertion-wrapped forms)
- type-only imports/exports are ignored (statement-level and inline
  modifiers, including `export type * from`)
- aliased tags (`import { css as styled } from 'ecij'`), with a scope
  check so shadowed bindings are not treated as the tag

Resolution follows ESM semantics: explicit exports shadow `export *`
sources even when not statically resolvable, ambiguous star exports are
not resolved (warn instead of picking a source), and `export *` never
forwards the default export.

Hardening that came out of review:

- a pending-load wait graph prevents build deadlocks on cyclic and
  self-referencing barrels while still awaiting concurrent siblings
- external modules in re-export chains are skipped instead of loaded
- class names from skipped extractions no longer leak into consumers
  (cross-module and same-file, with forward references still resolving)
- stylesheet dependencies are only recorded for successful resolutions,
  so probed-but-failing star sources no longer drag dead CSS in
- watch mode: traversed dependencies are registered via addWatchFile and
  evicted from caches on watchChange

Tested with the Vite pipeline and with plain rolldown (which hands raw
TypeScript to the transform), 12 -> 24 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 9, 2026 •

Copy link
Copy Markdown

Review Change Stack

Walkthrough

This PR substantially refactors ecij's CSS extraction pipeline to support rich ESM module resolution. The core changes introduce richer type models for tracking imports and exports, helpers for static expression evaluation and cycle detection, and a comprehensive re-export resolution pipeline that follows export {x} from, export * from, and export * as ns chains while respecting export precedence. Interpolation extraction is rewritten to defer same-file forward references until later and emit distinct warning codes for unresolved vs complex interpolations. The PR includes 40+ test fixtures and extensive test cases validating all import/export patterns, cycles, type-only imports, and edge cases.

Changes

ESM Import/Export Resolver

Layer / File(s) Summary
Core type model and expression evaluation helpers
src/index.ts
New ImportedIdentifier and ExportRecord types distinguish import/export kinds; stripQuery, unwrapExpression, resolveStaticExpression, and flattenMemberExpressionPath helpers handle module ID normalization, AST simplification, and static literal evaluation; deadlock-detection bookkeeping (pendingLoads cache) is added.
Import and export data collection
src/index.ts
parseFile prefers transformed module-graph code over disk reads; parsing state now tracks exportStarSources, localNameToExportedNamesMap, cssTagNames, and default-import spans; import/export collection is rewritten to populate the new richer model, record export * from provenance, and defer unresolved local exports.
Static literal and CSS template extraction
src/index.ts
AST visitors now unwrap TS assertions on initializers, resolve static strings/numbers via resolveStaticExpression, directly handle CSS-tagged templates when the tag resolves to the tracked css binding, and record unknown values for shadowing logic.
Export resolution and dependency tracking
src/index.ts
loadWouldDeadlock and resolveImportedFile add cycle detection; resolveExportTarget recursively follows re-export chains and detects ambiguity from multiple export * sources; traversed-file accumulation wires stylesheet dependencies.
Identifier and namespace member resolution
src/index.ts
resolveValue routes through import kinds (rejecting namespace for direct access); new resolveMemberPath resolves chained accesses like ns.foo through namespace re-exports while tracking dependencies; "own class names" are tracked for same-file forward-reference deferral.
Interpolated CSS extraction with deferred resolution
src/index.ts
processInterpolatedDeclaration unwraps/evaluates simple literals, resolves identifiers and namespace members, defers same-file forward refs when the class exists but isn't yet extracted, emits UNRESOLVED_INTERPOLATION vs COMPLEX_INTERPOLATION codes, and retries deferred declarations in a loop until fixed-point.
Cache management, watch/HMR eviction, and test configuration
src/index.ts, vitest.config.ts
buildEnd and watchChange clear new caches (extractedClassesPerFile, pendingLoads); module IDs are normalized via stripQuery; CSS modules are keyed by generated ID; Vitest console trace printing is disabled.
Test fixtures exercising import/export patterns and edge cases
test/fixtures/*
40+ fixture files covering: default exports (literals, CSS, wrapped, negative), export * precedence and ambiguous collisions, nested barrel aggregation with precedence rules, broken/complex interpolations, mutual import cycles, re-export chains (named, star, namespace), sibling concurrent resolution, same-file forward references and extraction isolation, type-only imports/exports, external package fallback patterns, and namespace member access edge cases.
Comprehensive test cases with snapshots
test/plugin.test.ts
Extensive Vitest cases validate default/export * precedence, ambiguous warnings, namespace resolution, chained namespace hardening, type-only erasure, cycle deadlock avoidance, sibling concurrent resolution, same-file forward-reference deferral, extraction isolation, and graceful external module skipping; test harness extended to support external modules and Windows path normalization.
README limitations and TODO cleanup
README.md
New "Limitations" section documents that css must be imported directly from ecij and interpolations must statically resolve to strings or numbers; TODO for "Full import/export handling" is removed.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • nstepien/ecij#58: Both PRs introduce distinct plugin warning codes for skipped CSS template extraction (COMPLEX_INTERPOLATION / UNRESOLVED_INTERPOLATION) when interpolations fail to resolve statically.
  • nstepien/ecij#14: Both PRs enhance identifier/value resolution during CSS template interpolation extraction—this PR refactors import/export-based resolution with support for re-export chains and namespaces, while the prior PR adds lexical scope-chain resolution for shadowed identifiers.
Pre-merge checks: Passed 4

Check name Status Explanation
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Title check Passed The title "Full import/export handling, query variant modules, and raw TypeScript" names the main change. The changeset adds cross-module resolution for default, namespace, re-export, and export * for…
Description check Passed The description is related to the changeset. It describes cross-module interpolation resolution for css tagged templates, supported import and export forms, resolution semantics, module identity and q…
Full details: Title check

Explanation

The title "Full import/export handling, query variant modules, and raw TypeScript" names the main change. The changeset adds cross-module resolution for default, namespace, re-export, and export * forms (src/index.ts, README, and many fixtures). It also covers query-variant module identity and raw TypeScript parsing, which the PR description and objectives both state. The title is 70 characters and 9 words, which is somewhat long but still a single descriptive sentence.

Full details: Description check

Explanation

The description is related to the changeset. It describes cross-module interpolation resolution for css tagged templates, supported import and export forms, resolution semantics, module identity and query variants, output changes for stylesheet imports, and behavior changes versus main. Its merge notes and test plan also match the changed files, which include the README, the src/index.ts pipeline, and many import/export fixtures. The description is detailed and not off-topic, so it passes.

Finishing Touches

Generate docstrings

  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)

  • Create PR with unit tests
  • Commit unit tests in branch full-import-export-support
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🧹 Nitpick comments (5)
test/fixtures/sibling-tokens.ts (1)

1-1: ⚡ Quick win

Use SCREAMING_SNAKE_CASE for module-level constant.

The exported constant pad should use SCREAMING_SNAKE_CASE per the coding guidelines for this project. As per coding guidelines, "Use SCREAMING_SNAKE_CASE for module-level constants" applies to **/*.{ts,tsx,js} files.

♻️ Proposed fix
-export const pad = 4;
+export const PAD = 4;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/fixtures/sibling-tokens.ts` at line 1, Rename the module-level exported
constant `pad` to SCREAMING_SNAKE_CASE (`PAD`) and update its export declaration
(`export const pad`) to `export const PAD`, then update all usages/imports in
the codebase to reference `PAD` instead of `pad` (search for the symbol `pad` to
find call sites), ensuring build and tests pass after the rename.

Source: Coding guidelines

test/fixtures/self-barrel-tokens.ts (1)

1-1: ⚡ Quick win

Use SCREAMING_SNAKE_CASE for module-level constant.

The exported constant tokenColor should use SCREAMING_SNAKE_CASE per the coding guidelines for this project. As per coding guidelines, "Use SCREAMING_SNAKE_CASE for module-level constants" applies to **/*.{ts,tsx,js} files.

♻️ Proposed fix
-export const tokenColor = 'teal';
+export const TOKEN_COLOR = 'teal';
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/fixtures/self-barrel-tokens.ts` at line 1, The module-level constant
tokenColor violates the SCREAMING_SNAKE_CASE rule; rename the exported symbol to
TOKEN_COLOR and update every usage/import to match (change export const
tokenColor -> export const TOKEN_COLOR and update any references/imports across
the codebase, including barrels or tests that import tokenColor). Ensure
TypeScript/ES exports and any default/barrel re-exports still resolve after
renaming.

Source: Coding guidelines

test/fixtures/typed.input.ts (1)

14-20: 💤 Low value

Module-level constant uses camelCase instead of SCREAMING_SNAKE_CASE.

The exported constant usesTypedTone should be USES_TYPED_TONE per the guideline requiring SCREAMING_SNAKE_CASE for module-level constants. However, as with the other fixture files, this is a test fixture where readability and natural naming may take precedence. As per coding guidelines, "Use SCREAMING_SNAKE_CASE for module-level constants" applies to **/*.{ts,tsx,js} files.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/fixtures/typed.input.ts` around lines 14 - 20, The exported module-level
constant usesTypedTone violates the SCREAMING_SNAKE_CASE rule; rename the symbol
usesTypedTone to USES_TYPED_TONE and update any local references/usages in this
fixture so the CSS template string remains the same but the exported identifier
uses SCREAMING_SNAKE_CASE (ensure you change the export name wherever
usesTypedTone is referenced).

Source: Coding guidelines

test/fixtures/typed-decoy.ts (1)

3-3: 💤 Low value

Module-level constant uses camelCase instead of SCREAMING_SNAKE_CASE.

Per coding guidelines, typedTone should be TYPED_TONE (SCREAMING_SNAKE_CASE for module-level constants). However, since this is a test fixture where the export name is significant for import resolution testing, and renaming would cascade to all consumers, this may be an acceptable deviation. Consider whether test fixtures should follow relaxed naming rules.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/fixtures/typed-decoy.ts` at line 3, The module-level constant exported
as typedTone violates the SCREAMING_SNAKE_CASE convention; either rename the
export to TYPED_TONE to follow guidelines (update all imports/consumers
accordingly) or, if this is an intentional test fixture exception, add a brief
comment above the export explaining the deliberate deviation so reviewers know
it’s intentional; locate the export named typedTone in the fixture and apply one
of these two changes consistently.

Source: Coding guidelines

test/fixtures/typed-tokens.ts (1)

1-1: 💤 Low value

Module-level constant uses camelCase instead of SCREAMING_SNAKE_CASE.

Per coding guidelines, typedTone should be TYPED_TONE. Same consideration as in typed-decoy.ts: this is a test fixture where the export name matters for resolution testing. As per coding guidelines, "Use SCREAMING_SNAKE_CASE for module-level constants" applies to **/*.{ts,tsx,js} files.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/fixtures/typed-tokens.ts` at line 1, The module-level constant is named
typedTone but must follow SCREAMING_SNAKE_CASE; rename the export symbol
typedTone to TYPED_TONE and update any consumers/tests that import or reference
typedTone to use TYPED_TONE instead (match the change made in typed-decoy.ts
style); ensure the exported value remains the same ('salmon') and that any
resolution tests or fixtures referencing the original symbol are adjusted
accordingly.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/index.ts`:
- Around line 1101-1183: The code mutates the shared stylesheetDependencies
while resolving interpolations in processInterpolatedDeclaration, causing
imports to be committed even when a declaration is later skipped or deferred;
fix by creating a per-declaration Set (e.g., localDependencies) inside
processInterpolatedDeclaration and use it for any dependency additions performed
by resolveValue/resolveMemberPath paths instead of mutating
stylesheetDependencies directly, ensure all early returns ('skipped' or
'deferred') do not touch the global set, and after
addProcessedDeclaration(declaration, cssContent) succeeds merge
localDependencies into the global stylesheetDependencies Set.
- Around line 426-438: isCssTagTemplate currently only checks scope.identifiers
and thus misses bindings declared later in the same scope; update it to also
scan the scope's AST block for any declarations that bind the same name
(node.tag.name) even if they haven't been added to scope.identifiers yet.
Specifically, inside isCssTagTemplate (and where you reference
scope.identifiers), inspect scope.block (or scope.node) for VariableDeclarator
ids, FunctionDeclaration ids, ClassDeclaration ids (and similar binding sites)
whose name equals node.tag.name and return false if any are found; keep the
existing parent-scope walk for earlier shadowing as well.

---

Nitpick comments:
In `@test/fixtures/self-barrel-tokens.ts`:
- Line 1: The module-level constant tokenColor violates the SCREAMING_SNAKE_CASE
rule; rename the exported symbol to TOKEN_COLOR and update every usage/import to
match (change export const tokenColor -> export const TOKEN_COLOR and update any
references/imports across the codebase, including barrels or tests that import
tokenColor). Ensure TypeScript/ES exports and any default/barrel re-exports
still resolve after renaming.

In `@test/fixtures/sibling-tokens.ts`:
- Line 1: Rename the module-level exported constant `pad` to
SCREAMING_SNAKE_CASE (`PAD`) and update its export declaration (`export const
pad`) to `export const PAD`, then update all usages/imports in the codebase to
reference `PAD` instead of `pad` (search for the symbol `pad` to find call
sites), ensuring build and tests pass after the rename.

In `@test/fixtures/typed-decoy.ts`:
- Line 3: The module-level constant exported as typedTone violates the
SCREAMING_SNAKE_CASE convention; either rename the export to TYPED_TONE to
follow guidelines (update all imports/consumers accordingly) or, if this is an
intentional test fixture exception, add a brief comment above the export
explaining the deliberate deviation so reviewers know it’s intentional; locate
the export named typedTone in the fixture and apply one of these two changes
consistently.

In `@test/fixtures/typed-tokens.ts`:
- Line 1: The module-level constant is named typedTone but must follow
SCREAMING_SNAKE_CASE; rename the export symbol typedTone to TYPED_TONE and
update any consumers/tests that import or reference typedTone to use TYPED_TONE
instead (match the change made in typed-decoy.ts style); ensure the exported
value remains the same ('salmon') and that any resolution tests or fixtures
referencing the original symbol are adjusted accordingly.

In `@test/fixtures/typed.input.ts`:
- Around line 14-20: The exported module-level constant usesTypedTone violates
the SCREAMING_SNAKE_CASE rule; rename the symbol usesTypedTone to
USES_TYPED_TONE and update any local references/usages in this fixture so the
CSS template string remains the same but the exported identifier uses
SCREAMING_SNAKE_CASE (ensure you change the export name wherever usesTypedTone
is referenced).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e19a3158-9acc-42d8-b238-2df58492bf4b

📥 Commits

Reviewing files that changed from the base of the PR and between 7d29442 and bda0816.

📒 Files selected for processing (56)
  • README.md
  • src/index.ts
  • test/fixtures/ambiguous-a.ts
  • test/fixtures/ambiguous-b.ts
  • test/fixtures/ambiguous-barrel.ts
  • test/fixtures/ambiguous.input.ts
  • test/fixtures/barrel-a.ts
  • test/fixtures/barrel-b.ts
  • test/fixtures/barrel-c.ts
  • test/fixtures/barrel-d.ts
  • test/fixtures/barrel-deep-only.input.ts
  • test/fixtures/barrel-nested.ts
  • test/fixtures/barrel.input.ts
  • test/fixtures/barrel.ts
  • test/fixtures/broken-export.input.ts
  • test/fixtures/broken-export.ts
  • test/fixtures/cycle-a.input.ts
  • test/fixtures/cycle-b.ts
  • test/fixtures/default-passthrough-source.ts
  • test/fixtures/default-passthrough.ts
  • test/fixtures/export-default-css.ts
  • test/fixtures/export-default-literal.ts
  • test/fixtures/export-default-local.ts
  • test/fixtures/export-default-negative.ts
  • test/fixtures/export-default-wrapped-css.ts
  • test/fixtures/external-barrel.ts
  • test/fixtures/external-star.input.ts
  • test/fixtures/external-tokens.ts
  • test/fixtures/import-export-hardening.input.ts
  • test/fixtures/import-export.input.ts
  • test/fixtures/named-styles.ts
  • test/fixtures/namespace-edge-cases.input.ts
  • test/fixtures/ns-chain-star.ts
  • test/fixtures/ns-chain.ts
  • test/fixtures/reexports-named.ts
  • test/fixtures/reexports-namespace.ts
  • test/fixtures/reexports-star.ts
  • test/fixtures/same-file-classes.input.ts
  • test/fixtures/self-barrel-tokens.ts
  • test/fixtures/self-barrel.input.ts
  • test/fixtures/self-barrel.ts
  • test/fixtures/sibling-consumer.ts
  • test/fixtures/sibling-producer.ts
  • test/fixtures/sibling-tokens.ts
  • test/fixtures/siblings.input.ts
  • test/fixtures/star-over-default.ts
  • test/fixtures/star-precedence-a.ts
  • test/fixtures/star-precedence.input.ts
  • test/fixtures/star-precedence.ts
  • test/fixtures/tag-alias.input.ts
  • test/fixtures/typed-barrel.ts
  • test/fixtures/typed-decoy.ts
  • test/fixtures/typed-tokens.ts
  • test/fixtures/typed.input.ts
  • test/plugin.test.ts
  • vitest.config.ts

Comment thread src/index.ts Outdated
Comment on lines +426 to +438
function isCssTagTemplate(node: TaggedTemplateExpression, scope: Scope): boolean {
if (!(node.tag.type === 'Identifier' && cssTagNames.has(node.tag.name))) {
return false;
}

// A local binding shadowing the imported tag means this is not the ecij tag
for (let current: Scope | null = scope; current !== null; current = current.parent) {
if (current.identifiers.has(node.tag.name)) {
return false;
}
}

return true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Shadow checks miss later bindings in the same scope.

isCssTagTemplate() only sees names already inserted into scope.identifiers, but those bindings are recorded when the visitor reaches their declarations later on (for example, Line 565 and Line 672). A later let/const/var/function/class styled = ... still shadows the imported alias from the start of the scope, so styled\...`can be extracted here even though runtime resolution hits a TDZ or hoisted local binding instead ofecij`'s tag.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/index.ts` around lines 426 - 438, isCssTagTemplate currently only checks
scope.identifiers and thus misses bindings declared later in the same scope;
update it to also scan the scope's AST block for any declarations that bind the
same name (node.tag.name) even if they haven't been added to scope.identifiers
yet. Specifically, inside isCssTagTemplate (and where you reference
scope.identifiers), inspect scope.block (or scope.node) for VariableDeclarator
ids, FunctionDeclaration ids, ClassDeclaration ids (and similar binding sites)
whose name equals node.tag.name and return false if any are found; keep the
existing parent-scope walk for earlier shadowing as well.

Comment thread src/index.ts Outdated
Comment on lines +1101 to +1183
async function processInterpolatedDeclaration(
declaration: Declaration,
finalAttempt: boolean,
): Promise<'extracted' | 'deferred' | 'skipped'> {
const { quasis, expressions } = declaration.node.quasi;

let cssContent = '';
let allResolved = true;

for (let i = 0; i < quasis.length; i++) {
cssContent += quasis[i]!.value.raw;

if (i < expressions.length) {
const expression = expressions[i]!;

let resolvedValue: string | undefined;

if (
expression.type === 'Literal' &&
(typeof expression.value === 'string' || typeof expression.value === 'number')
) {
resolvedValue = String(expression.value);
} else if (
expression.type === 'UnaryExpression' &&
(expression.operator === '-' || expression.operator === '+') &&
expression.argument.type === 'Literal' &&
typeof expression.argument.value === 'number'
) {
resolvedValue = String(
expression.operator === '-' ? -expression.argument.value : expression.argument.value,
);
} else if (expression.type === 'Identifier') {
resolvedValue = await resolveValue(expression.name, declaration.scope);

if (resolvedValue === undefined) {
// Cannot resolve - skip this entire css`` block
const expression = unwrapExpression(expressions[i]!);

let resolvedValue = resolveStaticExpression(expression);

if (resolvedValue === undefined) {
const memberPath =
expression.type === 'MemberExpression'
? flattenMemberExpressionPath(expression)
: undefined;

if (expression.type === 'Identifier') {
resolvedValue = await resolveValue(expression.name, declaration.scope);

// A class name of a same-file declaration that has not been
// extracted: defer in case it is a forward reference whose
// declaration is still pending; once no progress can be made
// it is a failed extraction and must not leak (no rule exists).
if (
resolvedValue !== undefined &&
ownClassNames.has(resolvedValue) &&
!extractedClasses.has(resolvedValue)
) {
if (!finalAttempt) return 'deferred';
resolvedValue = undefined;
}

if (resolvedValue === undefined) {
// Cannot resolve - skip this entire css`` block
context.warn(
{
pluginCode: 'UNRESOLVED_INTERPOLATION',
message: `skipped CSS extraction — could not resolve "${expression.name}" to a static string or number`,
},
expression.start,
);
return 'skipped';
}
} else if (memberPath !== undefined) {
// Namespace member access: `${ns.foo}` / `${ns.inner.foo}`
resolvedValue = await resolveMemberPath(memberPath, declaration.scope);

if (resolvedValue === undefined) {
context.warn(
{
pluginCode: 'UNRESOLVED_INTERPOLATION',
message: `skipped CSS extraction — could not resolve "${memberPath.join('.')}" to a static string or number`,
},
expression.start,
);
return 'skipped';
}
} else {
// Complex expression - skip this entire css`` block
context.warn(
{
pluginCode: 'UNRESOLVED_INTERPOLATION',
message: `skipped CSS extraction — could not resolve "${expression.name}" to a static string or number`,
pluginCode: 'COMPLEX_INTERPOLATION',
message:
'skipped CSS extraction — interpolation is not a static string, number, or identifier',
},
expression.start,
);
allResolved = false;
break;
return 'skipped';
}
} else {
// Complex expression - skip this entire css`` block
context.warn(
{
pluginCode: 'COMPLEX_INTERPOLATION',
message:
'skipped CSS extraction — interpolation is not a static string, number, or identifier',
},
expression.start,
);
allResolved = false;
break;
}

cssContent += resolvedValue;
}
}

// Only process if all interpolations were resolved
if (allResolved) {
addProcessedDeclaration(declaration, cssContent);
addProcessedDeclaration(declaration, cssContent);
return 'extracted';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Only commit stylesheet dependencies after the declaration is emitted.

This path evaluates interpolations against the shared stylesheetDependencies set as it goes. If an early interpolation resolves through resolveExportValue()/resolveMemberPath() but a later one returns 'skipped' or 'deferred', the dependency import remains even though this declaration never reaches addProcessedDeclaration(). That can pull unrelated CSS into the final output. Keep a per-declaration dependency set and merge it only after extraction succeeds.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/index.ts` around lines 1101 - 1183, The code mutates the shared
stylesheetDependencies while resolving interpolations in
processInterpolatedDeclaration, causing imports to be committed even when a
declaration is later skipped or deferred; fix by creating a per-declaration Set
(e.g., localDependencies) inside processInterpolatedDeclaration and use it for
any dependency additions performed by resolveValue/resolveMemberPath paths
instead of mutating stylesheetDependencies directly, ensure all early returns
('skipped' or 'deferred') do not touch the global set, and after
addProcessedDeclaration(declaration, cssContent) succeeds merge
localDependencies into the global stylesheetDependencies Set.

@nstepien nstepien self-assigned this Jun 24, 2026
nstepien and others added 15 commits August 30, 2026 01:46
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Decide css tag shadowing after the visit, once every scope is fully
  populated: `let`/`const`/`class` bindings are hoisted to the top of their
  block (TDZ) and `var`/function declarations to their function scope, so a
  local binding declared *after* a css`` template still shadows the imported
  tag. Such templates were wrongly extracted; they are now left untouched.
- Track stylesheet dependencies per declaration and only commit them once
  the declaration is extracted, so a skipped declaration no longer registers
  the stylesheets of modules it merely probed.
- Hoist `isBoundInScopeChain` to module level and reuse it for both checks.
- Document that test fixtures mirror user code and are exempt from the
  naming conventions (the camelCase fixture-export nitpicks).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A `var x = css`${y}`` inside a block used the function scope (where the
`var` binding lands) to resolve its interpolations, so a block-scoped `y`
shadowing an outer one resolved to the outer value, a block-only `y` was
unresolvable, and a loop variable could resolve to an outer binding of the
same name. Interpolations (and the tag) are now resolved in the scope the
template appears in; only the binding of `x` stays function-scoped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Drop the aliased-tag fixture/test: the tag-shadowing fixture imports the
  tag as `css as styled` and extracts a module-level template the same way.
- Drop the type-only section of import-export.input.ts: the plain-rolldown
  typed test covers the same fixtures and assertion, and is the one that
  exercises the plugin's own `isType` handling (Vite strips types first).
- Fold the var-in-block cases into scoping-advanced.input.ts (sections
  32-33), next to the existing var-hoisting cases.
- Replace scoping-helper.ts with the equivalent named-styles.ts in the
  scoping and dependency-skipped fixtures.
- Drop basic.input.ts (a copy of comprehensive's first block); the
  classPrefix test builds the identical.input.ts entry instead.

28 -> 26 tests, 70 -> 66 fixtures; line/branch coverage of src/index.ts is
unchanged (verified against the pre-change lcov).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Drop `multiShadow`/`afterMultiShadow` (section 1 with three variables) and
`deeplyNested` (sections 2-4 combined) from scoping.input.ts: neither adds
a code path or observable beyond the cases it recombines. Line/branch
coverage of src/index.ts is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Track `VariableDeclaration` kinds as a stack: a declarator following a
  sibling whose initializer contains nested declarations (e.g. a function
  body) lost its `var` kind and was bound to the block instead of the
  function scope, so a later interpolation resolved an outer binding.
- README: add a "Static resolution" section describing what the plugin
  resolves (literals, scoped bindings, imports through re-exports and
  barrels with ESM precedence) and how failures surface; clarify that the
  tag must be a named import.
- AGENTS.md: describe all five hooks (`watchChange` is new in this PR) and
  the cross-module resolution/caching machinery in the transform hook.
- Rename the dependency loop variable that shadowed the hook's `id`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`let`/`var` bindings with a literal initializer were resolved to that
literal even when reassigned later (`let color = 'red'; color = 'blue'`),
baking the wrong value into the CSS. Assignment targets (`x = …`, `x++`,
`for (x of …)`, destructuring assignments) are now collected during the
visit and, once every scope is populated, mark their binding — and any
export of it — as unresolvable. `<T>expr` type assertions are unwrapped
like the other TypeScript wrappers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`resolveImportedFile` ran `context.resolve` + `context.load` for every
interpolation from the same module, and again on every deferred retry.
Memoize the resolved path per (importer, source) for the duration of the
transform; parse info and extracted classes are read live regardless.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Covers the array/object pattern, default, rest and TypeScript assertion
branches of the reassignment walker with one template per target kind.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Vite strips `(x as T) = …` before plugin transforms run, so the plain
rolldown test is the one place the assertion branch of the reassignment
walker is reachable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… fixes

Findings from an independent review of src/index.ts, each reproduced
before fixing:

- Vite dev: resolved ids carry no `external` flag, so `external !== false`
  treated every import as external and no cross-module interpolation ever
  resolved; `ModuleInfo.code` throws in dev; and `this.load` does not run
  transforms there. Only a set `external` flag now counts, the module-graph
  code read is guarded, and in a dev environment the imported module is
  transformed through `environment.transformRequest`. Covered by a new
  dev-server test.
- Query-suffixed imports (`?raw`, `?url`, …) were resolved as the plain file
  and inlined its default export; ids with anything but Vite's marker
  queries are now treated as unresolvable.
- A redeclared `var` with an initializer simply overwrote the recorded
  value, so all uses saw the last initializer; it is now a reassignment.
- Injected imports were prepended before a hashbang (a parse error in both
  Rolldown and Vite builds) or ahead of `'use client'`; they are inserted
  after the prologue.
- Function bodies shared the parameter scope, so parameter defaults could
  see (and be shadowed by) body declarations; bodies get their own scope.
- In import cycles, extraction depended on declaration order; declarations
  whose interpolations need no import are now extracted before any import
  is followed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…king

Findings from the max-effort code review and a second independent review,
each reproduced before fixing:

- A `?raw`/`?url` variant of a module was transformed under the plain
  module's id and overwrote its cached parse info and classes; query-variant
  modules are now skipped.
- Under `"sideEffects": false` a module whose exports were all inlined was
  dropped together with its injected stylesheet imports, losing the rules
  its own CSS relies on; the transform now returns `moduleSideEffects: true`
  when it injects imports (the never-reached `resolveId` branch is gone).
- An ambiguous `export *` name reached through another `export *` shared its
  origin key with a value and was resolved; ambiguity is its own result kind
  and propagates.
- Loop and switch heads shared the body scope, so a body-level `let` could
  swallow a write in the head; bodies get their own scope, head targets are
  recorded in the enclosing one, and the switch scope starts with the first
  case.
- Function bodies are function scopes of their own: body `var`s no longer
  hoist next to the parameters (parameter defaults saw them), and a body
  `var` named like a parameter counts as a write to it.
- `export default x` before `var x` snapshots `undefined`, not the later
  initializer; such defaults are unresolvable.
- Vite dev: module code is read from the environment's module graph (also
  for `\0` virtual modules) and an unreadable module is no longer cached as
  empty for the build; it is reported once with `UNREADABLE_MODULE`.
- Sources are parsed as the language Rolldown assigned (`meta.moduleType`),
  so a `.js` module declared as JSX is no longer parsed as plain JS; parse
  failures are reported with `UNPARSEABLE_MODULE`.
- Raw TypeScript: `enum`, `namespace` (with its own scope) and `import x =`
  now bind their names.
- A module's own class reached back through a barrel goes through the same
  deferral as a direct forward reference.
- Dependencies are recorded as module paths and mapped to stylesheets when
  imports are emitted; a module still in flight is imported itself, so
  output no longer depends on scheduling.
- Cleanups: one pattern walker for bindings and assignment targets, one
  resolution path for `${name}` and `${ns.member}`, namespace hops share a
  helper, and a retry round of skips no longer counts as progress.

Documented: cycle ordering and cross-plugin `this.load` limitations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Findings from a third independent review of the previous round:

- Regression: gating the transform on Vite's marker-query allowlist skipped
  source modules whose id carries a query, e.g. an HTML page's inline
  `<script type="module">` (`index.html?html-proxy&index=0.js`), worker
  entries and framework sub-modules, leaving their templates untransformed.
  A module's identity is now its id minus the marker queries: query variants
  (`?raw`, `?url`, inline scripts, …) keep their own parse info, classes and
  stylesheets instead of being skipped or clobbering the plain module, each
  inline script gets its own class hashes, and values imported with a query
  resolve from the variant's own code (`?raw` yields the file's text, `?url`
  its asset URL). `watchChange` evicts the variants of a changed file too.
  Covered by an HTML-entry build test.
- A `var` is `undefined` until its declaration has run, yet templates placed
  before it, or after a conditional/loop/try initialization, inlined the
  initializer. Such bindings are now non-static: a `var` is only used by
  templates after its unconditional declaration. (`const`/class forward
  references stay supported — those would throw at runtime, so static
  evaluation is the only meaningful outcome.)
- Test for the raw-TypeScript `import x = …` handler.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`let`/`var` bindings with a literal initializer were resolved like `const`
ones since PR #14, which required reassignment tracking and, for `var`,
position and control-flow analysis to avoid inlining a value the runtime
never had — and still left gaps (hoisted callees, labeled blocks, Annex B
catch parameters). A static style token is idiomatically a `const`, and a
`const` has clean semantics: one assignment, TDZ before it (which the
plugin deliberately resolves, as for same-file forward references).

Only `const` declarators now record a value; `let`/`var` bindings (and
their exports) are recorded as unknown, so they still shadow outer
bindings and imports — including `var` hoisting to the function scope —
but a template using one is skipped with UNRESOLVED_INTERPOLATION. A css``
template assigned to a `let`/`var` is still extracted; only references to
it need a `const`.

Removed with it: reassignment tracking, redeclared/parameter-shadowing
`var` writes, conditional-depth and declaration-position bookkeeping,
forward `export default` handling, and the fixtures that existed only to
exercise those semantics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@nstepien nstepien changed the title Full import/export handling Full import/export handling, Vite dev resolution, const-only static values Aug 31, 2026
nstepien added a commit that referenced this pull request Oct 17, 2026
## Summary

First slice split out of #68 so it can be reviewed on its own: the
static-resolution and scoping semantics of `parseFile`, with none of the
import/export machinery.

**Only `const` bindings have a static value.** `let`/`var` bindings,
parameters, and destructured bindings are still recorded so they shadow
outer bindings and imports (including `var` hoisting to the function
scope), but a template using one is skipped with
`UNRESOLVED_INTERPOLATION`. A `css` template assigned to a `let`/`var`
is still extracted. Only references to it need a `const`. `parseFile`
records the initializer of each `const`, and `resolveValue` evaluates it
on demand, like an interpolation: a literal, signed numbers included
(`const offset = -5`), or another binding that resolves (`const accent =
brand`), class names and imports included. An imported binding is
resolved in the module that declares it, so an exported alias resolves
for its importers too. The values are cached for the transform, unknown
ones included, and a binding is cached as unknown while it is resolved,
so a cycle stays unknown. Resolution is static, so a template or a
`const` may reference a `const` declared later in the file (a TDZ error
at runtime). The plugin keeps resolving it on purpose, but the README
does not document it, so users do not rely on it. Re-exported imports
(`export { x }` of an import, and `export … from`) are still not
followed.

Resolving `let`/`var` values would require reassignment tracking and,
for `var`, position and control-flow analysis to avoid inlining a value
the runtime never had (hoisted callees, conditional initialization,
`break label`, Annex B `catch`). Static style tokens are idiomatically
`const` anyway.

**Tag shadowing.** A local binding named like the tag means the template
is not ecij's `css` and is left alone. Templates are collected during
the visit and decided once every scope is populated, so bindings
declared *after* the template count too (`let`/`const`/`class` are
hoisted to their block, `var`/functions to their function). The tag is
recognized through its import binding (`cssTagNames`), so `import { css
as styled } from 'ecij'` works as a by-product.

**Scopes.**
- Function bodies are scopes of their own: parameter defaults cannot see
the body's declarations, and body `var`s hoist into the body rather than
next to the parameters
- Loop and catch bodies are nested in the scope of the loop head or the
catch parameter instead of sharing it, so a body-level declaration
cannot leak into the loop head
- The switch scope is entered with the first case, so the discriminant
is evaluated in the enclosing scope
- The `var` declarator kind is tracked as a stack: a declarator
following a sibling whose initializer contains nested declarations, like
a function body, lost its kind, so a `var` was bound to the block (and a
`const` would no longer resolve)
- A template's interpolations are resolved in the scope where the
template appears (previously a `var`-declared template used the function
scope, so a block-scoped binding shadowing an outer one resolved to the
outer value)

Docs: the README gains a "Static resolution" section, with an example of
the supported interpolations, and AGENTS.md's transform description
matches. AGENTS.md also records why the `const` cache only lives for one
transform.

**Behavior changes vs `main`**
- `let`/`var` bindings with a literal initializer no longer resolve and
warn instead
- `const` bindings of signed numbers now resolve
- A `const` bound to another binding that resolves, local or imported,
now resolves too, also for the modules importing it
- A binding exported under several names now resolves under each of them
- Templates whose tag is shadowed by a local binding are no longer
extracted
- Aliased tag imports are now supported

## Test plan

- `variable scoping` and `advanced scoping` snapshots updated for the
`const`-only rule (§7 `var`, §11 for-head `let`, and §23 `var` in a
block now warn). New advanced sections 32–36: `var` declarator kind
stack, `let` not resolved, parameter defaults vs. body `const`/`var`,
and `var`-declared templates resolving where they appear
- The barrel test re-exports `scoping.input.ts`, so it now records that
fixture's `var` warning
- New `tag-shadow-hoisting` fixture and test: later
`const`/`var`/`function`/`class`, self-reference, enclosing-scope
declaration, nested-block negative control, and module level. Only the
two unshadowed templates are extracted
- `literal-interpolation` gains a `const` bound to a signed number
- New `const-alias` fixture and test: aliases of literals and class
names, an alias of a shadowing binding, a chain declared after its use,
an alias of an import, and exported aliases of a literal, a class name,
and an import resolve. Aliases of a `let` and a cycle warn
- Vite builds of scratch projects: a `const` cycle across two modules
and a re-export cycle terminate unresolved, a chain of aliases across
three modules resolves with the stylesheets in dependency order, modules
interpolating each other's aliased class names build, and a binding
exported under two names resolves under both
- `node --run test` (locally and with `CI=true` against `dist/`),
`lint`, `typecheck`, `format:check`, and `build` pass. `src/index.ts`
statement coverage is 99.6%, and its one uncovered line (TypeScript
parameter properties) is uncovered on `main` too

## Relationship to #68

The code here started out textually identical to the corresponding
regions of #68. Merging `main` into it (IIFE class names, hook filters,
cycle handling, and the dev server updates) and the review
simplifications since then mean they no longer match, so merging `main`
back into #68 afterwards will need conflict resolution in these regions
too.

Candidate next slices:
- Raw TypeScript handling (`enum`/`namespace`/`import =`,
`moduleType`-aware parsing)
- Prologue-safe import injection (#68's `moduleSideEffects` is no longer
needed: `main` keeps such modules alive with IIFE class names)
- The rest of the import/export resolution core: re-exports, and default
and namespace imports and exports
- Dev-server resolution and module identity for query variants

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
main took in the first slice of this PR (#79) and solved several of its
other problems differently since August, so the merge ports the rest of
this branch onto main's design instead of keeping both:

- Values, class names included, come from the parse of each module, and
  const initializers resolve lazily (#79). Re-exports, `export *`,
  default and namespace imports, and `export default` resolve through the
  same evaluator, with ESM precedence and ambiguity.
- Cycles are handled by main's pendingLoads and isWaitingFor (#102), so
  the wait graph, the deferred same-file passes, and the import-last
  ordering are gone, along with the guard against inlining the class
  names of skipped templates, which needed the imported modules'
  transforms.
- Class names are IIFEs (#87, #98), so `moduleSideEffects: true` is gone.
- Vite's dev server keeps main's behavior (#104) and does not transform
  imported modules through transformRequest. There, the code of modules
  that no file holds comes from their `load` hooks.
- UNREADABLE_MODULE and UNPARSEABLE_MODULE are gone: a module whose code
  cannot be read exports nothing, and modules are parsed as the language
  Rolldown assigned them.
- Kept: raw TypeScript bindings, `meta.moduleType` parsing,
  prologue-safe imports, query-variant module identity, skipping
  external modules, and resolving each import once per transform.

Tests: main's suite, plus this branch's tests of the kept features, with
new cases for the paths that no fixture reached. The tests of the removed
behavior, and the cycle test that main already has, are dropped.
basic.input.ts is restored for main's inlineConst tests, and this
branch's barrel.ts is now barrel-index.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nstepien nstepien changed the title Full import/export handling, Vite dev resolution, const-only static values Full import/export handling, query variant modules, and raw TypeScript Oct 17, 2026
nstepien added a commit that referenced this pull request Oct 17, 2026
The release workflow ran `npm version` itself but could only push the
tag, as the default branch ruleset blocks its push to `main`, so the
version commit never landed on `main`. Now `npm version` runs locally,
and pushing the version commit and its tag runs the workflow.

## How to release

On an up-to-date `main`:

```sh
npm version <patch|minor|major|x.y.z>
git push --atomic --follow-tags origin main
```

`npm version` commits the new version and creates an annotated `vX.Y.Z`
tag. `--follow-tags` pushes that tag along with `main`, and `--atomic`
makes the push all or nothing. As an admin, you bypass the default
branch ruleset for the push to `main`. The same steps are documented at
the top of `release.yml`.

## Changes

- The workflow runs on `push` of `v*` tags instead of
`workflow_dispatch`.
- The `build` job checks that the tagged commit is on `main` (replacing
the `github.ref` guard) and that the tag matches `package.json`'s
version, before installing anything. Then it runs the same checks and
build as before.
- The `release` job no longer configures git, runs `npm version`, or
pushes the tag. It publishes `dist` and creates the GitHub release for
the existing tag with `--verify-tag`, so `contents: write` is now only
used for the release.
- AGENTS.md points to the workflow's header for the release steps.

The workflow file name is unchanged, so npm's trusted publisher config
still matches.

## Verification

- With a local bare remote, I confirmed that `npm version minor` creates
an annotated tag, and that `git push --atomic --follow-tags origin main`
pushes both refs, or neither when the remote rejects `main`.
- The ancestry check passes for `v0.5.0`'s commit and fails for #68's
head. The version check passes for `v0.5.0` and fails for `v0.5.1` on
the current `package.json`.
- zizmor reports nothing with the regular persona. actionlint only flags
`cache-mode`, which it doesn't know yet.

## Suggested follow-up

Now that the workflow no longer creates tags, enable **Restrict
creations** in the `Release tags` ruleset. With the admin bypass, only
admins can then create `v*` tags, which means only admins can start a
release.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
nstepien added a commit that referenced this pull request Nov 4, 2026
When a template interpolates values imported from another module,
`resolveValue` resolved, loaded, and parsed that module again for every
value, calling `context.resolve` and `context.load` each time. A
transform now does this once per imported module, however many values it
interpolates from it. The modules are keyed by importer and import
source, as values can also come through the imports of an imported
module.

**Benchmarks**

Medians with Vite 8.3.5, main against this branch. The JS, CSS, and
final source maps are identical.

| Workload | main | This branch |
|---|---|---|
| Vite build of a synthetic project: 40 components × 8 templates × 5
tokens from one `tokens.ts` | 54.2 ms | 32.8 ms (−39%) |
| Cold dev server transforms of those 40 components | 71.1 ms | 39.8 ms
(−44%) |
| Vite build of react-data-grid's `src`, with 9 interpolations in 15
modules | 21.9 ms | 21.8 ms |

A transform now records each dependency and checks `isWaitingFor` once
per imported module instead of once per value.

The uncovered lines are unchanged: the tests reuse a loaded module 15
times. An import that can't be resolved stays untested, as on main.

#68 includes the same change (`importedFiles`), so whichever lands
second needs to account for it.

`AGENTS.md` now says that a transform handles each imported module once.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
nstepien added a commit that referenced this pull request Nov 4, 2026
ecij parsed the module it transforms as the language its file extension
implies, ignoring the module type the bundler assigned it. Rolldown
parses a `.js` file with JSX as JSX when configured with `moduleTypes: {
'.js': 'jsx' }`, but ecij failed to parse it and silently left its
templates untransformed. The output kept the raw `css` call, which
throws at runtime, and no CSS or warning came out.

- `transform` now parses the module as `meta.moduleType` when that is a
language the parser accepts (`js`, `jsx`, `ts`, or `tsx`), and otherwise
falls back to the file extension. The modules that interpolated values
come from are still read from disk and parsed by their extension.
- A new test builds a `.js` fixture with JSX through plain Rolldown with
`moduleTypes: { '.js': 'jsx' }`. On main, its output keeps the `css`
template and no stylesheet is extracted.
- `AGENTS.md` describes how the module is parsed.

Vite users see no change. Vite compiles TypeScript and JSX to JavaScript
before ecij runs, and passes `moduleType: 'js'` along with that code,
both in builds and in the dev server. A Vite build of react-data-grid's
`src` is byte-for-byte identical to main's, source maps included. The
fix is for Rolldown and tsdown users with custom `moduleTypes`.

This is ported from #68, which passes `meta.moduleType` to the parser
the same way.

The uncovered lines are unchanged. The fallback for a module type that
isn't a language, like `json`, is the one new branch the tests don't
reach.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
nstepien and others added 3 commits November 4, 2026 02:36
main reshaped the code this branch builds on since the last merge, so
the merge ports the branch onto main's design again:

- The scope visitor is shared across parses (#109), so the callbacks
  for raw TypeScript bindings and `export default` join it, and the
  default export is recorded through its visit state.
- Imported modules are resolved, loaded, and parsed once per transform
  through main's loadImportedModule (#124), which now also skips
  external modules, keys modules by moduleKey, and records dependencies
  by file.
- Modules are parsed as the language the bundler assigned them through
  main's parseLangOf (#125), which this branch had too.
- extractCssFromCode keeps main's single pass (#114), and hotUpdate its
  early return, with this branch's handling of query variants.
- readModuleCode moved to module level, as consistent-function-scoping
  is on since #109.

Tests: the inline snapshots of this branch's tests follow Rolldown's new
module comments (`// path` instead of `//#region path` and
`//#endregion`), with no other change. The dev server test of virtual
modules uses main's shared getCode and getStylesheets helpers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The first commit of this branch turned it off along with the import and
export work, unlike main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main reworked the import handling underneath this branch: imports read
from the top-level statements (#127), parses cached while pending (#128),
imports as bindings of the module-level scope with default imports
through `*default*` (#131), values resolved without promises (#133), and
TypeScript namespaces given a scope of their own (#130). src/index.ts was
taken from main and this branch's features ported onto it:

- Namespace imports are `NamespaceImport` bindings, and member chains
  (`ns.a.b`) resolve through the exports of each namespace.
- Exports are `moduleExports` (local, re-export, namespace) plus
  `exportStarSources`, read from the top-level statements, and
  `resolveExport` follows them with ESM precedence and ambiguity.
  Exporting an imported binding re-exports it, as in the spec. Exported
  functions, classes, enums, and destructured bindings are recorded, so
  that they take precedence over `export *`.
- Type-only imports and exports are skipped through `importKind` and
  `exportKind`, as the module record is no longer read.
- Main's `isCssTag`, `bindingValues`, and `MaybePromise` resolution are
  kept. An import resolves through `resolveExport` and imports the
  stylesheet of the module that declares the value.

Main's decisions win where they differ from this branch:

- TypeScript namespaces are not supported: the namespace-name and
  `import x =` bindings are dropped, along with the typed fixture's
  namespace cases, as main's namespace test covers containment. Enums
  still shadow.
- Templates exported by default get no `default` name, so their class
  names hash like main's.

Main's import-forms test now extracts the namespace member and the
re-export it skipped. Two fixture cases cover the export of a namespace
import and a member of a local object.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nstepien
nstepien marked this pull request as draft November 7, 2026 05:45
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