tempest-react-sdk 0.28.1 → 0.29.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,304 @@
1
+ import { createRequire } from "node:module";
2
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
+
7
+ import { analyzeCss } from "./analyze.mjs";
8
+ import { applyExtraction, classNamesIn, kebab, loneClass, planExtraction } from "./extract.mjs";
9
+ import { parseCss } from "./parse.mjs";
10
+
11
+ const SELF_DIR = resolve(dirname(new URL(import.meta.url).pathname), "..", "..");
12
+ const TYPESCRIPT_DIR = dirname(createRequire(import.meta.url).resolve("typescript/package.json"));
13
+
14
+ let root;
15
+
16
+ /** Write a file inside the fixture project, creating its directory. */
17
+ function write(rel, text) {
18
+ const full = join(root, rel);
19
+ mkdirSync(dirname(full), { recursive: true });
20
+ writeFileSync(full, text);
21
+ return full;
22
+ }
23
+
24
+ const ROW = `.row {
25
+ display: flex;
26
+ align-items: center;
27
+ gap: 8px;
28
+ }
29
+ .title {
30
+ font-weight: 600;
31
+ }
32
+ `;
33
+
34
+ /**
35
+ * A minimal project the codemod can act on: a tsconfig with the `@/*` alias, a
36
+ * global stylesheet that the entry imports, and the project's own TypeScript
37
+ * (symlinked, because the CLI resolves the compiler from the project on purpose).
38
+ */
39
+ function project() {
40
+ write("package.json", JSON.stringify({ name: "fixture" }));
41
+ write(
42
+ "tsconfig.json",
43
+ JSON.stringify({ compilerOptions: { baseUrl: ".", paths: { "@/*": ["./src/*"] } } }),
44
+ );
45
+ write("src/index.css", ":root {\n --brand: #0d6efd;\n}\n");
46
+ write("src/main.tsx", 'import "./index.css";\n');
47
+ mkdirSync(join(root, "node_modules"), { recursive: true });
48
+ symlinkSync(TYPESCRIPT_DIR, join(root, "node_modules", "typescript"), "dir");
49
+ }
50
+
51
+ /** Two modules declaring the same block, each used from its own component. */
52
+ function twoCopies() {
53
+ write("src/components/Card.module.css", ROW);
54
+ write(
55
+ "src/components/Card.tsx",
56
+ 'import styles from "./Card.module.css";\n\nexport const Card = () => (\n <div className={styles.row}>\n <b className={styles.title}>x</b>\n </div>\n);\n',
57
+ );
58
+ write("src/components/List.module.css", ROW.replace(".row", ".line"));
59
+ write(
60
+ "src/components/List.tsx",
61
+ 'import styles from "@/components/List.module.css";\n\nexport const List = () => (\n <li className={styles.line}>\n <b className={styles.title}>x</b>\n </li>\n);\n',
62
+ );
63
+ }
64
+
65
+ const plan = (options = {}) =>
66
+ planExtraction({
67
+ analysis: analyzeCss({ root, targets: ["."], selfDir: SELF_DIR }),
68
+ root,
69
+ ...options,
70
+ });
71
+
72
+ beforeEach(() => {
73
+ root = mkdtempSync(join(tmpdir(), "tempest-extract-"));
74
+ });
75
+
76
+ afterEach(() => {
77
+ rmSync(root, { recursive: true, force: true });
78
+ });
79
+
80
+ describe("helpers", () => {
81
+ it("recognizes a lone class selector and rejects anything else", () => {
82
+ expect(loneClass(".row")).toBe("row");
83
+ expect(loneClass(" .card-header ")).toBe("card-header");
84
+ expect(loneClass(".row:hover")).toBeNull();
85
+ expect(loneClass(".a .b")).toBeNull();
86
+ expect(loneClass(".a.b")).toBeNull();
87
+ expect(loneClass("div")).toBeNull();
88
+ });
89
+
90
+ it("kebab-cases a camelCase class name", () => {
91
+ expect(kebab("cardHeader")).toBe("card-header");
92
+ expect(kebab("row")).toBe("row");
93
+ expect(kebab("grid_2")).toBe("grid-2");
94
+ });
95
+
96
+ it("collects every class a sheet defines", () => {
97
+ const parsed = parseCss(".a .b { color: red; }\n.c:hover { color: blue; }\n");
98
+ expect(classNamesIn(parsed)).toEqual(new Set(["a", "b", "c"]));
99
+ });
100
+ });
101
+
102
+ describe("planExtraction — the happy path", () => {
103
+ beforeEach(() => {
104
+ project();
105
+ twoCopies();
106
+ });
107
+
108
+ it("plans one global class from two modules, with every call site", () => {
109
+ const result = plan();
110
+ expect(result.status).toBe("ok");
111
+ expect(result.target.file).toBe(join("src", "index.css"));
112
+ expect(result.groups).toHaveLength(1);
113
+
114
+ const [group] = result.groups;
115
+ expect(group.name).toBe("u-row");
116
+ expect(group.decls.map((d) => d.prop)).toEqual(["display", "align-items", "gap"]);
117
+ expect(group.occurrences.map((o) => o.className).sort()).toEqual(["line", "row"]);
118
+ expect(group.occurrences.flatMap((o) => o.sites)).toHaveLength(2);
119
+ });
120
+
121
+ it("honors --css-prefix", () => {
122
+ expect(plan({ prefix: "shared-" }).groups[0].name).toBe("shared-row");
123
+ });
124
+
125
+ it("honors an explicit --css-target", () => {
126
+ write("src/styles/app.css", "");
127
+ write("src/main.tsx", 'import "./styles/app.css";\n');
128
+ expect(plan({ target: "src/styles/app.css" }).target.file).toBe(
129
+ join("src", "styles", "app.css"),
130
+ );
131
+ });
132
+ });
133
+
134
+ describe("applyExtraction", () => {
135
+ beforeEach(() => {
136
+ project();
137
+ twoCopies();
138
+ });
139
+
140
+ it("moves the rule, deletes the local copies and rewrites the JSX", () => {
141
+ const result = applyExtraction({ plan: plan() });
142
+ expect(result.moved).toBe(2);
143
+ expect(result.rules).toBe(1);
144
+
145
+ const global = readFileSync(join(root, "src/index.css"), "utf8");
146
+ expect(global).toContain(".u-row {");
147
+ expect(global).toContain("align-items: center;");
148
+ expect(global).toContain("extraído por");
149
+
150
+ const card = readFileSync(join(root, "src/components/Card.module.css"), "utf8");
151
+ expect(card).not.toContain(".row");
152
+ expect(card).toContain(".title");
153
+
154
+ // `className={styles.row}` loses the braces; the other class is untouched.
155
+ const tsx = readFileSync(join(root, "src/components/Card.tsx"), "utf8");
156
+ expect(tsx).toContain('<div className="u-row">');
157
+ expect(tsx).toContain("{styles.title}");
158
+ });
159
+
160
+ it("writes nothing on a dry run", () => {
161
+ const before = readFileSync(join(root, "src/components/Card.tsx"), "utf8");
162
+ const result = applyExtraction({ plan: plan(), dryRun: true });
163
+ expect(result.moved).toBe(2);
164
+ expect(readFileSync(join(root, "src/components/Card.tsx"), "utf8")).toBe(before);
165
+ expect(readFileSync(join(root, "src/index.css"), "utf8")).not.toContain(".u-row");
166
+ });
167
+
168
+ it("leaves nothing to do on a second run", () => {
169
+ applyExtraction({ plan: plan() });
170
+ expect(plan().groups).toEqual([]);
171
+ });
172
+
173
+ it("rewrites a class read through a string index", () => {
174
+ write(
175
+ "src/components/List.tsx",
176
+ 'import styles from "./List.module.css";\n\nexport const List = () => <li className={styles["line"]} />;\n',
177
+ );
178
+ applyExtraction({ plan: plan() });
179
+ expect(readFileSync(join(root, "src/components/List.tsx"), "utf8")).toContain(
180
+ 'className="u-row"',
181
+ );
182
+ });
183
+
184
+ it("keeps the surrounding call when the class is one argument of many", () => {
185
+ write(
186
+ "src/components/List.tsx",
187
+ 'import { cn } from "tempest-react-sdk";\nimport styles from "./List.module.css";\n\nexport const List = ({ on }: { on: boolean }) => (\n <li className={cn(styles.line, on && styles.title)} />\n);\n',
188
+ );
189
+ applyExtraction({ plan: plan() });
190
+ expect(readFileSync(join(root, "src/components/List.tsx"), "utf8")).toContain(
191
+ 'cn("u-row", on && styles.title)',
192
+ );
193
+ });
194
+ });
195
+
196
+ describe("planExtraction — refusals", () => {
197
+ beforeEach(project);
198
+
199
+ /** The reason attached to the first refusal, for a fixture built by the caller. */
200
+ const refusal = () => plan().refusals[0]?.reason ?? "";
201
+
202
+ it("refuses a class another rule in the same module mentions", () => {
203
+ twoCopies();
204
+ write("src/components/Card.module.css", `${ROW}.row:hover {\n opacity: 0.8;\n}\n`);
205
+ expect(refusal()).toContain("ficaria sem sujeito");
206
+ expect(plan().groups).toEqual([]);
207
+ });
208
+
209
+ it("refuses a rule inside an at-rule", () => {
210
+ twoCopies();
211
+ write(
212
+ "src/components/Card.module.css",
213
+ `.title {\n font-weight: 600;\n}\n@media (min-width: 600px) {\n .row {\n display: flex;\n align-items: center;\n gap: 8px;\n }\n}\n`,
214
+ );
215
+ expect(refusal()).toContain("mudaria quando a regra vale");
216
+ });
217
+
218
+ it("refuses when the module would be left empty", () => {
219
+ twoCopies();
220
+ write(
221
+ "src/components/Card.module.css",
222
+ ".row {\n display: flex;\n align-items: center;\n gap: 8px;\n}\n",
223
+ );
224
+ expect(refusal()).toContain("única regra do módulo");
225
+ });
226
+
227
+ it("refuses a module used through a computed key", () => {
228
+ twoCopies();
229
+ write(
230
+ "src/components/Card.tsx",
231
+ 'import styles from "./Card.module.css";\n\nexport const Card = ({ k }: { k: string }) => (\n <div className={styles[k]}>\n <b className={styles.row} />\n </div>\n);\n',
232
+ );
233
+ expect(refusal()).toContain("não estática");
234
+ });
235
+
236
+ it("refuses a module whose styles object is passed around whole", () => {
237
+ twoCopies();
238
+ write(
239
+ "src/components/Card.tsx",
240
+ 'import styles from "./Card.module.css";\n\nexport const Card = () => (\n <div className={styles.row} data-keys={Object.keys(styles).length} />\n);\n',
241
+ );
242
+ expect(refusal()).toContain("usado inteiro");
243
+ });
244
+
245
+ it("refuses a class no source file reads", () => {
246
+ twoCopies();
247
+ write(
248
+ "src/components/Card.tsx",
249
+ 'import styles from "./Card.module.css";\n\nexport const Card = () => <div className={styles.title} />;\n',
250
+ );
251
+ expect(refusal()).toContain("código morto");
252
+ });
253
+
254
+ it("needs the block in two different files", () => {
255
+ write(
256
+ "src/components/Card.module.css",
257
+ `${ROW}.other {\n display: flex;\n align-items: center;\n gap: 8px;\n}\n`,
258
+ );
259
+ write(
260
+ "src/components/Card.tsx",
261
+ 'import styles from "./Card.module.css";\n\nexport const Card = () => (\n <div className={styles.row}>\n <b className={styles.other} />\n <i className={styles.title} />\n </div>\n);\n',
262
+ );
263
+ expect(plan().groups).toEqual([]);
264
+ });
265
+
266
+ it("refuses when the new name is already taken in the global sheet", () => {
267
+ twoCopies();
268
+ write("src/index.css", ".u-row {\n display: block;\n}\n");
269
+ const result = plan();
270
+ expect(result.groups).toEqual([]);
271
+ expect(result.refusals[0].reason).toContain("já existe na folha global");
272
+ });
273
+
274
+ it("reports a missing global stylesheet instead of creating one", () => {
275
+ twoCopies();
276
+ rmSync(join(root, "src/index.css"));
277
+ const result = plan();
278
+ expect(result.status).toBe("no-target");
279
+ expect(result.message).toContain("nenhuma folha global");
280
+ });
281
+
282
+ it("refuses a global stylesheet nobody imports", () => {
283
+ twoCopies();
284
+ write("src/main.tsx", "export const main = 1;\n");
285
+ const result = plan();
286
+ expect(result.status).toBe("no-target");
287
+ expect(result.message).toContain("não é importada");
288
+ });
289
+
290
+ it("refuses a CSS Module as the target", () => {
291
+ twoCopies();
292
+ write("src/theme.module.css", ".x { color: red; }\n");
293
+ const result = plan({ target: "src/theme.module.css" });
294
+ expect(result.status).toBe("no-target");
295
+ expect(result.message).toContain("é um CSS Module");
296
+ });
297
+
298
+ it("reports a missing TypeScript instead of guessing at the call sites", () => {
299
+ twoCopies();
300
+ rmSync(join(root, "node_modules", "typescript"), { recursive: true, force: true });
301
+ const result = plan();
302
+ expect(result.status).toBe("no-typescript");
303
+ });
304
+ });
@@ -1,5 +1,6 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
3
+ import { createRequire } from "node:module";
3
4
  import { tmpdir } from "node:os";
