Skip to content

Full import/export handling#68

Open
nstepien wants to merge 1 commit into
mainfrom
full-import-export-support
Open

Full import/export handling#68
nstepien wants to merge 1 commit 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: interpolation values inside css\`` templates now resolve across modules through every ES module import/export form.

Supported forms

  • 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, export { default as x } from, export { x as default } from, export * from (excluding default), export * as ns from, and transitive chains through barrel files — including import d from 'mod'; export { d };, which oxc normalizes to an entry pointing at the local binding rather than default
  • export default of css tagged templates and static expressions (string/number literals, signed numbers, parenthesized / TS-assertion-wrapped forms) via a single shared static-expression evaluator
  • Type-only imports/exports are ignored in all their forms: import type { x }, inline import { type x }, export type { x } from, export { type x } from, and export type * from
  • Aliased tags (import { css as styled } from 'ecij'), with a scope check so local bindings shadowing the tag are not extracted

ESM-conformant resolution semantics

  • Explicit exports shadow export * sources even when their value is not statically resolvable (warn instead of silently taking the star value)
  • Names provided by multiple star sources through different bindings are ambiguous and not resolved (two paths to the same binding are fine)
  • export * never forwards the default export

Robustness

  • A pending-load wait graph detects when awaiting context.load would close a wait cycle, so self-referencing barrels and mutual css-class cycles resolve (or degrade to a warning) instead of deadlocking the build, while concurrent sibling modules still await each other's extraction results
  • External modules reached through re-export chains are skipped instead of loaded (previously: indefinite hang)
  • Class names whose declarations failed extraction (or live in filtered-out files) no longer leak into consumers as phantom selectors — cross-module and same-file, with same-file forward references still resolving via a deferred retry pass
  • Stylesheet dependencies are recorded only for successful resolutions, so star sources probed without providing the value no longer drag dead CSS into the bundle
  • Watch mode: traversed dependencies are registered with addWatchFile, and watchChange evicts the per-file caches

Known limitations (documented in the README)

  • The css tag itself must be imported directly from 'ecij'; re-exporting the tag through another module leaves templates untransformed
  • Bindings are resolved from their initializers; reassignments are not tracked (matches the existing scoping tests' semantics)
  • Watch-mode behavior has no automated coverage yet (all tests are single-shot builds)

Test plan

  • 12 → 24 integration tests, all snapshot-reviewed: barrels (incl. depth-2 export * chains and explicit-over-star precedence), default/namespace/re-export matrix, namespace edge cases, star ambiguity, cycles, concurrent siblings, externals, phantom classes, tag aliases, and type-only forms
  • One test runs plain rolldown (not Vite) so the transform receives raw TypeScript — the only way to exercise the plugin's own isType handling, since Vite strips types before plugin transforms run
  • npm test (multiple shuffle seeds), npm run typecheck, npm run format:check, and npm run build all pass

🤖 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

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.

Poem

🐰 Exports dance through modules deep,
Star and named, their secrets keep,
Cycles caught before they break,
Deferred refs the code will make,
Fixtures guard each edge case true!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.69% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title "Full import/export handling" directly matches the main objective: implementing cross-module resolution of interpolation values inside css templates, covering all ES module import/export forms.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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

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

❤️ Share

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

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