tempest-react-sdk 0.28.1 → 0.29.0

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,406 @@
1
+ // `tempest fix --extract-css` — move a declaration block that repeats across CSS
2
+ // Modules into the project's global stylesheet, and rewrite the JSX that pointed
3
+ // at the local copies.
4
+ //
5
+ // Opt-in, and it stays opt-in. The other passes of `fix` remove things that are
6
+ // provably dead; this one makes a **design** decision — that N screens should now
7
+ // share one class, and therefore change together. That is a call about coupling,
8
+ // so it happens only when somebody asks for it by name.
9
+ //
10
+ // Everything here is refusal-first. A group is extracted only when every one of
11
+ // these holds, and each refusal is reported with its reason rather than skipped
12
+ // in silence:
13
+ //
14
+ // - every occurrence is a lone class selector (`.row`), outside any at-rule;
15
+ // - no other rule in that module mentions the class (a `:hover`, a descendant
16
+ // selector or a `@media` override would stay behind and lose its subject);
17
+ // - the module keeps at least one other rule, so its import does not become
18
+ // dead and take the remaining styles down with it;
19
+ // - the class is only ever read as `styles.row` / `styles["row"]`, never
20
+ // through a computed key or by handing the whole object around;
21
+ // - the target global sheet exists AND some source file imports it, because
22
+ // appending to a stylesheet nobody loads is a silent no-op;
23
+ // - the new global name collides with nothing already in that sheet.
24
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
25
+ import { join, relative } from "node:path";
26
+
27
+ import { discoverAlias } from "../alias/discover.mjs";
28
+ import { loadTypeScript } from "../alias/typescript.mjs";
29
+ import { collectSources, indexClassUses, resolveSpecifier } from "./references.mjs";
30
+ import { parseCss } from "./parse.mjs";
31
+ import { declSignature } from "./semantic.mjs";
32
+
33
+ /** A repeated block is only worth extracting from this many declarations up. */
34
+ const MIN_DECLS = 3;
35
+
36
+ /** Stylesheets treated as "the project's global sheet", in order of preference. */
37
+ const TARGET_CANDIDATES = [
38
+ "src/styles/globals.css",
39
+ "src/styles/global.css",
40
+ "src/globals.css",
41
+ "src/global.css",
42
+ "src/index.css",
43
+ "src/app.css",
44
+ "app/globals.css",
45
+ "styles/globals.css",
46
+ "index.css",
47
+ ];
48
+
49
+ /** `.row` → `row`, or `null` when the selector is anything more than one class. */
50
+ export function loneClass(prelude) {
51
+ const match = /^\.([A-Za-z_][A-Za-z0-9_-]*)$/.exec(prelude.trim());
52
+ return match ? match[1] : null;
53
+ }
54
+
55
+ /** `cardHeader` → `card-header`. */
56
+ export function kebab(name) {
57
+ return name
58
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
59
+ .replace(/[_\s]+/g, "-")
60
+ .toLowerCase();
61
+ }
62
+
63
+ /** Class names a stylesheet already defines, at any depth. */
64
+ export function classNamesIn(parsed) {
65
+ const names = new Set();
66
+ for (const block of parsed.blocks) {
67
+ for (const match of block.prelude.matchAll(/\.([A-Za-z_][A-Za-z0-9_-]*)/g)) {
68
+ names.add(match[1]);
69
+ }
70
+ }
71
+ return names;
72
+ }
73
+
74
+ /**
75
+ * Pick the global stylesheet to append to, and prove somebody imports it.
76
+ *
77
+ * @param {object} params
78
+ * @param {string} params.root
79
+ * @param {string} [params.explicit] - Path from `--css-target`.
80
+ * @param {{ prefix: string, baseDir: string } | null} params.alias
81
+ * @returns {{ path: string, file: string } | { error: string }}
82
+ */
83
+ export function findGlobalSheet({ root, explicit, alias }) {
84
+ const candidates = explicit ? [explicit] : TARGET_CANDIDATES;
85
+ const found = candidates.map((rel) => join(root, rel)).find(existsSync);
86
+ if (!found) {
87
+ return {
88
+ error: explicit
89
+ ? `--css-target "${explicit}" não existe`
90
+ : `nenhuma folha global encontrada (procurei ${TARGET_CANDIDATES.join(", ")}) — passe --css-target <arquivo>`,
91
+ };
92
+ }
93
+ if (found.endsWith(".module.css")) {
94
+ return {
95
+ error: `${relative(root, found)} é um CSS Module — a folha alvo tem que ser global`,
96
+ };
97
+ }
98
+
99
+ const imported = collectSources({ root }).some((file) => {
100
+ let text;
101
+ try {
102
+ text = readFileSync(file, "utf8");
103
+ } catch {
104
+ return false;
105
+ }
106
+ if (!text.includes(".css")) return false;
107
+ for (const match of text.matchAll(/["']([^"']+\.css)["']/g)) {
108
+ const target = resolveSpecifier({ specifier: match[1], fromFile: file, alias });
109
+ if (target === found) return true;
110
+ }
111
+ return false;
112
+ });
113
+ if (!imported) {
114
+ return {
115
+ error: `${relative(root, found)} não é importada por nenhum arquivo do projeto — o que fosse movido pra lá não carregaria`,
116
+ };
117
+ }
118
+ return { path: found, file: relative(root, found) };
119
+ }
120
+
121
+ /** Group the analysed sheets by identical declaration block. */
122
+ function groupBySignature(sheets) {
123
+ const groups = new Map();
124
+ for (const sheet of sheets) {
125
+ if (!sheet.isModule) continue;
126
+ for (const block of sheet.parsed.blocks) {
127
+ if (block.kind !== "rule" || block.decls.length < MIN_DECLS) continue;
128
+ const signature = declSignature(block);
129
+ const group = groups.get(signature) ?? { signature, entries: [] };
130
+ group.entries.push({ sheet, block });
131
+ groups.set(signature, group);
132
+ }
133
+ }
134
+ return [...groups.values()];
135
+ }
136
+
137
+ /**
138
+ * Why this occurrence cannot be extracted, or `null` when it can.
139
+ *
140
+ * @returns {string | null}
141
+ */
142
+ function occurrenceRefusal({ sheet, block, className, uses, opaqueModules }) {
143
+ if (block.context.length > 0) {
144
+ return `está dentro de \`${block.context.join(" › ")}\` — mover pra fora mudaria quando a regra vale`;
145
+ }
146
+ if (!className) return `\`${block.prelude}\` não é uma classe simples`;
147
+
148
+ const mentions = sheet.parsed.blocks.filter(
149
+ (other) =>
150
+ other !== block && new RegExp(`\\.${className}(?![A-Za-z0-9_-])`).test(other.prelude),
151
+ );
152
+ if (mentions.length > 0) {
153
+ return `outra regra na mesma folha usa \`.${className}\` (linha ${mentions[0].line}) e ficaria sem sujeito`;
154
+ }
155
+
156
+ const remaining = sheet.parsed.blocks.filter(
157
+ (other) => other !== block && other.kind === "rule",
158
+ );
159
+ if (remaining.length === 0) {
160
+ return "é a única regra do módulo — o import viraria código morto e levaria a folha inteira";
161
+ }
162
+
163
+ const opaque = opaqueModules.get(sheet.path);
164
+ if (opaque) {
165
+ return `o módulo é usado de forma não estática em ${opaque.file}:${opaque.line} — ${opaque.reason}`;
166
+ }
167
+
168
+ if (!uses || uses.length === 0) {
169
+ return `nenhum \`styles.${className}\` no código — a classe já é código morto, e mover não conserta isso`;
170
+ }
171
+ return null;
172
+ }
173
+
174
+ /**
175
+ * Build the extraction plan.
176
+ *
177
+ * @param {object} params
178
+ * @param {ReturnType<import("./analyze.mjs").analyzeCss>} params.analysis
179
+ * @param {string} params.root
180
+ * @param {string} [params.target] - Path from `--css-target`.
181
+ * @param {string} [params.prefix] - Prefix for the new global class names.
182
+ * @returns {{
183
+ * status: "ok" | "no-typescript" | "no-target",
184
+ * message?: string,
185
+ * target?: { path: string, file: string },
186
+ * groups: Array<object>,
187
+ * refusals: Array<{ file: string, line: number, reason: string }>,
188
+ * }}
189
+ */
190
+ export function planExtraction({ analysis, root, target, prefix = "u-" }) {
191
+ const ts = loadTypeScript(root);
192
+ if (!ts) {
193
+ return {
194
+ status: "no-typescript",
195
+ message:
196
+ "typescript não instalado — a reescrita do TSX precisa do compilador do projeto",
197
+ groups: [],
198
+ refusals: [],
199
+ };
200
+ }
201
+ const alias = discoverAlias({ root, ts });
202
+ const sheet = findGlobalSheet({ root, explicit: target, alias });
203
+ if (sheet.error) {
204
+ return { status: "no-target", message: sheet.error, groups: [], refusals: [] };
205
+ }
206
+
207
+ const modulePaths = analysis.sheets.filter((s) => s.isModule).map((s) => s.path);
208
+ const { byModule, opaqueModules } = indexClassUses({ root, ts, modulePaths, alias });
209
+
210
+ const targetText = readFileSync(sheet.path, "utf8");
211
+ const taken = classNamesIn(parseCss(targetText));
212
+ const groups = [];
213
+ const refusals = [];
214
+
215
+ for (const group of groupBySignature(analysis.sheets)) {
216
+ if (group.entries.length < 2) continue;
217
+
218
+ const eligible = [];
219
+ for (const entry of group.entries) {
220
+ const className = loneClass(entry.block.prelude);
221
+ const uses = className ? byModule.get(entry.sheet.path)?.get(className) : null;
222
+ const refusal = occurrenceRefusal({ ...entry, className, uses, opaqueModules });
223
+ if (refusal) {
224
+ refusals.push({ file: entry.sheet.file, line: entry.block.line, reason: refusal });
225
+ continue;
226
+ }
227
+ eligible.push({ ...entry, className, uses });
228
+ }
229
+
230
+ const files = new Set(eligible.map((e) => e.sheet.file));
231
+ if (eligible.length < 2 || files.size < 2) continue;
232
+
233
+ const name = `${prefix}${kebab(commonName(eligible))}`;
234
+ if (taken.has(name)) {
235
+ refusals.push({
236
+ file: sheet.file,
237
+ line: 1,
238
+ reason: `\`.${name}\` já existe na folha global — renomeie a classe local ou use --css-prefix`,
239
+ });
240
+ continue;
241
+ }
242
+ taken.add(name);
243
+
244
+ groups.push({
245
+ name,
246
+ decls: eligible[0].block.decls.map((d) => ({ prop: d.prop, value: d.value })),
247
+ occurrences: eligible.map((e) => ({
248
+ path: e.sheet.path,
249
+ file: e.sheet.file,
250
+ line: e.block.line,
251
+ className: e.className,
252
+ start: e.block.start,
253
+ end: e.block.end,
254
+ sites: e.uses.map((u) => ({
255
+ path: u.file,
256
+ file: relative(root, u.file),
257
+ line: u.line,
258
+ start: u.start,
259
+ end: u.end,
260
+ })),
261
+ })),
262
+ });
263
+ }
264
+
265
+ return { status: "ok", target: sheet, groups, refusals };
266
+ }
267
+
268
+ /**
269
+ * The name for the extracted class: the local name the codebase already uses
270
+ * most, by modules first and call sites second.
271
+ *
272
+ * A tie is the normal case — the copies live in different modules precisely
273
+ * because nobody agreed on a name — so the order of the occurrences breaks it,
274
+ * which keeps the result deterministic across runs. The chosen name is printed
275
+ * before anything is written; `--css-prefix` is there for when it needs a
276
+ * namespace rather than a different word.
277
+ */
278
+ function commonName(eligible) {
279
+ const counts = new Map();
280
+ for (const entry of eligible) {
281
+ const current = counts.get(entry.className) ?? { modules: 0, sites: 0 };
282
+ counts.set(entry.className, {
283
+ modules: current.modules + 1,
284
+ sites: current.sites + entry.uses.length,
285
+ });
286
+ }
287
+ return [...counts.entries()].sort(
288
+ (a, b) => b[1].modules - a[1].modules || b[1].sites - a[1].sites,
289
+ )[0][0];
290
+ }
291
+
292
+ /** Render the extracted rule for the global sheet. */
293
+ export function renderRule({ name, decls, occurrences }) {
294
+ const body = decls.map((d) => ` ${d.prop}: ${d.value};`).join("\n");
295
+ return [
296
+ `/* extraído por \`tempest fix --extract-css\` de ${occurrences.length} CSS Modules */`,
297
+ `.${name} {`,
298
+ body,
299
+ "}",
300
+ "",
301
+ ].join("\n");
302
+ }
303
+
304
+ /**
305
+ * Grow a removal range over the leading indentation and the trailing newline.
306
+ * Same rule as the dedupe pass, so a removed rule leaves no blank indented line.
307
+ */
308
+ function expandRange(text, start, end) {
309
+ let from = start;
310
+ while (from > 0 && (text[from - 1] === " " || text[from - 1] === "\t")) from -= 1;
311
+ if (from > 0 && text[from - 1] !== "\n") from = start;
312
+ let to = end;
313
+ while (to < text.length && (text[to] === " " || text[to] === "\t")) to += 1;
314
+ if (text[to] === "\r") to += 1;
315
+ if (text[to] === "\n") to += 1;
316
+ return { start: from, end: to };
317
+ }
318
+
319
+ /** Apply text edits back-to-front. */
320
+ function splice(text, edits) {
321
+ let out = text;
322
+ for (const edit of [...edits].sort((a, b) => b.start - a.start)) {
323
+ out = out.slice(0, edit.start) + (edit.text ?? "") + out.slice(edit.end);
324
+ }
325
+ return out;
326
+ }
327
+
328
+ /**
329
+ * Apply an extraction plan to disk.
330
+ *
331
+ * @param {object} params
332
+ * @param {ReturnType<typeof planExtraction>} params.plan
333
+ * @param {boolean} [params.dryRun]
334
+ * @returns {{
335
+ * moved: number,
336
+ * rules: number,
337
+ * files: Array<{ file: string, changes: string[] }>,
338
+ * errors: Array<{ file: string, message: string }>,
339
+ * }}
340
+ */
341
+ export function applyExtraction({ plan, dryRun = false }) {
342
+ const result = { moved: 0, rules: 0, files: [], errors: [] };
343
+ if (plan.status !== "ok" || plan.groups.length === 0) return result;
344
+
345
+ const edits = new Map();
346
+ const changes = new Map();
347
+ const record = (file, message) => {
348
+ changes.set(file, [...(changes.get(file) ?? []), message]);
349
+ };
350
+ const push = (path, edit) => {
351
+ edits.set(path, [...(edits.get(path) ?? []), edit]);
352
+ };
353
+
354
+ let appended = "";
355
+ for (const group of plan.groups) {
356
+ appended += `\n${renderRule(group)}`;
357
+ result.rules += 1;
358
+ for (const occurrence of group.occurrences) {
359
+ let text;
360
+ try {
361
+ text = readFileSync(occurrence.path, "utf8");
362
+ } catch (err) {
363
+ result.errors.push({
364
+ file: occurrence.file,
365
+ message: String(err?.message ?? err),
366
+ });
367
+ continue;
368
+ }
369
+ push(occurrence.path, expandRange(text, occurrence.start, occurrence.end));
370
+ record(
371
+ occurrence.file,
372
+ `removida \`.${occurrence.className}\` (linha ${occurrence.line}) → \`.${group.name}\` em ${plan.target.file}`,
373
+ );
374
+ result.moved += 1;
375
+ for (const site of occurrence.sites) {
376
+ push(site.path, { start: site.start, end: site.end, text: `"${group.name}"` });
377
+ record(
378
+ site.file,
379
+ `\`styles.${occurrence.className}\` → \`"${group.name}"\` (linha ${site.line})`,
380
+ );
381
+ }
382
+ }
383
+ }
384
+
385
+ if (!dryRun) {
386
+ for (const [path, fileEdits] of edits) {
387
+ let text;
388
+ try {
389
+ text = readFileSync(path, "utf8");
390
+ writeFileSync(path, splice(text, fileEdits));
391
+ } catch (err) {
392
+ result.errors.push({ file: path, message: String(err?.message ?? err) });
393
+ }
394
+ }
395
+ try {
396
+ const current = readFileSync(plan.target.path, "utf8");
397
+ writeFileSync(plan.target.path, `${current.replace(/\s*$/, "\n")}${appended}`);
398
+ } catch (err) {
399
+ result.errors.push({ file: plan.target.file, message: String(err?.message ?? err) });
400
+ }
401
+ }
402
+ record(plan.target.file, `+${result.rules} classe(s) global(is)`);
403
+
404
+ result.files = [...changes.entries()].map(([file, list]) => ({ file, changes: list }));
405
+ return result;
406
+ }
@@ -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
+ }
package/bin/tempest.mjs CHANGED
@@ -14,7 +14,7 @@ import { fileURLToPath } from "node:url";
14
14
  import { aliasImports } from "./lib/alias/index.mjs";
