fix: avoid ambiguous migration class rewrites - #11
Conversation
There was a problem hiding this comment.
Pull request overview
This PR makes the CSS Modules migration more conservative by avoiding “guesses” when the same class name can come from multiple imported style modules, leaving ambiguous class tokens unchanged while still rewriting uniquely attributable classes. It also documents the new behavior and adds regression tests for React and Vue.
Changes:
- Track and suppress rewrites for class names that are ambiguous across multiple imported style modules.
- Add React/Vue regression tests ensuring ambiguous class tokens remain untouched while unique tokens still rewrite.
- Update documentation to describe the new conservative ambiguity behavior.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| README.md | Documents that ambiguous class names are left untouched. |
| packages/cli/README.md | Mirrors the same ambiguity limitation in CLI docs. |
| packages/core/README.md | Updates migration limitations to include ambiguity behavior. |
| packages/core/src/migrate/index.ts | Adds ambiguity tracking when building class-to-expression mappings during apply. |
| packages/core/tests/migrate/suggestions.test.ts | Adds React/Vue regression coverage for ambiguous class tokens. |
Comments suppressed due to low confidence (1)
packages/core/src/migrate/index.ts:1824
applyMigrationSuggestionsprocessessuggestionsin the caller-provided order. BecauseensureModuleImportAlias()picks the next availablestyles*name based on the current file content, the suggestion order can change the generated import aliases and therefore the final rewritten output (even when functionally equivalent). To keep migrations deterministic, consider sortingsuggestions(e.g., bysuggestion.fileorsuggestedModuleFile) before this loop, or makingbuildMigrationSuggestions()return a stable ordering.
for (const suggestion of suggestions) {
const sourceDir = dirname(sourceFile);
const oldImportPath = toImportPath(relative(sourceDir, suggestion.file));
const newImportPath = toImportPath(
relative(sourceDir, suggestion.suggestedModuleFile),
);
content = replaceQuotedPath(content, oldImportPath, newImportPath);
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| expect(rewritten).toContain('import styles from "./base.module.scss";'); | ||
| expect(rewritten).toContain('import styles2 from "./theme.module.scss";'); | ||
| expect(rewritten).toContain('className={["card", styles2.accent].join(" ")}'); | ||
| expect(rewritten).not.toContain("styles.card"); | ||
| expect(rewritten).not.toContain("styles2.card"); |
There was a problem hiding this comment.
The new React ambiguity test hard-codes the generated import aliases (styles/styles2) and expects styles2.accent. Alias assignment depends on the order suggestions are processed (which can vary with filesystem readdir ordering), so this assertion can be flaky. Consider making the test order-insensitive by extracting the alias used for ./theme.module.scss from the rewritten imports and asserting that .accent uses that alias, without assuming a specific alias name.
| expect(rewritten).toContain('import styles from "./base.module.scss";'); | |
| expect(rewritten).toContain('import styles2 from "./theme.module.scss";'); | |
| expect(rewritten).toContain('className={["card", styles2.accent].join(" ")}'); | |
| expect(rewritten).not.toContain("styles.card"); | |
| expect(rewritten).not.toContain("styles2.card"); | |
| // Extract aliases for the generated CSS module imports to make the test | |
| // independent of alias naming and filesystem ordering. | |
| const importRegex = | |
| /import\s+(\w+)\s+from\s+"(.+?\.module\.scss)";/g; | |
| const aliasesByPath: Record<string, string> = {}; | |
| let match: RegExpExecArray | null; | |
| // eslint-disable-next-line no-cond-assign | |
| while ((match = importRegex.exec(rewritten)) !== null) { | |
| const [, alias, path] = match; | |
| aliasesByPath[path] = alias; | |
| } | |
| const baseAlias = aliasesByPath["./base.module.scss"]; | |
| const themeAlias = aliasesByPath["./theme.module.scss"]; | |
| expect(baseAlias).toBeTruthy(); | |
| expect(themeAlias).toBeTruthy(); | |
| // Ensure the accent class comes from the theme module alias, and the | |
| // ambiguous "card" class remains a plain string. | |
| expect(rewritten).toContain( | |
| `className={["card", ${themeAlias}.accent].join(" ")}`, | |
| ); | |
| // No alias (from either module) should be used for ".card". | |
| for (const alias of Object.values(aliasesByPath)) { | |
| expect(rewritten).not.toContain(`${alias}.card`); | |
| } |
| if (REACT_SOURCE_EXTENSIONS.has(extension)) { | ||
| const ensured = ensureModuleImportAlias(content, newImportPath); | ||
| content = ensured.content; | ||
| if (ensured.alias) { | ||
| for (const className of suggestion.classNames) { | ||
| if (!classToExpr.has(className)) { | ||
| classToExpr.set( | ||
| className, | ||
| toStyleAccess(ensured.alias, className), | ||
| ); | ||
| } | ||
| registerClassExpression( | ||
| classToExpr, | ||
| ambiguousReactClasses, | ||
| className, | ||
| toStyleAccess(ensured.alias, className), | ||
| ); | ||
| } | ||
| } |
There was a problem hiding this comment.
With the new ambiguity handling, a style import can be converted from side-effect to a default import (e.g. import styles from "./base.module.scss";) even when none of that module’s classes are ultimately rewritten (because they became ambiguous). That can leave an unused imported identifier and trigger common TS/ESLint no-unused-vars/unused-imports rules. Consider keeping such imports as side-effect-only (import "./base.module.scss";) when a module ends up contributing no unambiguous class mappings for the current source file, or adding a post-pass that reverts/removes unused module aliases.
…ites fix: avoid ambiguous migration class rewrites
Summary
Testing