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.
@@ -155,6 +155,50 @@ describe("tempest doctor — generic findings still surface", () => {
155
155
  });
156
156
  });
157
157
 
158
+ describe("tempest doctor — a project on TypeScript 7", () => {
159
+ /**
160
+ * TypeScript 7 is the native port: same package name, and no classic compiler
161
+ * API — it moved to `typescript/unstable/*`. Before this was detected, `doctor`
162
+ * died with `ts.readConfigFile is not a function` on any such project, which is
163
+ * the worst possible failure for the command people run first.
164
+ */
165
+ beforeEach(() => {
166
+ thirdPartyApp();
167
+ write("node_modules/typescript/package.json", {
168
+ name: "typescript",
169
+ version: "7.0.2",
170
+ main: "index.js",
171
+ });
172
+ write(
173
+ "node_modules/typescript/index.js",
174
+ 'module.exports = { version: "7.0.2", versionMajorMinor: "7.0" };',
175
+ );
176
+ });
177
+
178
+ it("does not crash, and still reports the tsconfig checks", () => {
179
+ const { out, code } = doctor();
180
+ expect(out).not.toContain("is not a function");
181
+ expect(out).toContain("moduleResolution: bundler");
182
+ expect(code).toBe(0);
183
+ });
184
+
185
+ it("says the codemods are unavailable, and why", () => {
186
+ const { out } = doctor();
187
+ expect(out).toContain("no classic compiler API");
188
+ expect(out).toContain("every other pass runs");
189
+ });
190
+
191
+ it("reads a tsconfig with comments through the fallback parser", () => {
192
+ writeFileSync(
193
+ join(root, "tsconfig.json"),
194
+ '{\n // vite default\n "compilerOptions": { "strict": true, "jsx": "react-jsx", "moduleResolution": "bundler" },\n}\n',
195
+ );
196
+ const { out } = doctor();
197
+ expect(out).toContain("strict mode on");
198
+ expect(out).not.toContain("strict mode off");
199
+ });
200
+ });
201
+
158
202
  describe("tempest doctor — stylesheets", () => {
159
203
  beforeEach(thirdPartyApp);
160
204
 
package/bin/tempest.mjs CHANGED
@@ -12,9 +12,9 @@ import { mkdir, writeFile } from "node:fs/promises";
12
12
  import { dirname, join, resolve } from "node:path";
13
13
  import { fileURLToPath } from "node:url";
14
14
  import { aliasImports } from "./lib/alias/index.mjs";
15
- import { loadTypeScript } from "./lib/alias/typescript.mjs";
15
+ import { describeTypeScript, 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";
@@ -535,6 +535,21 @@ function doctor() {
535
535
  const tsc = readTsconfig({ root: ROOT, ts: loadTypeScript(ROOT) });
536
536
  if (tsc) {
537
537
  checks.push(["section", "TypeScript"]);
538
+ /*
539
+ * TypeScript 7 installs under the same package name and does not ship the
540
+ * classic compiler API — it lives behind `typescript/unstable/*` with a
541
+ * different shape. The tsconfig checks below keep working (they fall back to
542
+ * a JSONC-tolerant parse), but the codemods cannot run, and saying so here is
543
+ * better than each pass reporting it as if TypeScript were missing.
544
+ */
545
+ const tsInstall = describeTypeScript(ROOT);
546
+ if (tsInstall.status === "api-unavailable") {
547
+ checks.push([
548
+ "info",
549
+ `typescript ${tsInstall.version} has no classic compiler API`,
550
+ "`tempest fix` skips the alias and --extract-css codemods — they need the TypeScript 6 API; every other pass runs",
551
+ ]);
552
+ }
538
553
  const co = tsc.compilerOptions;
539
554
  // The `@/*` alias is the SDK's convention, not a health property: a project
540
555
  // that has not adopted the SDK is not wrong for lacking it.
@@ -814,7 +829,36 @@ function lint(args) {
814
829
  }
815
830
 
816
831
  /** Flags `fix` owns itself; anything else is rejected rather than forwarded. */
817
- const FIX_FLAGS = new Set(["--no-alias", "--no-css", "--dry-run"]);
832
+ const FIX_FLAGS = new Set(["--no-alias", "--no-css", "--dry-run", "--extract-css"]);
833
+
834
+ /** Flags of `fix` that consume the next argument as their value. */
835
+ const FIX_VALUE_FLAGS = new Set(["--css-target", "--css-prefix"]);
836
+
837
+ /**
838
+ * Split the `fix` argv into flags, flag values and positional paths.
839
+ *
840
+ * `splitArgs` cannot do this: it classifies by leading `-`, so the value of
841
+ * `--css-target src/index.css` would land in the path list and `fix` would run
842
+ * against a single stylesheet instead of the project.
843
+ *
844
+ * @param {string[]} args
845
+ * @returns {{ flags: string[], values: Record<string, string>, paths: string[] }}
846
+ */
847
+ function parseFixArgs(args) {
848
+ const flags = [];
849
+ const values = {};
850
+ const paths = [];
851
+ for (let i = 0; i < args.length; i += 1) {
852
+ const arg = args[i];
853
+ if (FIX_VALUE_FLAGS.has(arg)) {
854
+ values[arg] = args[i + 1] ?? "";
855
+ i += 1;
856
+ continue;
857
+ }
858
+ (arg.startsWith("-") ? flags : paths).push(arg);
859
+ }
860
+ return { flags, values, paths };
861
+ }
818
862
 
819
863
  /**
820
864
  * Print the alias pass result.
@@ -826,7 +870,7 @@ const FIX_FLAGS = new Set(["--no-alias", "--no-css", "--dry-run"]);
826
870
  function reportAlias(result, dryRun) {
827
871
  if (result.status === "no-typescript") {
828
872
  console.log(
829
- `${c.yellow}! typescript not installed — skipping alias pass${c.reset} ${c.dim}(npm i -D typescript)${c.reset}`,
873
+ `${c.yellow}! alias pass skipped${c.reset} ${c.dim} ${result.reason ?? "typescript não instalado"}${c.reset}`,
830
874
  );
831
875
  return;
832
876
  }
@@ -875,9 +919,12 @@ function severityMark(severity) {
875
919
  * @param {object} params
876
920
  * @param {string[]} params.targets - Positional paths from the command line.
877
921
  * @param {boolean} params.dryRun
922
+ * @param {boolean} [params.extract] - Run the opt-in cross-file extraction codemod.
923
+ * @param {string} [params.target] - Global stylesheet to extract into.
924
+ * @param {string} [params.prefix] - Prefix for the extracted class names.
878
925
  * @returns {number} Exit status contribution.
879
926
  */
880
- function cssPass({ targets, dryRun }) {
927
+ function cssPass({ targets, dryRun, extract = false, target, prefix }) {
881
928
  console.log(
882
929
  `${c.dim}→ css (dedupe declarations · drop dead rules)${dryRun ? " [dry-run]" : ""}${c.reset}`,
883
930
  );
@@ -939,20 +986,95 @@ function cssPass({ targets, dryRun }) {
939
986
  ` ${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
987
  );
941
988
  }
942
- return result.errors.length || analysis.counts.error ? 1 : 0;
989
+
990
+ const extractStatus = extract
991
+ ? extractPass({ targets, dryRun, target, prefix, dedupedFiles: result.files.length })
992
+ : 0;
993
+
994
+ return result.errors.length || analysis.counts.error || extractStatus ? 1 : 0;
995
+ }
996
+
997
+ /**
998
+ * The opt-in extraction codemod: a block repeated across CSS Modules becomes one
999
+ * class in the project's global stylesheet, and the `styles.x` that pointed at
1000
+ * the local copies become the new class name.
1001
+ *
1002
+ * The analysis is re-run from disk rather than reused: the dedupe pass that just
1003
+ * finished may have rewritten these same files, and every edit here is a splice at
1004
+ * a recorded offset. Reusing the stale parse would splice at positions that no
1005
+ * longer exist.
1006
+ *
1007
+ * @param {object} params
1008
+ * @param {string[]} params.targets
1009
+ * @param {boolean} params.dryRun
1010
+ * @param {string} [params.target]
1011
+ * @param {string} [params.prefix]
1012
+ * @param {number} params.dedupedFiles - Files the dedupe pass touched, for the re-read note.
1013
+ * @returns {number} Exit status contribution.
1014
+ */
1015
+ function extractPass({ targets, dryRun, target, prefix, dedupedFiles }) {
1016
+ console.log(
1017
+ `${c.dim}→ css extract (bloco repetido → classe global)${dryRun ? " [dry-run]" : ""}${c.reset}`,
1018
+ );
1019
+ let plan;
1020
+ try {
1021
+ const fresh = analyzeCss({ root: ROOT, targets, selfDir: SELF_DIR });
1022
+ if (dedupedFiles > 0 && !dryRun) {
1023
+ console.log(` ${c.dim}re-analisado depois da passada de dedupe${c.reset}`);
1024
+ }
1025
+ plan = planExtraction({ analysis: fresh, root: ROOT, target, prefix });
1026
+ } catch (err) {
1027
+ console.log(
1028
+ ` ${c.red}✗ extraction failed${c.reset} ${c.dim}${err?.message ?? err}${c.reset}`,
1029
+ );
1030
+ return 1;
1031
+ }
1032
+
1033
+ if (plan.status !== "ok") {
1034
+ console.log(` ${c.yellow}! ${plan.message}${c.reset}`);
1035
+ return 0;
1036
+ }
1037
+ for (const refusal of plan.refusals) {
1038
+ console.log(
1039
+ ` [${c.yellow}!${c.reset}] ${refusal.file}:${refusal.line} ${c.dim}não extraído — ${refusal.reason}${c.reset}`,
1040
+ );
1041
+ }
1042
+ if (plan.groups.length === 0) {
1043
+ console.log(` ${c.dim}nada a extrair${c.reset}`);
1044
+ return 0;
1045
+ }
1046
+
1047
+ const result = applyExtraction({ plan, dryRun });
1048
+ for (const file of result.files) {
1049
+ console.log(` ${file.file} ${c.dim}${file.changes.length}${c.reset}`);
1050
+ for (const change of file.changes) console.log(` ${c.dim}${change}${c.reset}`);
1051
+ }
1052
+ for (const err of result.errors) {
1053
+ console.log(` ${c.red}✗ ${err.file}${c.reset} ${c.dim}${err.message}${c.reset}`);
1054
+ }
1055
+ const verb = dryRun ? "moveria" : "movidas";
1056
+ console.log(
1057
+ ` ${c.green}✓${c.reset} ${verb} ${result.moved} regra(s) local(is) para ${result.rules} classe(s) em ${plan.target.file}`,
1058
+ );
1059
+ return result.errors.length ? 1 : 0;
943
1060
  }
944
1061
 
945
1062
  function fix(args) {
946
- const { flags, paths } = splitArgs(args);
1063
+ const { flags, values, paths } = parseFixArgs(args);
947
1064
  const unknown = flags.filter((f) => !FIX_FLAGS.has(f));
948
1065
  if (unknown.length) {
949
1066
  console.error(
950
- `${c.red}✗ Unknown flag for fix: ${unknown.join(", ")}${c.reset} — supported: ${[...FIX_FLAGS].join(", ")}`,
1067
+ `${c.red}✗ Unknown flag for fix: ${unknown.join(", ")}${c.reset} — supported: ${[...FIX_FLAGS, ...FIX_VALUE_FLAGS].join(", ")}`,
951
1068
  );
952
1069
  return 1;
953
1070
  }
954
1071
  const targets = paths.length ? paths : ["."];
955
1072
  const dryRun = flags.includes("--dry-run");
1073
+ const extract = flags.includes("--extract-css");
1074
+ if (!extract && (values["--css-target"] || values["--css-prefix"])) {
1075
+ console.error(`${c.red}✗ --css-target/--css-prefix só valem com --extract-css.${c.reset}`);
1076
+ return 1;
1077
+ }
956
1078
 
957
1079
  let aliasStatus = 0;
958
1080
  if (!flags.includes("--no-alias")) {
@@ -962,7 +1084,15 @@ function fix(args) {
962
1084
  aliasStatus = result.status === "error" ? 1 : 0;
963
1085
  }
964
1086
 
965
- const cssStatus = flags.includes("--no-css") ? 0 : cssPass({ targets, dryRun });
1087
+ const cssStatus = flags.includes("--no-css")
1088
+ ? 0
1089
+ : cssPass({
1090
+ targets,
1091
+ dryRun,
1092
+ extract,
1093
+ target: values["--css-target"],
1094
+ prefix: values["--css-prefix"],
1095
+ });
966
1096
 
967
1097
  if (dryRun) return aliasStatus || cssStatus;
968
1098
 
@@ -1125,6 +1255,13 @@ ${c.bold}fix options${c.reset}
1125
1255
  write nothing
1126
1256
  --no-alias Skip the alias pass
1127
1257
  --no-css Skip the CSS pass (analysis and dead-code removal)
1258
+ --extract-css Move a declaration block repeated across CSS Modules into the
1259
+ project's global stylesheet and rewrite the styles.x reads that
1260
+ pointed at the local copies (opt-in — it changes what couples
1261
+ to what). Refuses anything it cannot prove safe, with a reason
1262
+ --css-target <f> Global stylesheet to extract into (default: the first of
1263
+ src/styles/globals.css, src/globals.css, src/index.css, …)
1264
+ --css-prefix <p> Prefix for extracted class names (default: u-)
1128
1265
 
1129
1266
  ${c.bold}Options${c.reset}
1130
1267
  -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.1",
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",