4
5
  import { dirname, join } from "node:path";
5
6
  import { fileURLToPath } from "node:url";
@@ -97,3 +98,93 @@ describe("tempest fix — the CSS pass", () => {
97
98
  expect(code).toBe(1);
98
99
  });
99
100
  });
101
+
102
+ const TYPESCRIPT_DIR = dirname(createRequire(import.meta.url).resolve("typescript/package.json"));
103
+
104
+ /**
105
+ * A fixture with two modules declaring the same block, each read from its own
106
+ * component, plus the global sheet the entry imports and the project's own
107
+ * TypeScript — everything `--extract-css` refuses to work without.
108
+ */
109
+ function extractableProject() {
110
+ const rule =
111
+ ".row {\n display: flex;\n align-items: center;\n gap: 8px;\n}\n.title {\n font-weight: 600;\n}\n";
112
+ writeFileSync(
113
+ join(root, "package.json"),
114
+ JSON.stringify({ name: "fixture", devDependencies: { typescript: "^5" } }),
115
+ );
116
+ write(
117
+ "tsconfig.json",
118
+ JSON.stringify({ compilerOptions: { baseUrl: ".", paths: { "@/*": ["./src/*"] } } }),
119
+ );
120
+ write("src/index.css", ":root {\n --brand: #0d6efd;\n}\n");
121
+ write("src/main.tsx", 'import "./index.css";\n');
122
+ write("src/components/Card.module.css", rule);
123
+ write(
124
+ "src/components/Card.tsx",
125
+ 'import styles from "./Card.module.css";\n\nexport const Card = () => (\n <div className={styles.row}>\n <b className={styles.title} />\n </div>\n);\n',
126
+ );
127
+ write("src/components/List.module.css", rule.replace(".row", ".line"));
128
+ write(
129
+ "src/components/List.tsx",
130
+ 'import styles from "@/components/List.module.css";\n\nexport const List = () => (\n <li className={styles.line}>\n <b className={styles.title} />\n </li>\n);\n',
131
+ );
132
+ mkdirSync(join(root, "node_modules"), { recursive: true });
133
+ symlinkSync(TYPESCRIPT_DIR, join(root, "node_modules", "typescript"), "dir");
134
+ }
135
+
136
+ describe("tempest fix --extract-css", () => {
137
+ it("moves the repeated block into the global sheet and rewrites the JSX", () => {
138
+ extractableProject();
139
+ const { out } = fix(["--extract-css"]);
140
+ expect(out).toContain("css extract");
141
+ expect(out).toContain("movidas 2 regra(s)");
142
+ expect(readFileSync(join(root, "src/index.css"), "utf8")).toContain(".u-row {");
143
+ expect(readFileSync(join(root, "src/components/Card.tsx"), "utf8")).toContain(
144
+ 'className="u-row"',
145
+ );
146
+ expect(readFileSync(join(root, "src/components/Card.module.css"), "utf8")).not.toContain(
147
+ ".row",
148
+ );
149
+ });
150
+
151
+ it("writes nothing under --dry-run", () => {
152
+ extractableProject();
153
+ const before = readFileSync(join(root, "src/components/Card.tsx"), "utf8");
154
+ const { out } = fix(["--extract-css", "--dry-run"]);
155
+ expect(out).toContain("moveria 2 regra(s)");
156
+ expect(readFileSync(join(root, "src/components/Card.tsx"), "utf8")).toBe(before);
157
+ });
158
+
159
+ it("honors --css-prefix without treating its value as a path", () => {
160
+ extractableProject();
161
+ fix(["--extract-css", "--css-prefix", "shared-"]);
162
+ expect(readFileSync(join(root, "src/index.css"), "utf8")).toContain(".shared-row {");
163
+ });
164
+
165
+ it("does nothing extra when the flag is absent", () => {
166
+ extractableProject();
167
+ const { out } = fix([]);
168
+ expect(out).not.toContain("css extract");
169
+ expect(readFileSync(join(root, "src/index.css"), "utf8")).not.toContain(".u-row");
170
+ });
171
+
172
+ it("rejects --css-target without --extract-css", () => {
173
+ extractableProject();
174
+ const { out, code } = fix(["--css-target", "src/index.css"]);
175
+ expect(out).toContain("só valem com --extract-css");
176
+ expect(code).toBe(1);
177
+ });
178
+
179
+ it("reports the reason instead of extracting what it cannot prove safe", () => {
180
+ extractableProject();
181
+ write(
182
+ "src/components/Card.module.css",
183
+ ".row {\n display: flex;\n align-items: center;\n gap: 8px;\n}\n.row:hover {\n opacity: 0.8;\n}\n.title {\n font-weight: 600;\n}\n",
184
+ );
185
+ const { out } = fix(["--extract-css"]);
186
+ expect(out).toContain("não extraído");
187
+ expect(out).toContain("nada a extrair");
188
+ expect(readFileSync(join(root, "src/index.css"), "utf8")).not.toContain(".u-row");
189
+ });
190
+ });
@@ -3,6 +3,16 @@ export { analyzeCss, applyCssFixes } from "./analyze.mjs";
3
3
  export { collectStylesheets, customPropertiesInSources, looksMinified } from "./collect.mjs";
