tempest-react-sdk 0.29.0 → 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.
@@ -7,7 +7,7 @@ import { collectFiles } from "./collect.mjs";
7
7
  import { discoverAlias } from "./discover.mjs";
8
8
  import { rewriteCss } from "./rewrite-css.mjs";
9
9
  import { rewriteSource } from "./rewrite.mjs";
10
- import { loadTypeScript } from "./typescript.mjs";
10
+ import { loadTypeScript, typeScriptUnavailableReason } from "./typescript.mjs";
11
11
 
12
12
  export { collectFiles } from "./collect.mjs";
13
13
  export { discoverAlias } from "./discover.mjs";
@@ -15,7 +15,7 @@ export { rewriteCss } from "./rewrite-css.mjs";
15
15
  export { rewriteSource } from "./rewrite.mjs";
16
16
  export { isInsideBase, toAlias } from "./specifier.mjs";
17
17
  export { readTsconfig } from "./tsconfig.mjs";
18
- export { loadTypeScript } from "./typescript.mjs";
18
+ export { describeTypeScript, loadTypeScript, typeScriptUnavailableReason } from "./typescript.mjs";
19
19
 
20
20
  /**
21
21
  * Convert relative imports that climb out of their directory into alias imports.
@@ -42,7 +42,13 @@ export function aliasImports({ root, targets, dryRun = false }) {
42
42
  const empty = { files: [], total: 0, errors: [] };
43
43
 
44
44
  const ts = loadTypeScript(root);
45
- if (!ts) return { status: "no-typescript", ...empty };
45
+ if (!ts) {
46
+ return {
47
+ status: "no-typescript",
48
+ reason: typeScriptUnavailableReason(root),
49
+ ...empty,
50
+ };
51
+ }
46
52
 
47
53
  const alias = discoverAlias({ root, ts });
48
54
  if (!alias) return { status: "no-alias", ...empty };
@@ -6,19 +6,62 @@ import { dirname, isAbsolute, join, resolve } from "node:path";
6
6
 
7
7
  const MAX_EXTENDS_DEPTH = 16;
8
8
 
9
+ /**
10
+ * Strip the JSONC a tsconfig is allowed to contain: `//` and block comments, and
11
+ * a trailing comma before `}` or `]`.
12
+ *
13
+ * Needed because `ts.readConfigFile` is not always available — TypeScript 7 does
14
+ * not ship the classic API — and a plain `JSON.parse` chokes on the comments
15
+ * every Vite-generated tsconfig has. Losing the whole config to a comment would
16
+ * silently drop the `strict`/`jsx`/`paths` checks.
17
+ *
18
+ * Comment markers inside a string are left alone, which matters: `"paths"` and
19
+ * `"extends"` values legitimately hold `//` (a URL, a Windows share).
20
+ *
21
+ * @param {string} text
22
+ * @returns {string} Plain JSON.
23
+ */
24
+ export function stripJsonc(text) {
25
+ let out = "";
26
+ let i = 0;
27
+ while (i < text.length) {
28
+ const ch = text[i];
29
+ if (ch === '"') {
30
+ let j = i + 1;
31
+ while (j < text.length && text[j] !== '"') j += text[j] === "\\" ? 2 : 1;
32
+ out += text.slice(i, Math.min(j + 1, text.length));
33
+ i = j + 1;
34
+ continue;
35
+ }
36
+ if (ch === "/" && text[i + 1] === "/") {
37
+ const end = text.indexOf("\n", i);
38
+ i = end < 0 ? text.length : end;
39
+ continue;
40
+ }
41
+ if (ch === "/" && text[i + 1] === "*") {
42
+ const end = text.indexOf("*/", i + 2);
43
+ i = end < 0 ? text.length : end + 2;
44
+ continue;
45
+ }
46
+ out += ch;
47
+ i += 1;
48
+ }
49
+ return out.replace(/,(\s*[}\]])/g, "$1");
50
+ }
51
+
9
52
  /**
10
53
  * Parse one tsconfig file into its raw JSON object.
11
54
  *
12
- * Prefers `ts.readConfigFile`, which accepts the JSONC dialect `tsc` accepts
13
- * (comments, trailing commas). Falls back to `JSON.parse` so a project without
14
- * TypeScript installed still gets the previous behavior instead of nothing.
55
+ * Prefers `ts.readConfigFile`, which accepts the exact dialect `tsc` accepts.
56
+ * Falls back to a JSONC-tolerant parse so a project without the classic
57
+ * TypeScript API still gets its config read instead of nothing.
15
58
  *
16
59
  * @param {string} path - Absolute path to a tsconfig file.
17
60
  * @param {object | null} ts - The `typescript` module, or `null`.
18
61
  * @returns {object | null} The raw config object, or `null` when unreadable.
19
62
  */