15
15
  import { loadTypeScript } from "./lib/alias/typescript.mjs";
16
16
  import { readTsconfig } from "./lib/alias/tsconfig.mjs";
17
- import { analyzeCss, applyCssFixes } from "./lib/css/index.mjs";
17
+ import { analyzeCss, applyCssFixes, applyExtraction, planExtraction } from "./lib/css/index.mjs";
18
18
  import { checkLucide } from "./lib/doctor/lucide.mjs";
19
19
  import { generateRegistry, loadIconTables } from "./lib/icons/generate.mjs";
20
20
  import { generate } from "./lib/openapi/generate.mjs";
@@ -814,7 +814,36 @@ function lint(args) {
814
814
  }
815
815
 
816
816
  /** Flags `fix` owns itself; anything else is rejected rather than forwarded. */
817
- const FIX_FLAGS = new Set(["--no-alias", "--no-css", "--dry-run"]);
817
+ const FIX_FLAGS = new Set(["--no-alias", "--no-css", "--dry-run", "--extract-css"]);
818
+
819
+ /** Flags of `fix` that consume the next argument as their value. */
820
+ const FIX_VALUE_FLAGS = new Set(["--css-target", "--css-prefix"]);
821
+
822
+ /**
823
+ * Split the `fix` argv into flags, flag values and positional paths.
824
+ *
825
+ * `splitArgs` cannot do this: it classifies by leading `-`, so the value of
826
+ * `--css-target src/index.css` would land in the path list and `fix` would run
827
+ * against a single stylesheet instead of the project.
828
+ *
829
+ * @param {string[]} args
830
+ * @returns {{ flags: string[], values: Record<string, string>, paths: string[] }}
831
+ */
832
+ function parseFixArgs(args) {
833
+ const flags = [];
834
+ const values = {};
835
+ const paths = [];
836
+ for (let i = 0; i < args.length; i += 1) {
837
+ const arg = args[i];
838
+ if (FIX_VALUE_FLAGS.has(arg)) {
839
+ values[arg] = args[i + 1] ?? "";
840
+ i += 1;
841
+ continue;
842
+ }
843
+ (arg.startsWith("-") ? flags : paths).push(arg);
844
+ }
845
+ return { flags, values, paths };
846
+ }
818
847
 