4
4
  export { countBySeverity, finding, SEVERITY, sortFindings } from "./findings.mjs";
5
5
  export { fixCss } from "./fix.mjs";
6
+ export {
7
+ applyExtraction,
8
+ classNamesIn,
9
+ findGlobalSheet,
10
+ kebab,
11
+ loneClass,
12
+ planExtraction,
13
+ renderRule,
14
+ } from "./extract.mjs";
15
+ export { collectSources, findClassUses, indexClassUses, resolveSpecifier } from "./references.mjs";
6
16
  export { maskValue, normalizeSelectors, parseCss, stripComments } from "./parse.mjs";
7
17
  export { distance, KNOWN_AT_RULES, KNOWN_PROPERTIES, nearest } from "./properties.mjs";
8
18
  export { matchUtility, repetitionFindings } from "./repetition.mjs";
@@ -0,0 +1,238 @@
1
+ // Where a CSS Module class is used in TypeScript.
2
+ //
3
+ // Extraction cannot be a CSS-only edit: deleting `.row` from `Card.module.css`
4
+ // leaves `styles.row` in `Card.tsx` evaluating to `undefined`, which renders an
5
+ // element with `class="undefined"` and no styling — valid code, silent damage. So
6
+ // the codemod only touches a class it can see **every** use of.
7
+ //
8
+ // "Every use" is decided with the project's own TypeScript compiler rather than a
9
+ // regex: `styles.row` inside a comment, a template literal or a string is not a
10
+ // use, and a regex cannot tell. When anything about a module's usage is not
11
+ // statically decidable — `styles[key]`, `Object.keys(styles)`, the object handed
12
+ // to a child as a prop — the whole module is declared opaque and nothing in it is
13
+ // extracted.
14
+ import { readdirSync, readFileSync } from "node:fs";
15
+ import { dirname, extname, join, relative, resolve } from "node:path";
16
+
17
+ const SOURCE_EXTS = new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]);
18
+ const SKIP_DIRS = new Set([
19
+ "node_modules",
20
+ "dist",
21
+ "build",
22
+ "out",
23
+ "coverage",
24
+ ".git",
25
+ ".vite",
26
+ ".next",
27
+ ".turbo",
28
+ ".cache",
29
+ ]);
30
+
31
+ /** Every source file under `root` the codemod may read. */
32
+ export function collectSources({ root, maxFiles = 4000 }) {
33
+ const out = [];
34
+ const walk = (dir) => {
35
+ if (out.length >= maxFiles) return;
36
+ let entries;
37
+ try {
38
+ entries = readdirSync(dir, { withFileTypes: true });
39
+ } catch {
40
+ return;
41
+ }
42
+ for (const entry of entries) {
43
+ if (SKIP_DIRS.has(entry.name)) continue;
44
+ if (entry.name.startsWith(".") && entry.isDirectory()) continue;
45
+ const full = join(dir, entry.name);
46
+ if (entry.isDirectory()) walk(full);
47
+ else if (entry.isFile() && SOURCE_EXTS.has(extname(entry.name))) out.push(full);
48
+ }
49
+ };
50
+ walk(root);
51
+ return out.sort((a, b) => a.localeCompare(b));
52
+ }
53
+
54
+ /**
55
+ * Resolve an import specifier to an absolute path, honoring the project alias.
56
+ *
57
+ * @param {object} params
58
+ * @param {string} params.specifier - Raw module specifier.
59
+ * @param {string} params.fromFile - File the import is written in.
60
+ * @param {{ prefix: string, baseDir: string } | null} params.alias
61
+ * @returns {string | null} Absolute path, or `null` when it is a bare package.
62
+ */
63
+ export function resolveSpecifier({ specifier, fromFile, alias }) {
64
+ if (specifier.startsWith("./") || specifier.startsWith("../")) {
65
+ return resolve(dirname(fromFile), specifier);
66
+ }
67
+ if (alias && specifier.startsWith(`${alias.prefix}/`)) {
68
+ return resolve(alias.baseDir, specifier.slice(alias.prefix.length + 1));
69
+ }
70
+ return null;
71
+ }
72
+
73
+ /**
74
+ * Find every static use of a CSS Module's classes in one source file.
75
+ *
76
+ * @param {object} params
77
+ * @param {string} params.filePath
78
+ * @param {string} params.text - File contents.
79
+ * @param {object} params.ts - The project's `typescript` module.
80
+ * @param {Set<string>} params.moduleFiles - Absolute paths of the CSS Modules of interest.
81
+ * @param {{ prefix: string, baseDir: string } | null} params.alias
82
+ * @returns {{
83
+ * uses: Array<{ module: string, className: string, start: number, end: number, line: number }>,
84
+ * opaque: Array<{ module: string, reason: string, line: number }>,
85
+ * }}
86
+ */
87
+ export function findClassUses({ filePath, text, ts, moduleFiles, alias }) {
88
+ const uses = [];
89
+ const opaque = [];
90
+ const source = ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true);
91
+
92
+ /** Local identifier → absolute CSS Module path, for the modules we care about. */
93
+ const bindings = new Map();
94
+ for (const statement of source.statements) {
95
+ if (!ts.isImportDeclaration(statement)) continue;
96
+ const specifier = statement.moduleSpecifier;
97
+ if (!ts.isStringLiteral(specifier)) continue;
98
+ const target = resolveSpecifier({ specifier: specifier.text, fromFile: filePath, alias });
99
+ if (!target || !moduleFiles.has(target)) continue;
100
+ const clause = statement.importClause;
101
+ if (clause?.name) bindings.set(clause.name.text, target);
102
+ // `import * as styles from "./x.module.css"` behaves the same for our purposes.
103
+ if (clause?.namedBindings && ts.isNamespaceImport(clause.namedBindings)) {
104
+ bindings.set(clause.namedBindings.name.text, target);
105
+ }
106
+ }
107
+ if (bindings.size === 0) return { uses, opaque };
108
+
109
+ const lineOf = (pos) => source.getLineAndCharacterOfPosition(pos).line + 1;
110
+
111
+ /**
112
+ * Range to replace with the new class string.
113
+ *
114
+ * `className={styles.row}` widens to the whole `{…}` container so the result
115
+ * is `className="u-row"` rather than `className={"u-row"}` — the braces are
116
+ * only there because the value was an expression, and leaving them behind
117
+ * would make every rewritten attribute read like generated code.
118
+ */
119
+ const replacementRange = (access) => {
120
+ const parent = access.parent;
121
+ if (
122
+ parent &&
123
+ ts.isJsxExpression(parent) &&
124
+ parent.expression === access &&
125
+ parent.parent &&
126
+ ts.isJsxAttribute(parent.parent)
127
+ ) {
128
+ return { start: parent.getStart(source), end: parent.getEnd() };
129
+ }
130
+ return { start: access.getStart(source), end: access.getEnd() };
131
+ };
132
+
133
+ /**
134
+ * Record a use, or mark the module opaque when the reference is not a plain
135
+ * `styles.name` / `styles["name"]` read.
136
+ */
137
+ const visit = (node) => {
138
+ if (ts.isIdentifier(node) && bindings.has(node.text)) {
139
+ const module = bindings.get(node.text);
140
+ const parent = node.parent;
141
+ const isImportName =
142
+ parent &&
143
+ (ts.isImportClause(parent) || ts.isNamespaceImport(parent)) &&
144
+ parent.name === node;
145
+ if (isImportName) return;
146
+
147
+ if (parent && ts.isPropertyAccessExpression(parent) && parent.expression === node) {
148
+ uses.push({
149
+ module,
150
+ className: parent.name.text,
151
+ ...replacementRange(parent),
152
+ line: lineOf(parent.getStart(source)),
153
+ });
154
+ return;
155
+ }
156
+ if (
157
+ parent &&
158
+ ts.isElementAccessExpression(parent) &&
159
+ parent.expression === node &&
160
+ parent.argumentExpression &&
161
+ ts.isStringLiteralLike(parent.argumentExpression)
162
+ ) {
163
+ uses.push({
164
+ module,
165
+ className: parent.argumentExpression.text,
166
+ ...replacementRange(parent),
167
+ line: lineOf(parent.getStart(source)),
168
+ });
169
+ return;
170
+ }
171
+ opaque.push({
172
+ module,
173
+ reason:
174
+ parent && ts.isElementAccessExpression(parent)
175
+ ? "acesso dinâmico (`styles[expr]`)"
176
+ : "o objeto de estilos é usado inteiro, não por classe",
177
+ line: lineOf(node.getStart(source)),
178
+ });
179
+ return;
180
+ }
181
+ ts.forEachChild(node, visit);
182
+ };
183
+
184
+ ts.forEachChild(source, visit);
185
+ return { uses, opaque };
186
+ }
187
+
188
+ /**
189
+ * Index every static class use across the project.
190
+ *
191
+ * @param {object} params
192
+ * @param {string} params.root
193
+ * @param {object} params.ts
194
+ * @param {Iterable<string>} params.modulePaths - Absolute paths of CSS Modules.
195
+ * @param {{ prefix: string, baseDir: string } | null} params.alias
196
+ * @returns {{
197
+ * byModule: Map<string, Map<string, Array<{ file: string, start: number, end: number, line: number }>>>,
198
+ * opaqueModules: Map<string, { file: string, reason: string, line: number }>,
199
+ * texts: Map<string, string>,
200
+ * }}
201
+ */
202
+ export function indexClassUses({ root, ts, modulePaths, alias }) {
203
+ const moduleFiles = new Set(modulePaths);
204
+ const byModule = new Map();
205
+ const opaqueModules = new Map();
206
+ const texts = new Map();
207
+
208
+ for (const file of collectSources({ root })) {
209
+ let text;
210
+ try {
211
+ text = readFileSync(file, "utf8");
212
+ } catch {
213
+ continue;
214
+ }
215
+ if (!text.includes(".module.css")) continue;
216
+ const { uses, opaque } = findClassUses({ filePath: file, text, ts, moduleFiles, alias });
217
+ if (uses.length === 0 && opaque.length === 0) continue;
218
+ texts.set(file, text);
219
+ for (const entry of opaque) {
220
+ if (!opaqueModules.has(entry.module)) {
221
+ opaqueModules.set(entry.module, {
222
+ file: relative(root, file),
223
+ reason: entry.reason,
224
+ line: entry.line,
225
+ });
226
+ }
227
+ }
228
+ for (const use of uses) {
229
+ const classes = byModule.get(use.module) ?? new Map();
230
+ const sites = classes.get(use.className) ?? [];
231
+ sites.push({ file, start: use.start, end: use.end, line: use.line });
232
+ classes.set(use.className, sites);
233
+ byModule.set(use.module, classes);
234
+ }
235
+ }
236
+
237
+ return { byModule, opaqueModules, texts };
238
+ }