20
63
  function parseConfigFile(path, ts) {
21
- if (ts) {
64
+ if (ts && typeof ts.readConfigFile === "function") {
22
65
  const { config, error } = ts.readConfigFile(path, (p) => {
23
66
  try {
24
67
  return readFileSync(p, "utf8");
@@ -29,7 +72,7 @@ function parseConfigFile(path, ts) {
29
72
  return error || !config ? null : config;
30
73
  }
31
74
  try {
32
- return JSON.parse(readFileSync(path, "utf8"));
75
+ return JSON.parse(stripJsonc(readFileSync(path, "utf8")));
33
76
  } catch {
34
77
  return null;
35
78
  }
@@ -3,6 +3,23 @@
3
3
  import { createRequire } from "node:module";
4
4
  import { join } from "node:path";
5
5
 
6
+ /**
7
+ * The classic-API members every codemod in this CLI needs.
8
+ *
9
+ * TypeScript 7 is the native port: its package still installs under the name
10
+ * `typescript`, but the `.` export is a version stub — the JS API moved behind
11
+ * `typescript/unstable/*` with a different shape. So "the module resolved" no
12
+ * longer implies "the API is there", and calling into it blew up with
13
+ * `ts.readConfigFile is not a function` — a stack trace out of `tempest doctor`,
14
+ * which is the command people run first.
15
+ */
16
+ const REQUIRED_API = ["readConfigFile", "createSourceFile", "forEachChild"];
17
+
18
+ /** True when the resolved module exposes the classic compiler API. */
19
+ function hasClassicApi(module) {
20
+ return Boolean(module) && REQUIRED_API.every((name) => typeof module[name] === "function");
21
+ }
22
+
6
23
  /**
7
24
  * Load `typescript` from the project's `node_modules`.
8
25
  *
@@ -11,13 +28,66 @@ import { join } from "node:path";
11
28
  * compiler version than the one that type-checks it would let syntax the app
12
29
  * accepts fail here.
13
30
  *
31
+ * Returns `null` both when TypeScript is absent and when the install does not
32
+ * expose the classic API, because callers treat the two the same way — report it
33
+ * and leave the source untouched. Use {@link describeTypeScript} to say which of
34
+ * the two happened.
35
+ *
14
36
  * @param {string} root - Project root, the directory holding `node_modules`.
15
- * @returns {object | null} The `typescript` module, or `null` when not installed.
37
+ * @returns {object | null} The `typescript` module, or `null`.
16
38
  */
17
39
  export function loadTypeScript(root) {
18
40
  try {
19
- return createRequire(join(root, "package.json"))("typescript");
41
+ const module = createRequire(join(root, "package.json"))("typescript");
42
+ return hasClassicApi(module) ? module : null;
20
43
  } catch {
21
44
  return null;
22
45
  }
23
46
  }
47
+
48
+ /**
49
+ * What the project's TypeScript install is, so a message can say the truth
50
+ * instead of "not installed" about a package that is right there.
51
+ *
52
+ * @param {string} root - Project root.
53
+ * @returns {{ status: "ok" | "missing" | "api-unavailable", version: string | null }}
54
+ */
55
+ export function describeTypeScript(root) {
56
+ let resolve_;
57
+ try {
58
+ resolve_ = createRequire(join(root, "package.json"));
59
+ } catch {
60
+ return { status: "missing", version: null };
61
+ }
62
+ let version = null;
63
+ try {
64
+ version = resolve_("typescript/package.json")?.version ?? null;
65
+ } catch {
66
+ return { status: "missing", version: null };
67
+ }
68
+ try {
69
+ return {
70
+ status: hasClassicApi(resolve_("typescript")) ? "ok" : "api-unavailable",
71
+ version,
72
+ };
73
+ } catch {
74
+ return { status: "api-unavailable", version };
75
+ }
76
+ }
77
+
78
+ /**
79
+ * One line explaining why a codemod cannot run, or `null` when it can.
80
+ *
81
+ * @param {string} root - Project root.
82
+ * @returns {string | null}
83
+ */
84
+ export function typeScriptUnavailableReason(root) {
85
+ const { status, version } = describeTypeScript(root);
86
+ if (status === "ok") return null;
87
+ if (status === "missing") return "typescript não instalado (npm i -D typescript)";
88
+ return (
89
+ `typescript ${version ?? "?"} não expõe a API clássica do compilador — o 7 publica só ` +
90
+ "`typescript/unstable/*`, com outra forma. Pra usar os codemods, tenha o TypeScript 6 " +
91
+ "instalado; o resto do comando segue normal"
92
+ );
93
+ }
@@ -0,0 +1,120 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+
6
+ import { readTsconfig, stripJsonc } from "./tsconfig.mjs";
7
+ import { describeTypeScript, loadTypeScript, typeScriptUnavailableReason } from "./typescript.mjs";
8
+
9
+ let root;
10
+
11
+ /** Write a file under the fixture, creating parent directories. */
12
+ function write(rel, text) {
13
+ const full = join(root, rel);
14
+ mkdirSync(dirname(full), { recursive: true });
15
+ writeFileSync(full, text);
16
+ }
17
+
18
+ /**
19
+ * Fake an installed `typescript` whose main module exports `exports`.
20
+ *
21
+ * TypeScript 7 is exactly this shape: the package resolves, the version is there,
22
+ * and the classic API is not — it moved to `typescript/unstable/*`.
23
+ */
24
+ function fakeTypeScript(version, exports) {
25
+ write(
26
+ "node_modules/typescript/package.json",
27
+ JSON.stringify({ name: "typescript", version, main: "index.js" }),
28
+ );
29
+ write("node_modules/typescript/index.js", `module.exports = ${exports};`);
30
+ }
31
+
32
+ beforeEach(() => {
33
+ root = mkdtempSync(join(tmpdir(), "tempest-ts-"));
34
+ write("package.json", JSON.stringify({ name: "fixture" }));
35
+ });
36
+
37
+ afterEach(() => {
38
+ rmSync(root, { recursive: true, force: true });
39
+ });
40
+
41
+ describe("loadTypeScript", () => {
42
+ it("returns null when typescript is not installed", () => {
43
+ expect(loadTypeScript(root)).toBeNull();
44
+ expect(describeTypeScript(root)).toEqual({ status: "missing", version: null });
45
+ expect(typeScriptUnavailableReason(root)).toContain("não instalado");
46
+ });
47
+
48
+ it("returns null for an install without the classic compiler API", () => {
49
+ fakeTypeScript("7.0.2", '{ version: "7.0.2", versionMajorMinor: "7.0" }');
50
+ expect(loadTypeScript(root)).toBeNull();
51
+ expect(describeTypeScript(root)).toEqual({ status: "api-unavailable", version: "7.0.2" });
52
+ const reason = typeScriptUnavailableReason(root);
53
+ expect(reason).toContain("7.0.2");
54
+ expect(reason).toContain("unstable");
55
+ });
56
+
57
+ it("returns the module when the classic API is there", () => {
58
+ fakeTypeScript(
59
+ "6.0.3",
60
+ "{ readConfigFile: () => ({}), createSourceFile: () => ({}), forEachChild: () => {} }",
61
+ );
62
+ expect(loadTypeScript(root)).not.toBeNull();
63
+ expect(describeTypeScript(root)).toEqual({ status: "ok", version: "6.0.3" });
64
+ expect(typeScriptUnavailableReason(root)).toBeNull();
65
+ });
66
+
67
+ it("treats a partial API as unavailable rather than half-usable", () => {
68
+ fakeTypeScript("7.1.0", "{ createSourceFile: () => ({}) }");
69
+ expect(loadTypeScript(root)).toBeNull();
70
+ });
71
+ });
72
+
73
+ describe("stripJsonc", () => {
74
+ it("removes line and block comments", () => {
75
+ expect(stripJsonc('{ // hi\n "a": 1 /* there */ }')).toBe('{ \n "a": 1 }');
76
+ });
77
+
78
+ it("removes a trailing comma before a close", () => {
79
+ expect(JSON.parse(stripJsonc('{ "a": [1, 2,], }'))).toEqual({ a: [1, 2] });
80
+ });
81
+
82
+ it("leaves comment markers inside strings alone", () => {
83
+ expect(JSON.parse(stripJsonc('{ "url": "https://x.dev/a", "p": "a/*b" }'))).toEqual({
84
+ url: "https://x.dev/a",
85
+ p: "a/*b",
86
+ });
87
+ });
88
+
89
+ it("survives an unterminated comment instead of throwing", () => {
90
+ expect(() => stripJsonc('{ "a": 1 } /* forever')).not.toThrow();
91
+ });
92
+ });
93
+
94
+ describe("readTsconfig without the compiler API", () => {
95
+ it("still reads a tsconfig that has comments and a trailing comma", () => {
96
+ write(
97
+ "tsconfig.json",
98
+ '{\n // the app\n "compilerOptions": {\n "strict": true,\n "paths": { "@/*": ["./src/*"] },\n },\n}\n',
99
+ );
100
+ const config = readTsconfig({ root, ts: null });
101
+ expect(config.compilerOptions.strict).toBe(true);
102
+ expect(config.compilerOptions.paths["@/*"]).toEqual(["./src/*"]);
103
+ });
104
+
105
+ it("follows extends with the fallback parser", () => {
106
+ write("base.json", '{ "compilerOptions": { "jsx": "react-jsx" } } // base\n');
107
+ write(
108
+ "tsconfig.json",
109
+ '{ "extends": "./base.json", "compilerOptions": { "strict": true } }',
110
+ );
111
+ const config = readTsconfig({ root, ts: null });
112
+ expect(config.compilerOptions).toMatchObject({ jsx: "react-jsx", strict: true });
113
+ });
114
+
115
+ it("ignores a `ts` that cannot read a config file", () => {
116
+ write("tsconfig.json", '{ "compilerOptions": { "strict": true } }');
117
+ const config = readTsconfig({ root, ts: { version: "7.0.2" } });
118
+ expect(config.compilerOptions.strict).toBe(true);
119
+ });
120
+ });
@@ -25,7 +25,7 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
25
25
  import { join, relative } from "node:path";
26
26
 
27
27
  import { discoverAlias } from "../alias/discover.mjs";
28
- import { loadTypeScript } from "../alias/typescript.mjs";
28
+ import { loadTypeScript, typeScriptUnavailableReason } from "../alias/typescript.mjs";
29
29
  import { collectSources, indexClassUses, resolveSpecifier } from "./references.mjs";
30
30
  import { parseCss } from "./parse.mjs";
31
31
  import { declSignature } from "./semantic.mjs";
@@ -192,8 +192,7 @@ export function planExtraction({ analysis, root, target, prefix = "u-" }) {
192
192
  if (!ts) {
193
193
  return {
194
194
  status: "no-typescript",
195
- message:
196
- "typescript não instalado — a reescrita do TSX precisa do compilador do projeto",
195
+ message: `a reescrita do TSX precisa do compilador do projeto: ${typeScriptUnavailableReason(root)}`,
197
196
  groups: [],
198
197
  refusals: [],
199
198
  };
@@ -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,7 +12,7 @@ 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
17
  import { analyzeCss, applyCssFixes, applyExtraction, planExtraction } from "./lib/css/index.mjs";
18
18
  import { checkLucide } from "./lib/doctor/lucide.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.
@@ -855,7 +870,7 @@ function parseFixArgs(args) {
855
870
  function reportAlias(result, dryRun) {
856
871
  if (result.status === "no-typescript") {
857
872
  console.log(
858
- `${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}`,
859
874
  );
860
875
  return;
861
876
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tempest-react-sdk",
3
- "version": "0.29.0",
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",