tempest-react-sdk 0.77.0 → 0.78.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.
Files changed (79) hide show
  1. package/bin/lib/alias/collect.mjs +20 -3
  2. package/bin/lib/alias/index.mjs +12 -1
  3. package/bin/lib/alias/index.test.mjs +24 -0
  4. package/bin/lib/css/analyze.test.mjs +21 -1
  5. package/bin/lib/css/collect.mjs +14 -0
  6. package/bin/lib/css/extract.mjs +63 -5
  7. package/bin/lib/css/extract.test.mjs +110 -1
  8. package/bin/lib/css/fix.e2e.test.mjs +50 -0
  9. package/bin/lib/css/index.mjs +6 -1
  10. package/bin/lib/fs/contain.mjs +50 -0
  11. package/bin/lib/icons/generate.mjs +28 -3
  12. package/bin/lib/icons/generate.test.mjs +57 -1
  13. package/bin/lib/openapi/escape.mjs +20 -6
  14. package/bin/lib/openapi/generate.encode.test.mjs +120 -0
  15. package/bin/lib/openapi/generate.hostile.test.mjs +38 -9
  16. package/bin/lib/openapi/generate.mjs +41 -6
  17. package/bin/lib/openapi/generate.test.mjs +7 -2
  18. package/bin/lib/openapi/schema-to-zod.mjs +5 -3
  19. package/bin/tempest-gen.test.mjs +188 -0
  20. package/bin/tempest.mjs +120 -44
  21. package/dist/auth/create-tempest-auth.cjs +1 -1
  22. package/dist/auth/create-tempest-auth.cjs.map +1 -1
  23. package/dist/auth/create-tempest-auth.js +186 -113
  24. package/dist/auth/create-tempest-auth.js.map +1 -1
  25. package/dist/br/pix.cjs +1 -1
  26. package/dist/br/pix.cjs.map +1 -1
  27. package/dist/br/pix.js +30 -27
  28. package/dist/br/pix.js.map +1 -1
  29. package/dist/br.d.ts +11 -4
  30. package/dist/components/CodeBlock/CodeBlock.cjs +1 -1
  31. package/dist/components/CodeBlock/CodeBlock.cjs.map +1 -1
  32. package/dist/components/CodeBlock/CodeBlock.js +1 -1
  33. package/dist/components/CodeBlock/CodeBlock.js.map +1 -1
  34. package/dist/components/CodeBlock/tokenize.cjs +1 -1
  35. package/dist/components/CodeBlock/tokenize.cjs.map +1 -1
  36. package/dist/components/CodeBlock/tokenize.js +54 -25
  37. package/dist/components/CodeBlock/tokenize.js.map +1 -1
  38. package/dist/components/Markdown/markdown-parse.cjs +6 -6
  39. package/dist/components/Markdown/markdown-parse.cjs.map +1 -1
  40. package/dist/components/Markdown/markdown-parse.js +91 -74
  41. package/dist/components/Markdown/markdown-parse.js.map +1 -1
  42. package/dist/http/resumable-upload.cjs +1 -1
  43. package/dist/http/resumable-upload.cjs.map +1 -1
  44. package/dist/http/resumable-upload.js +141 -105
  45. package/dist/http/resumable-upload.js.map +1 -1
  46. package/dist/http/retry.cjs +1 -1
  47. package/dist/http/retry.cjs.map +1 -1
  48. package/dist/http/retry.js +5 -6
  49. package/dist/http/retry.js.map +1 -1
  50. package/dist/sw/background-sync.cjs +1 -1
  51. package/dist/sw/background-sync.cjs.map +1 -1
  52. package/dist/sw/background-sync.js +32 -31
  53. package/dist/sw/background-sync.js.map +1 -1
  54. package/dist/sw/credential-headers.cjs.map +1 -1
  55. package/dist/sw/credential-headers.js.map +1 -1
  56. package/dist/sw.d.ts +46 -8
  57. package/dist/telemetry/app-error-reporter.cjs +1 -1
  58. package/dist/telemetry/app-error-reporter.cjs.map +1 -1
  59. package/dist/telemetry/app-error-reporter.js +1 -1
  60. package/dist/telemetry/app-error-reporter.js.map +1 -1
  61. package/dist/tempest-react-sdk.cjs +1 -1
  62. package/dist/tempest-react-sdk.d.ts +214 -12
  63. package/dist/tempest-react-sdk.js +119 -119
  64. package/dist/utils/csv.cjs +3 -3
  65. package/dist/utils/csv.cjs.map +1 -1
  66. package/dist/utils/csv.js +11 -10
  67. package/dist/utils/csv.js.map +1 -1
  68. package/dist/{telemetry/app-error-owner.cjs → utils/owner-fingerprint.cjs} +2 -2
  69. package/dist/utils/owner-fingerprint.cjs.map +1 -0
  70. package/dist/{telemetry/app-error-owner.js → utils/owner-fingerprint.js} +3 -3
  71. package/dist/utils/owner-fingerprint.js.map +1 -0
  72. package/dist/vite/tempest-csp.cjs +2 -2
  73. package/dist/vite/tempest-csp.cjs.map +1 -1
  74. package/dist/vite/tempest-csp.js +69 -58
  75. package/dist/vite/tempest-csp.js.map +1 -1
  76. package/dist/vite.d.ts +5 -3
  77. package/package.json +1 -1
  78. package/dist/telemetry/app-error-owner.cjs.map +0 -1
  79. package/dist/telemetry/app-error-owner.js.map +0 -1
