Repository navigation
Resolve only const bindings; scope templates and tags correctly - #79
Merged
Merged
Conversation
Static resolution now takes values from `const` bindings only. `let`/`var`
bindings, parameters and destructured bindings are still recorded so they
shadow outer bindings and imports (including `var` hoisting), but a
template using one is skipped with UNRESOLVED_INTERPOLATION: a `let`/`var`
may be reassigned and a `var` is `undefined` until its declaration has run,
which static analysis cannot follow faithfully. A css`` template assigned
to a `let`/`var` is still extracted; only references to it need a `const`.
Templates are collected during the visit and extracted once every scope is
populated, so a local binding shadowing the tag — even one declared later
and hoisted — leaves the template alone; the tag is recognized through its
import binding, which also supports `import { css as styled }`.
Scopes: function bodies are scopes of their own (parameter defaults cannot
see the body, body `var`s hoist into the body), loop bodies are separate
from their heads, the switch scope starts with the first case, the `var`
declarator kind is tracked as a stack, and a template's interpolations
resolve where the template appears rather than in the `var` target scope.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configuration
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. Comment |
Conflicts: - src/index.ts: keep the PR's loop, switch, and tag-shadowing scopes with main's JSDoc and `ecij()` signature - AGENTS.md: main's transform description with the PR's resolution rules - test fixtures and snapshots: main's formatting and IIFE class names; the barrel test now records the warning from scoping.input.ts's `var` case Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Drop "e.g."/"i.e." and README semicolons, unwrap the README section, and use JSDoc for interface fields like `Scope`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Drop `CssTagCandidate.bindingScope`: only a `const` records its class name, and a `const` is bound in the scope of its template. - Give catch bodies their own nested scope, like loop bodies, which retires `skippedBlockStatements`. - Share `resolveStaticExpression` between interpolations and `const` initializers, so `const offset = -5` resolves, as the README states. - Require the scope argument of `recordIdentifierWithValue`: templates are extracted after the visit, when `currentScope` is the root again. - README: drop the ambiguous "or a class" and the repeated list of bindings that are not resolved. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A `const` value is now evaluated once every scope is populated, like the tag shadowing, so an initializer may also reference another binding: `const accent = brand` takes the value of `brand`, class names included, following lexical scoping. The values are evaluated in rounds, as an initializer may reference a `const` evaluated after it, until a round makes no progress, which leaves cycles unknown. An alias of an import is not followed. `isBoundInScopeChain` becomes `findBindingScope`, which `resolveValue` now uses to walk the scope chain too. Every tagged template passed to `addCssTagCandidate` is marked as processed, whatever its tag. The README shows the supported interpolations in an example instead of a list, and formats css`` as code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`parseFile` now records the initializer of each `const` instead of evaluating it, and `resolveValue` evaluates an expression on demand: a literal, a signed number, or an identifier, whose `const` initializer is resolved in turn, in its own module for an imported binding. So a `const` bound to an import resolves too, and so does an exported alias in the modules importing it. The values are cached for the transform, keyed by initializer, unknown ones included. A binding is cached as unknown while it is resolved, so that a cycle stays unknown. Exports now map exported names to local names, so a binding exported under several names resolves under each of them. Re-exported imports are still not followed. The README example interpolates an imported class name, and no longer shows a `const` declared after the template that uses it, so that users do not rely on it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nstepien
added a commit
that referenced
this pull request
Oct 17, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
constbindings have a static value.let/varbindings, parameters, and destructured bindings are still recorded so they shadow outer bindings and imports (includingvarhoisting to the function scope), but a template using one is skipped withUNRESOLVED_INTERPOLATION. Acsstemplate assigned to alet/varis still extracted. Only references to it need aconst.parseFilerecords the initializer of eachconst, andresolveValueevaluates 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 aconstmay reference aconstdeclared 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, andexport … from) are still not followed.Resolving
let/varvalues would require reassignment tracking and, forvar, position and control-flow analysis to avoid inlining a value the runtime never had (hoisted callees, conditional initialization,break label, Annex Bcatch). Static style tokens are idiomaticallyconstanyway.Tag shadowing. A local binding named like the tag means the template is not ecij's
cssand 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/classare hoisted to their block,var/functions to their function). The tag is recognized through its import binding (cssTagNames), soimport { css as styled } from 'ecij'works as a by-product.Scopes.
vars hoist into the body rather than next to the parametersvardeclarator kind is tracked as a stack: a declarator following a sibling whose initializer contains nested declarations, like a function body, lost its kind, so avarwas bound to the block (and aconstwould no longer resolve)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
constcache only lives for one transform.Behavior changes vs
mainlet/varbindings with a literal initializer no longer resolve and warn insteadconstbindings of signed numbers now resolveconstbound to another binding that resolves, local or imported, now resolves too, also for the modules importing itTest plan
variable scopingandadvanced scopingsnapshots updated for theconst-only rule (§7var, §11 for-headlet, and §23varin a block now warn). New advanced sections 32–36:vardeclarator kind stack,letnot resolved, parameter defaults vs. bodyconst/var, andvar-declared templates resolving where they appearscoping.input.ts, so it now records that fixture'svarwarningtag-shadow-hoistingfixture and test: laterconst/var/function/class, self-reference, enclosing-scope declaration, nested-block negative control, and module level. Only the two unshadowed templates are extractedliteral-interpolationgains aconstbound to a signed numberconst-aliasfixture 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 aletand a cycle warnconstcycle 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 bothnode --run test(locally and withCI=trueagainstdist/),lint,typecheck,format:check, andbuildpass.src/index.tsstatement coverage is 99.6%, and its one uncovered line (TypeScript parameter properties) is uncovered onmaintooRelationship to #68
The code here started out textually identical to the corresponding regions of #68. Merging
maininto 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 mergingmainback into #68 afterwards will need conflict resolution in these regions too.Candidate next slices:
enum/namespace/import =,moduleType-aware parsing)moduleSideEffectsis no longer needed:mainkeeps such modules alive with IIFE class names)🤖 Generated with Claude Code