819
848
  /**
820
849
  * Print the alias pass result.
@@ -875,9 +904,12 @@ function severityMark(severity) {
875
904
  * @param {object} params
876
905
  * @param {string[]} params.targets - Positional paths from the command line.
877
906
  * @param {boolean} params.dryRun
907
+ * @param {boolean} [params.extract] - Run the opt-in cross-file extraction codemod.
908
+ * @param {string} [params.target] - Global stylesheet to extract into.
909
+ * @param {string} [params.prefix] - Prefix for the extracted class names.
878
910
  * @returns {number} Exit status contribution.
879
911
  */
880
- function cssPass({ targets, dryRun }) {
912
+ function cssPass({ targets, dryRun, extract = false, target, prefix }) {
881
913
  console.log(
882
914
  `${c.dim}→ css (dedupe declarations · drop dead rules)${dryRun ? " [dry-run]" : ""}${c.reset}`,
883
915
  );
@@ -939,20 +971,95 @@ function cssPass({ targets, dryRun }) {
939
971
  ` ${c.red}✗ ${analysis.counts.error} CSS syntax error(s)${c.reset} ${c.dim}— those files were not touched; fix them and run again${c.reset}`,
940
972
  );
941
973
  }
942
- return result.errors.length || analysis.counts.error ? 1 : 0;
974
+
975
+ const extractStatus = extract
976
+ ? extractPass({ targets, dryRun, target, prefix, dedupedFiles: result.files.length })
977
+ : 0;
978
+
979
+ return result.errors.length || analysis.counts.error || extractStatus ? 1 : 0;
980
+ }
981
+
982
+ /**
983
+ * The opt-in extraction codemod: a block repeated across CSS Modules becomes one
984
+ * class in the project's global stylesheet, and the `styles.x` that pointed at
985
+ * the local copies become the new class name.
986
+ *
987
+ * The analysis is re-run from disk rather than reused: the dedupe pass that just
988
+ * finished may have rewritten these same files, and every edit here is a splice at
989
+ * a recorded offset. Reusing the stale parse would splice at positions that no
990
+ * longer exist.
991
+ *
992
+ * @param {object} params
993
+ * @param {string[]} params.targets
994
+ * @param {boolean} params.dryRun
995
+ * @param {string} [params.target]
996
+ * @param {string} [params.prefix]
997
+ * @param {number} params.dedupedFiles - Files the dedupe pass touched, for the re-read note.
998
+ * @returns {number} Exit status contribution.
999
+ */
1000
+ function extractPass({ targets, dryRun, target, prefix, dedupedFiles }) {
1001
+ console.log(
1002
+ `${c.dim}→ css extract (bloco repetido → classe global)${dryRun ? " [dry-run]" : ""}${c.reset}`,
1003
+ );
1004
+ let plan;
1005
+ try {
1006
+ const fresh = analyzeCss({ root: ROOT, targets, selfDir: SELF_DIR });
1007
+ if (dedupedFiles > 0 && !dryRun) {
1008
+ console.log(` ${c.dim}re-analisado depois da passada de dedupe${c.reset}`);
1009
+ }
1010
+ plan = planExtraction({ analysis: fresh, root: ROOT, target, prefix });
1011
+ } catch (err) {
1012
+ console.log(
1013
+ ` ${c.red}✗ extraction failed${c.reset} ${c.dim}${err?.message ?? err}${c.reset}`,
1014
+ );
1015
+ return 1;
1016
+ }
1017
+
1018
+ if (plan.status !== "ok") {
1019
+ console.log(` ${c.yellow}! ${plan.message}${c.reset}`);
1020
+ return 0;
1021
+ }
1022
+ for (const refusal of plan.refusals) {
1023
+ console.log(
1024
+ ` [${c.yellow}!${c.reset}] ${refusal.file}:${refusal.line} ${c.dim}não extraído — ${refusal.reason}${c.reset}`,
1025
+ );
1026
+ }
1027
+ if (plan.groups.length === 0) {
1028
+ console.log(` ${c.dim}nada a extrair${c.reset}`);
1029
+ return 0;
1030
+ }
1031
+
1032
+ const result = applyExtraction({ plan, dryRun });
1033
+ for (const file of result.files) {
1034
+ console.log(` ${file.file} ${c.dim}${file.changes.length}${c.reset}`);
1035
+ for (const change of file.changes) console.log(` ${c.dim}${change}${c.reset}`);
1036
+ }
1037
+ for (const err of result.errors) {
1038
+ console.log(` ${c.red}✗ ${err.file}${c.reset} ${c.dim}${err.message}${c.reset}`);
1039
+ }
1040
+ const verb = dryRun ? "moveria" : "movidas";
1041
+ console.log(
1042
+ ` ${c.green}✓${c.reset} ${verb} ${result.moved} regra(s) local(is) para ${result.rules} classe(s) em ${plan.target.file}`,
1043
+ );
1044
+ return result.errors.length ? 1 : 0;
943
1045
  }