@@ -2,6 +2,7 @@
2
2
  import { readdirSync, statSync } from "node:fs";
3
3
  import { extname, join, resolve } from "node:path";
4
4
 
5
+ import { landsInside } from "../fs/contain.mjs";
5
6
  import { isInsideBase } from "./specifier.mjs";
6
7
 
7
8
  const SOURCE_EXTS = new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]);
@@ -40,6 +41,13 @@ function kindOf(path) {
40
41
  * the base; a target that is merely *outside* it yields nothing, so
41
42
  * `tempest fix e2e` rewrites nothing instead of quietly rewriting `src/`.
42
43
  *
44
+ * A file that resolves outside the project once symlinks are followed is dropped
45
+ * too: the walk descends into the base with `readdirSync`, which follows a base
46
+ * that is itself a link (`src -> /elsewhere`), and the codemod would otherwise
47
+ * rewrite files that are not the project's. Each such file is handed to
48
+ * `onOutside`, so the caller can say how many it left alone instead of skipping
49
+ * them in silence.
50
+ *
43
51
  * Results are sorted so the report — and any diff a reviewer reads — is stable
44
52
  * across runs and platforms.
45
53
  *
@@ -47,15 +55,24 @@ function kindOf(path) {
47
55
  * @param {string} params.root - Project root; relative targets resolve against it.
48
56
  * @param {string[]} params.targets - Positional paths from the command line.
49
57
  * @param {string} params.baseDir - Absolute alias base directory.
58
+ * @param {(path: string) => void} [params.onOutside] - Called once per file left
59
+ * out because it resolves outside the project.
50
60
  * @returns {Array<{ path: string, kind: "source" | "style" }>}
51
61
  */
52
- export function collectFiles({ root, targets, baseDir }) {
62
+ export function collectFiles({ root, targets, baseDir, onOutside = () => {} }) {
53
63
  const out = new Map();
64
+ const outside = new Set();
54
65
 
55
66
  const addFile = (path) => {
56
- if (out.has(path) || !isInsideBase(path, baseDir)) return;
67
+ if (out.has(path) || outside.has(path) || !isInsideBase(path, baseDir)) return;
57
68
  const kind = kindOf(path);
58
- if (kind) out.set(path, { path, kind });
69
+ if (!kind) return;
70
+ if (!landsInside(root, path)) {
71
+ outside.add(path);
72
+ onOutside(path);
73
+ return;
74
+ }
75
+ out.set(path, { path, kind });
59
76
  };
60
77
 
61
78
  const walk = (dir) => {
@@ -36,6 +36,7 @@ export { describeTypeScript, loadTypeScript, typeScriptUnavailableReason } from
36
36
  * files: Array<{ path: string, changes: Array<{ from: string, to: string, line: number }> }>,
37
37
  * total: number,
38
38
  * errors: Array<{ path: string, message: string }>,
39
+ * outside?: number,
39
40
  * }}
40
41
  */
41
42
  export function aliasImports({ root, targets, dryRun = false }) {
@@ -58,7 +59,16 @@ export function aliasImports({ root, targets, dryRun = false }) {
58
59
  const errors = [];
59
60
  let total = 0;
60
61
 
61
- for (const { path, kind } of collectFiles({ root, targets, baseDir })) {
62
+ let outside = 0;
63
+ const collected = collectFiles({
64
+ root,
65
+ targets,
66
+ baseDir,
67
+ onOutside: () => {
68
+ outside += 1;
69
+ },
70
+ });
71
+ for (const { path, kind } of collected) {
62
72
  let text;
63
73
  try {
64
74
  text = readFileSync(path, "utf8");
@@ -90,5 +100,6 @@ export function aliasImports({ root, targets, dryRun = false }) {
90
100
  files,
91
101
  total,
92
102
  errors,
103
+ outside,
93
104
  };
94
105
  }
@@ -136,3 +136,27 @@ describe("aliasImports — degraded projects", () => {
136
136
  expect(read("src/pages/admin/Login.tsx")).toBe(before);
137
137
  });
138
138
  });
139
+
140
+ describe("aliasImports — files outside the project", () => {
141
+ let outside;
142
+
143
+ beforeEach(() => {
144
+ outside = mkdtempSync(join(tmpdir(), "tempest-alias-outside-"));
145
+ });
146
+
147
+ afterEach(() => {
148
+ rmSync(outside, { recursive: true, force: true });
149
+ });
150
+
151
+ it("does not rewrite through an alias base that is a symlink leaving the project", () => {
152
+ const before = `import { b } from "../b";\nexport const a = b;\n`;
153
+ mkdirSync(join(outside, "sub"));
154
+ writeFileSync(join(outside, "b.ts"), "export const b = 1;\n");
155
+ writeFileSync(join(outside, "sub", "a.ts"), before);
156
+ rmSync(join(root, "src"), { recursive: true });
157
+ symlinkSync(outside, join(root, "src"), "dir");
158
+
159
+ expect(run()).toMatchObject({ total: 0, files: [], outside: 2 });
160
+ expect(readFileSync(join(outside, "sub", "a.ts"), "utf8")).toBe(before);
161
+ });
162
+ });
@@ -1,4 +1,4 @@
1
- import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
1
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
@@ -64,6 +64,26 @@ describe("analyzeCss — collection", () => {
64
64
  expect(analysis.stats.files).toBe(1);
65
65
  });
66
66
 
67
+ it("skips a sheet reached through a symlink leaving the project, so fix never rewrites it", () => {
68
+ const outside = mkdtempSync(join(tmpdir(), "tempest-css-outside-"));
69
+ try {
70
+ const before = ".a {\n color: red;\n color: red;\n}\n";
71
+ writeFileSync(join(outside, "a.css"), before);
72
+ symlinkSync(outside, join(root, "src"), "dir");
73
+
74
+ const analysis = analyze(["src"]);
75
+ applyCssFixes({ analysis });
76
+
77
+ expect(analysis.stats.files).toBe(0);
78
+ expect(analysis.skipped[0]).toMatchObject({
79
+ reason: "resolves outside the project through a symlink",
80
+ });
81
+ expect(readFileSync(join(outside, "a.css"), "utf8")).toBe(before);
82
+ } finally {
83
+ rmSync(outside, { recursive: true, force: true });
84
+ }
85
+ });
86
+
67
87
  it("sorts findings error → warn → info", () => {
68
88
  write("src/a.css", ".a {\n color: ;\n}\n.b {\n color: red;\n color: red;\n}\n");
69
89
  const severities = analyze().findings.map((f) => f.severity);
@@ -4,6 +4,8 @@
4
4
  import { readdirSync, readFileSync, statSync } from "node:fs";
5
5
  import { join, resolve } from "node:path";
6
6
 
7
+ import { landsInside } from "../fs/contain.mjs";
8
+
7
9
  const SKIP_DIRS = new Set([
8
10
  "node_modules",
9
11
  "dist",
@@ -19,6 +21,9 @@ const SKIP_DIRS = new Set([
19
21
  ".cache",
20
22
  ]);
21
23
 
24
+ /** `skipped` reason for a sheet that resolves outside the project. */
25
+ export const OUTSIDE_PROJECT = "resolves outside the project through a symlink";
26
+
22
27
  /** Above this size a stylesheet is treated as generated, not authored. */
23
28
  const MAX_BYTES = 512 * 1024;
24
29
 
@@ -35,6 +40,11 @@ export function looksMinified(text) {
35
40
  /**
36
41
  * Collect the project's own `.css` files.
37
42
  *
43
+ * A sheet that resolves outside the project once symlinks are followed is
44
+ * skipped with a reason: an explicit target that is a link (`tempest fix src`
45
+ * with `src -> /elsewhere`) is walked through it, and `fix` would rewrite sheets
46
+ * that are not the project's.
47
+ *
38
48
  * @param {object} params
39
49
  * @param {string} params.root - Project root; relative targets resolve against it.
40
50
  * @param {string[]} [params.targets] - Positional paths from the command line.
@@ -62,6 +72,10 @@ export function collectStylesheets({ root, targets = ["."], maxFiles = MAX_FILES
62
72
  skipped.push({ path, reason: "larger than 512 KB — treated as generated" });
63
73
  return;
64
74
  }
75
+ if (!landsInside(root, path)) {
76
+ skipped.push({ path, reason: OUTSIDE_PROJECT });
77
+ return;
78
+ }
65
79
  if (/\.min\.css$/.test(path)) {
66
80
  skipped.push({ path, reason: "minified" });
67
81
  return;
@@ -18,13 +18,17 @@
18
18
  // dead and take the remaining styles down with it;
19
19
  // - the class is only ever read as `styles.row` / `styles["row"]`, never
20
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;
21
+ // - no declaration carries a line terminator, so the appended rule is the
22
+ // same lines it was in the module and cannot open a new line of its own;
23
+ // - the target global sheet is a regular file (not a symlink) whose realpath is
24
+ // inside the project, AND some source file imports it, because appending to a
25
+ // stylesheet nobody loads is a silent no-op;
23
26
  // - the new global name collides with nothing already in that sheet.
24
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
27
+ import { readFileSync, realpathSync, writeFileSync } from "node:fs";
25
28
  import { join, relative } from "node:path";
26
29
 
27
30
  import { discoverAlias } from "../alias/discover.mjs";
31
+ import { landsInside, lstatOrNull } from "../fs/contain.mjs";
28
32
  import { loadTypeScript, typeScriptUnavailableReason } from "../alias/typescript.mjs";
29
33
  import { collectSources, indexClassUses, resolveSpecifier } from "./references.mjs";
30
34
  import { parseCss } from "./parse.mjs";
@@ -33,6 +37,9 @@ import { declSignature } from "./semantic.mjs";
33
37
  /** A repeated block is only worth extracting from this many declarations up. */
34
38
  const MIN_DECLS = 3;
35
39
 
40
+ /** CSS newlines (`\n`, `\r`, `\f`) plus the two Unicode line separators. */
41
+ const LINE_TERMINATOR = /[\n\r\f\u2028\u2029]/;
42
+
36
43
  /** Stylesheets treated as "the project's global sheet", in order of preference. */
37
44
  const TARGET_CANDIDATES = [
38
45
  "src/styles/globals.css",
@@ -82,7 +89,7 @@ export function classNamesIn(parsed) {
82
89
  */
83
90
  export function findGlobalSheet({ root, explicit, alias }) {
84
91
  const candidates = explicit ? [explicit] : TARGET_CANDIDATES;
85
- const found = candidates.map((rel) => join(root, rel)).find(existsSync);
92
+ const found = candidates.map((rel) => join(root, rel)).find((path) => lstatOrNull(path));
86
93
  if (!found) {
87
94
  return {
88
95
  error: explicit
@@ -95,6 +102,9 @@ export function findGlobalSheet({ root, explicit, alias }) {
95
102
  error: `${relative(root, found)} é um CSS Module — a folha alvo tem que ser global`,
96
103
  };
97
104
  }
105
+ const unsafe = unsafeTargetReason(root, found);
106
+ if (unsafe) return { error: `${relative(root, found)} ${unsafe} — recusado` };
107
+ const realFound = realpathSync(found);
98
108
 
99
109
  const imported = collectSources({ root }).some((file) => {
100
110
  let text;
@@ -106,7 +116,7 @@ export function findGlobalSheet({ root, explicit, alias }) {
106
116
  if (!text.includes(".css")) return false;
107
117
  for (const match of text.matchAll(/["']([^"']+\.css)["']/g)) {
108
118
  const target = resolveSpecifier({ specifier: match[1], fromFile: file, alias });
109
- if (target === found) return true;
119
+ if (target !== null && realpathOrNull(target) === realFound) return true;
110
120
  }
111
121
  return false;
112
122
  });
@@ -118,6 +128,47 @@ export function findGlobalSheet({ root, explicit, alias }) {
118
128
  return { path: found, file: relative(root, found) };
119
129
  }
120
130
 
131
+ /**
132
+ * `realpath` that answers `null` for a missing or dangling path.
133
+ *
134
+ * @param {string} path - Absolute path.
135
+ * @returns {string | null}
136
+ */
137
+ function realpathOrNull(path) {
138
+ try {
139
+ return realpathSync(path);
140
+ } catch {
141
+ return null;
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Why the candidate global sheet must not be appended to, or `null`.
147
+ *
148
+ * `writeFileSync` follows symlinks, so a `src/index.css` that is a link — or
149
+ * that sits under a linked folder pointing out of the project — carries the
150
+ * append to wherever it points: measured before this check, a link to a shell rc
151
+ * file got the extracted rule appended, and a custom property value with a
152
+ * newline in it became a line the shell ran.
153
+ *
154
+ * The rule matches the generators': the sheet itself must be a regular file
155
+ * (`lstat`, so a link — dangling included — is refused), and its `realpath` must
156
+ * land inside `realpath(root)`. A symlinked folder that stays inside the project
157
+ * (a monorepo's `src -> app/src`) is accepted — the write lands in the project
158
+ * either way, and refusing it would switch the pass off for a legitimate layout.
159
+ *
160
+ * @param {string} root - Project root.
161
+ * @param {string} path - Absolute candidate path, which `lstat` sees.
162
+ * @returns {string | null} A phrase completing "`<file>` …", or `null`.
163
+ */
164
+ function unsafeTargetReason(root, path) {
165
+ const stat = lstatOrNull(path);
166
+ if (stat?.isSymbolicLink()) return "é um symlink, e a escrita seguiria o link";
167
+ if (!stat?.isFile()) return "não é um arquivo comum";
168
+ if (!landsInside(root, path)) return "fica fora do projeto depois de resolver symlinks";
169
+ return null;
170
+ }
171
+
121
172
  /** Group the analysed sheets by identical declaration block. */
122
173
  function groupBySignature(sheets) {
123
174
  const groups = new Map();
@@ -145,6 +196,13 @@ function occurrenceRefusal({ sheet, block, className, uses, opaqueModules }) {
145
196
  }
146
197
  if (!className) return `\`${block.prelude}\` não é uma classe simples`;
147
198
 
199
+ const broken = block.decls.find(
200
+ (d) => LINE_TERMINATOR.test(d.prop) || LINE_TERMINATOR.test(d.value),
201
+ );
202
+ if (broken) {
203
+ return `a declaração \`${broken.prop.split(LINE_TERMINATOR)[0]}\` tem quebra de linha — copiada pra folha global, viraria uma linha nova fora da regra`;
204
+ }
205
+
148
206
  const mentions = sheet.parsed.blocks.filter(
149
207
  (other) =>
150
208
  other !== block && new RegExp(`\\.${className}(?![A-Za-z0-9_-])`).test(other.prelude),
@@ -1,7 +1,7 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
- import { dirname, join, resolve } from "node:path";
4
+ import { dirname, join, relative, resolve } from "node:path";
5
5
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
6
 
7
7
  import { analyzeCss } from "./analyze.mjs";
@@ -295,6 +295,20 @@ describe("planExtraction — refusals", () => {
295
295
  expect(result.message).toContain("é um CSS Module");
296
296
  });
297
297
 
298
+ it("refuses a declaration with a line break instead of copying it into the global sheet", () => {
299
+ twoCopies();
300
+ const broken =
301
+ ".row {\n --payload: x\necho INJECTED\n;\n display: flex;\n align-items: center;\n gap: 8px;\n}\n.title {\n font-weight: 600;\n}\n";
302
+ write("src/components/Card.module.css", broken);
303
+ write("src/components/List.module.css", broken.replace(".row", ".line"));
304
+ const result = plan();
305
+ expect(result.groups).toEqual([]);
306
+ expect(result.refusals.map((r) => r.reason)).toEqual([
307
+ expect.stringContaining("tem quebra de linha"),
308
+ expect.stringContaining("tem quebra de linha"),
309
+ ]);
310
+ });
311
+
298
312
  it("reports a missing TypeScript instead of guessing at the call sites", () => {
299
313
  twoCopies();
300
314
  rmSync(join(root, "node_modules", "typescript"), { recursive: true, force: true });
@@ -302,3 +316,98 @@ describe("planExtraction — refusals", () => {
302
316
  expect(result.status).toBe("no-typescript");
303
317
  });
304
318
  });
319
+
320
+ describe("planExtraction — the global sheet stays inside the project (#483)", () => {
321
+ let outside;
322
+
323
+ beforeEach(() => {
324
+ project();
325
+ twoCopies();
326
+ outside = mkdtempSync(join(tmpdir(), "tempest-extract-outside-"));
327
+ });
328
+
329
+ afterEach(() => {
330
+ rmSync(outside, { recursive: true, force: true });
331
+ });
332
+
333
+ /** Plan and apply, the way `tempest fix --extract-css` does. */
334
+ const run = () => {
335
+ const result = plan();
336
+ applyExtraction({ plan: result });
337
+ return result;
338
+ };
339
+
340
+ it("refuses a src/index.css that is a symlink to a file outside the project", () => {
341
+ const victim = join(outside, "victimrc");
342
+ writeFileSync(victim, "echo ORIGINAL\n");
343
+ rmSync(join(root, "src/index.css"));
344
+ symlinkSync(victim, join(root, "src/index.css"));
345
+
346
+ const result = run();
347
+
348
+ expect(result.status).toBe("no-target");
349
+ expect(result.message).toContain("é um symlink");
350
+ expect(readFileSync(victim, "utf8")).toBe("echo ORIGINAL\n");
351
+ });
352
+
353
+ it("refuses a dangling src/index.css symlink instead of falling through to another sheet", () => {
354
+ rmSync(join(root, "src/index.css"));
355
+ symlinkSync(join(outside, "not-yet.css"), join(root, "src/index.css"));
356
+ write("src/app.css", ":root {}\n");
357
+ write("src/main.tsx", 'import "./index.css";\nimport "./app.css";\n');
358
+
359
+ const result = run();
360
+
361
+ expect(result.status).toBe("no-target");
362
+ expect(result.message).toContain("src/index.css é um symlink");
363
+ });
364
+
365
+ it("refuses a global sheet under a folder that is a symlink leaving the project", () => {
366
+ writeFileSync(join(outside, "globals.css"), ":root {}\n");
367
+ symlinkSync(outside, join(root, "src/styles"));
368
+ write("src/main.tsx", 'import "./styles/globals.css";\n');
369
+
370
+ const result = run();
371
+
372
+ expect(result.status).toBe("no-target");
373
+ expect(result.message).toContain("fica fora do projeto");
374
+ expect(readFileSync(join(outside, "globals.css"), "utf8")).toBe(":root {}\n");
375
+ });
376
+
377
+ /**
378
+ * Reversed on purpose. The first version of #483 refused any symlink on the
379
+ * path, so a monorepo layout like `src/styles -> theme` switched the pass off
380
+ * with a `!` and exit 0. The write lands inside the project either way; what
381
+ * is refused is a link on the sheet itself and a realpath that leaves the
382
+ * project — the same rule as `gen api`/`gen component`.
383
+ */
384
+ it("accepts a symlinked folder that stays inside the project, as the generators do", () => {
385
+ write("theme/globals.css", ":root {}\n");
386
+ symlinkSync(join(root, "theme"), join(root, "src/styles"));
387
+ write("src/main.tsx", 'import "./styles/globals.css";\n');
388
+
389
+ const result = run();
390
+
391
+ expect(result.status).toBe("ok");
392
+ expect(result.groups).toHaveLength(1);
393
+ expect(readFileSync(join(root, "theme/globals.css"), "utf8")).toContain(".u-row {");
394
+ });
395
+
396
+ it("refuses a --css-target outside the project", () => {
397
+ writeFileSync(join(outside, "x.css"), "");
398
+ const result = plan({ target: relative(root, join(outside, "x.css")) });
399
+ expect(result.status).toBe("no-target");
400
+ expect(result.message).toContain("fora do projeto");
401
+ });
402
+
403
+ it("matches importers by realpath, so a project under a symlinked path still works", () => {
404
+ const linked = join(outside, "linked-project");
405
+ symlinkSync(root, linked);
406
+ const result = planExtraction({
407
+ analysis: analyzeCss({ root: linked, targets: ["."], selfDir: SELF_DIR }),
408
+ root: linked,
409
+ });
410
+ expect(result.status).toBe("ok");
411
+ expect(result.groups).toHaveLength(1);
412
+ });
413
+ });
@@ -188,3 +188,53 @@ describe("tempest fix --extract-css", () => {
188
188
  expect(readFileSync(join(root, "src/index.css"), "utf8")).not.toContain(".u-row");
189
189
  });
190
190
  });
191
+
192
+ describe("tempest fix — files outside the project are skipped out loud", () => {
193
+ let outside;
194
+
195
+ beforeEach(() => {
196
+ outside = mkdtempSync(join(tmpdir(), "tempest-fix-outside-"));
197
+ });
198
+
199
+ afterEach(() => {
200
+ rmSync(outside, { recursive: true, force: true });
201
+ });
202
+
203
+ it("the CSS pass prints how many sheets it skipped", () => {
204
+ const before = ".a {\n color: red;\n color: red;\n}\n";
205
+ writeFileSync(join(outside, "a.css"), before);
206
+ symlinkSync(outside, join(root, "src"), "dir");
207
+
208
+ const { out } = fix(["src"]);
209
+
210
+ expect(out).toContain("! 1 arquivo(s) fora do projeto (symlink) — pulados");
211
+ expect(readFileSync(join(outside, "a.css"), "utf8")).toBe(before);
212
+ });
213
+
214
+ it("the alias pass prints how many files it skipped", () => {
215
+ const typescript = dirname(
216
+ createRequire(import.meta.url).resolve("typescript/package.json"),
217
+ );
218
+ mkdirSync(join(root, "node_modules"));
219
+ symlinkSync(typescript, join(root, "node_modules", "typescript"), "dir");
220
+ write(
221
+ "tsconfig.json",
222
+ JSON.stringify({ compilerOptions: { paths: { "@/*": ["./src/*"] } } }),
223
+ );
224
+ const before = 'import { b } from "../b";\nexport const a = b;\n';
225
+ mkdirSync(join(outside, "sub"));
226
+ writeFileSync(join(outside, "b.ts"), "export const b = 1;\n");
227
+ writeFileSync(join(outside, "sub", "a.ts"), before);
228
+ symlinkSync(outside, join(root, "src"), "dir");
229
+
230
+ const result = spawnSync(process.execPath, [CLI, "fix", "--no-css"], {
231
+ cwd: root,
232
+ encoding: "utf8",
233
+ env: { ...process.env, NO_COLOR: "1", FORCE_COLOR: "0" },
234
+ });
235
+ const out = `${result.stdout}${result.stderr}`;
236
+
237
+ expect(out).toContain("! 2 arquivo(s) fora do projeto (symlink) — pulados");
238
+ expect(readFileSync(join(outside, "sub", "a.ts"), "utf8")).toBe(before);
239
+ });
240
+ });
@@ -1,6 +1,11 @@
1
1
  // Public surface of the CSS analysis used by `tempest doctor` and `tempest fix`.
2
2
  export { analyzeCss, applyCssFixes } from "./analyze.mjs";
3
- export { collectStylesheets, customPropertiesInSources, looksMinified } from "./collect.mjs";
3
+ export {
4
+ collectStylesheets,
5
+ customPropertiesInSources,
6
+ looksMinified,
7
+ OUTSIDE_PROJECT,
8
+ } from "./collect.mjs";
4
9
  export { countBySeverity, finding, SEVERITY, sortFindings } from "./findings.mjs";
5
10
  export { fixCss } from "./fix.mjs";
6
11
  export {
@@ -0,0 +1,50 @@
1
+ // Where a CLI write really lands. Every pass that writes into the user's project
2
+ // (`gen *`, `fix`'s alias and CSS passes) asks these before touching a path, so a
3
+ // symlink planted in a cloned repository cannot carry a write out of it.
4
+ import { lstatSync, realpathSync } from "node:fs";
5
+ import { dirname, isAbsolute, relative, sep } from "node:path";
6
+
7
+ /**
8
+ * `lstat` that answers `null` for a missing path instead of throwing.
9
+ *
10
+ * @param {string} path - Absolute path.
11
+ * @returns {import("node:fs").Stats | null}
12
+ */
13
+ export function lstatOrNull(path) {
14
+ try {
15
+ return lstatSync(path);
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Whether `path` lands inside `root` once symlinks are resolved.
23
+ *
24
+ * Resolves the nearest ancestor that exists through `realpath`, so `../x`, an
25
+ * absolute path and an ancestor that is a symlink to somewhere else
26
+ * (`src/components -> /elsewhere`) are all seen where they really land. The walk
27
+ * stops at the nearest ancestor `lstat` sees, so a dangling symlink on the way is
28
+ * not skipped over: its `realpath` throws and the path counts as outside, since
29
+ * `mkdir` or `writeFile` would create whatever the link names.
30
+ *
31
+ * @param {string} root - Project root.
32
+ * @param {string} path - Absolute path, existing or about to be created.
33
+ * @returns {boolean}
34
+ */
35
+ export function landsInside(root, path) {
36
+ let existing = path;
37
+ while (lstatOrNull(existing) === null && dirname(existing) !== existing) {
38
+ existing = dirname(existing);
39
+ }
40
+ let real;
41
+ let realRoot;
42
+ try {
43
+ real = realpathSync(existing);
44
+ realRoot = realpathSync(root);
45
+ } catch {
46
+ return false;
47
+ }
48
+ const rel = relative(realRoot, real);
49
+ return !(rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel));
50
+ }
@@ -14,19 +14,44 @@ const NAME_PATTERNS = [
14
14
  const SOURCE_FILE = /\.[cm]?[jt]sx?$/;
15
15
  const SKIP_DIRS = new Set(["node_modules", "dist", "build", "coverage"]);
16
16
 
17
+ /**
18
+ * The two catalogue modules `loadIconTables` reads, relative to `dist/`.
19
+ *
20
+ * Both are pure data (`export { e as iconNames }`, `export { e as iconAliases }`)
21
+ * with no import of their own, which `scripts/check-dist-guards.mjs` asserts
22
+ * after every build.
23
+ */
24
+ export const ICON_TABLE_MODULES = {
25
+ names: "icons/generated/icon-names.js",
26
+ aliases: "icons/generated/aliases.js",
27
+ };
28
+
17
29
  /**
18
30
  * Read the slug tables out of the installed SDK build.
19
31
  *
20
32
  * Deliberately reads `dist/` rather than embedding a copy: the CLI must agree with
21
33
  * the `<Icon>` the app actually renders, and both come from the same install.
22
34
  *
35
+ * It imports the two catalogue modules directly instead of the `dist/icons.js`
36
+ * barrel. Since 0.63.0 every component module imports its own stylesheet, and the
37
+ * barrel reaches `<IconPicker>` and its `.css`: in plain Node — which is how the
38
+ * `tempest` bin runs — that import died with `ERR_UNKNOWN_FILE_EXTENSION ".css"`
39
+ * and `gen icons` failed in every installed app (#485). Registering the
40
+ * `/node-css-loader` hooks would also have worked, but it changes module loading
41
+ * for the whole CLI process to read two arrays, and the barrel would still pull
42
+ * React and the component graph just to list slugs; the data modules need
43
+ * neither.
44
+ *
23
45
  * @param {string} selfDir - Directory holding this CLI (`bin/`).
24
46
  * @returns {Promise<{ known: Set<string>, aliases: Record<string, string> }>}
25
47
  */
26
48
  export async function loadIconTables(selfDir) {
27
- const dist = join(selfDir, "..", "dist", "icons.js");
28
- const mod = await import(pathToFileURL(dist).href);
29
- return { known: new Set(mod.iconNames), aliases: mod.iconAliases };
49
+ const dist = join(selfDir, "..", "dist");
50
+ const [names, aliases] = await Promise.all([
51
+ import(pathToFileURL(join(dist, ICON_TABLE_MODULES.names)).href),
52
+ import(pathToFileURL(join(dist, ICON_TABLE_MODULES.aliases)).href),
53
+ ]);
54
+ return { known: new Set(names.iconNames), aliases: aliases.iconAliases };
30
55
  }
31
56
 
32
57
  /**
@@ -1,6 +1,8 @@
1
+ import { spawnSync } from "node:child_process";
1
2
  import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
3
  import { tmpdir } from "node:os";
3
- import { join } from "node:path";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
4
6
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
7
 
6
8
  import { iconAliases, iconNames } from "@/icons";
@@ -123,3 +125,57 @@ describe("renderRegistry — reserved names", () => {
123
125
  expect(out).toContain(`"infinity": InfinityIcon,`);
124
126
  });
125
127
  });
128
+
129
+ describe("loadIconTables in plain Node (#485)", () => {
130
+ let pkg;
131
+
132
+ beforeEach(() => {
133
+ pkg = mkdtempSync(join(tmpdir(), "tempest-icon-tables-"));
134
+ });
135
+
136
+ afterEach(() => {
137
+ rmSync(pkg, { recursive: true, force: true });
138
+ });
139
+
140
+ /**
141
+ * Lay out a package the way `npm pack` ships it: a `dist/icons.js` barrel that
142
+ * imports a stylesheet, as every component module has since 0.63.0, next to the
143
+ * two pure data modules. Vitest would load the `.css` on its own, so the call
144
+ * runs in a child `node` with no loader — exactly how the `tempest` bin runs.
145
+ */
146
+ function loadInPlainNode() {
147
+ const dist = join(pkg, "dist");
148
+ mkdirSync(join(dist, "icons", "generated"), { recursive: true });
149
+ mkdirSync(join(pkg, "bin"));
150
+ writeFileSync(join(dist, "icons", "IconPicker.css"), ".a{color:red}");
151
+ writeFileSync(
152
+ join(dist, "icons.js"),
153
+ 'import "./icons/IconPicker.css";\nexport const iconNames = [];\nexport const iconAliases = {};\n',
154
+ );
155
+ writeFileSync(
156
+ join(dist, "icons", "generated", "icon-names.js"),
157
+ 'var e = ["check", "house"];\nexport { e as iconNames };\n',
158
+ );
159
+ writeFileSync(
160
+ join(dist, "icons", "generated", "aliases.js"),
161
+ 'var e = { home: "house" };\nexport { e as iconAliases };\n',
162
+ );
163
+ const generator = join(dirname(fileURLToPath(import.meta.url)), "generate.mjs");
164
+ const script = `const { loadIconTables } = await import(${JSON.stringify(generator)});
165
+ const t = await loadIconTables(${JSON.stringify(join(pkg, "bin"))});
166
+ console.log(JSON.stringify({ known: [...t.known], aliases: t.aliases }));`;
167
+ return spawnSync(process.execPath, ["--input-type=module", "-e", script], {
168
+ encoding: "utf8",
169
+ });
170
+ }
171
+
172
+ it("reads the tables without importing a stylesheet", () => {
173
+ const result = loadInPlainNode();
174
+ expect(result.stderr).not.toMatch(/ERR_UNKNOWN_FILE_EXTENSION/);
175
+ expect(result.status).toBe(0);
176
+ expect(JSON.parse(result.stdout)).toEqual({
177
+ known: ["check", "house"],
178
+ aliases: { home: "house" },
179
+ });
180
+ });
181
+ });