944
1046
 
945
1047
  function fix(args) {
946
- const { flags, paths } = splitArgs(args);
1048
+ const { flags, values, paths } = parseFixArgs(args);
947
1049
  const unknown = flags.filter((f) => !FIX_FLAGS.has(f));
948
1050
  if (unknown.length) {
949
1051
  console.error(
950
- `${c.red}✗ Unknown flag for fix: ${unknown.join(", ")}${c.reset} — supported: ${[...FIX_FLAGS].join(", ")}`,
1052
+ `${c.red}✗ Unknown flag for fix: ${unknown.join(", ")}${c.reset} — supported: ${[...FIX_FLAGS, ...FIX_VALUE_FLAGS].join(", ")}`,
951
1053
  );
952
1054
  return 1;
953
1055
  }
954
1056
  const targets = paths.length ? paths : ["."];
955
1057
  const dryRun = flags.includes("--dry-run");
1058
+ const extract = flags.includes("--extract-css");
1059
+ if (!extract && (values["--css-target"] || values["--css-prefix"])) {
1060
+ console.error(`${c.red}✗ --css-target/--css-prefix só valem com --extract-css.${c.reset}`);
1061
+ return 1;
1062
+ }
956
1063
 
957
1064
  let aliasStatus = 0;
958
1065
  if (!flags.includes("--no-alias")) {
@@ -962,7 +1069,15 @@ function fix(args) {
962
1069
  aliasStatus = result.status === "error" ? 1 : 0;
963
1070
  }
964
1071
 
965
- const cssStatus = flags.includes("--no-css") ? 0 : cssPass({ targets, dryRun });
1072
+ const cssStatus = flags.includes("--no-css")
1073
+ ? 0
1074
+ : cssPass({
1075
+ targets,
1076
+ dryRun,
1077
+ extract,
1078
+ target: values["--css-target"],
1079
+ prefix: values["--css-prefix"],
1080
+ });
966
1081
 
967
1082
  if (dryRun) return aliasStatus || cssStatus;
968
1083
 
@@ -1125,6 +1240,13 @@ ${c.bold}fix options${c.reset}
1125
1240
  write nothing
1126
1241
  --no-alias Skip the alias pass
1127
1242
  --no-css Skip the CSS pass (analysis and dead-code removal)
1243
+ --extract-css Move a declaration block repeated across CSS Modules into the
1244
+ project's global stylesheet and rewrite the styles.x reads that
1245
+ pointed at the local copies (opt-in — it changes what couples
1246
+ to what). Refuses anything it cannot prove safe, with a reason
1247
+ --css-target <f> Global stylesheet to extract into (default: the first of
1248
+ src/styles/globals.css, src/globals.css, src/index.css, …)
1249
+ --css-prefix <p> Prefix for extracted class names (default: u-)
1128
1250
 
1129
1251
  ${c.bold}Options${c.reset}
1130
1252
  -h, --help Show this help
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tempest-react-sdk",
3
- "version": "0.28.1",
3
+ "version": "0.29.0",
4
4
  "description": "SDK público da Tempest com componentes, hooks e integrações para projetos React.",
5
5
  "type": "module",
6
6
  "license": "MIT",