tempest-react-sdk 0.76.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 (173) hide show
  1. package/README.md +2 -2
  2. package/bin/create-tempest-app.e2e.test.mjs +112 -0
  3. package/bin/create-tempest-app.mjs +69 -28
  4. package/bin/lib/alias/collect.mjs +20 -3
  5. package/bin/lib/alias/index.mjs +12 -1
  6. package/bin/lib/alias/index.test.mjs +24 -0
  7. package/bin/lib/component/generate.mjs +191 -0
  8. package/bin/lib/component/generate.test.mjs +336 -0
  9. package/bin/lib/css/analyze.test.mjs +21 -1
  10. package/bin/lib/css/collect.mjs +14 -0
  11. package/bin/lib/css/extract.mjs +63 -5
  12. package/bin/lib/css/extract.test.mjs +110 -1
  13. package/bin/lib/css/fix.e2e.test.mjs +50 -0
  14. package/bin/lib/css/index.mjs +6 -1
  15. package/bin/lib/fs/contain.mjs +50 -0
  16. package/bin/lib/icons/generate.mjs +28 -3
  17. package/bin/lib/icons/generate.test.mjs +57 -1
  18. package/bin/lib/openapi/escape.mjs +224 -0
  19. package/bin/lib/openapi/generate.encode.test.mjs +120 -0
  20. package/bin/lib/openapi/generate.hostile.test.mjs +621 -0
  21. package/bin/lib/openapi/generate.mjs +129 -18
  22. package/bin/lib/openapi/generate.test.mjs +7 -2
  23. package/bin/lib/openapi/schema-to-zod.mjs +43 -4
  24. package/bin/tempest-gen.test.mjs +188 -0
  25. package/bin/tempest.mjs +263 -6
  26. package/dist/auth/create-tempest-auth.cjs +1 -1
  27. package/dist/auth/create-tempest-auth.cjs.map +1 -1
  28. package/dist/auth/create-tempest-auth.js +203 -58
  29. package/dist/auth/create-tempest-auth.js.map +1 -1
  30. package/dist/auth/refresh-queue.cjs +1 -1
  31. package/dist/auth/refresh-queue.cjs.map +1 -1
  32. package/dist/auth/refresh-queue.js +13 -6
  33. package/dist/auth/refresh-queue.js.map +1 -1
  34. package/dist/br/pix.cjs +1 -1
  35. package/dist/br/pix.cjs.map +1 -1
  36. package/dist/br/pix.js +85 -72
  37. package/dist/br/pix.js.map +1 -1
  38. package/dist/br.d.ts +19 -4
  39. package/dist/components/Chat/ChatAttachments.cjs +1 -1
  40. package/dist/components/Chat/ChatAttachments.cjs.map +1 -1
  41. package/dist/components/Chat/ChatAttachments.js +46 -38
  42. package/dist/components/Chat/ChatAttachments.js.map +1 -1
  43. package/dist/components/CodeBlock/CodeBlock.cjs +1 -1
  44. package/dist/components/CodeBlock/CodeBlock.cjs.map +1 -1
  45. package/dist/components/CodeBlock/CodeBlock.js +1 -1
  46. package/dist/components/CodeBlock/CodeBlock.js.map +1 -1
  47. package/dist/components/CodeBlock/tokenize.cjs +1 -1
  48. package/dist/components/CodeBlock/tokenize.cjs.map +1 -1
  49. package/dist/components/CodeBlock/tokenize.js +54 -25
  50. package/dist/components/CodeBlock/tokenize.js.map +1 -1
  51. package/dist/components/Markdown/markdown-parse.cjs +7 -5
  52. package/dist/components/Markdown/markdown-parse.cjs.map +1 -1
  53. package/dist/components/Markdown/markdown-parse.js +171 -90
  54. package/dist/components/Markdown/markdown-parse.js.map +1 -1
  55. package/dist/components/Toast/Toast.module.cjs +1 -1
  56. package/dist/components/Toast/Toast.module.cjs.map +1 -1
  57. package/dist/components/Toast/Toast.module.js +7 -3
  58. package/dist/components/Toast/Toast.module.js.map +1 -1
  59. package/dist/components/Toast/ToastProvider.cjs +1 -1
  60. package/dist/components/Toast/ToastProvider.cjs.map +1 -1
  61. package/dist/components/Toast/ToastProvider.js +77 -51
  62. package/dist/components/Toast/ToastProvider.js.map +1 -1
  63. package/dist/data/create-data-provider.cjs +1 -1
  64. package/dist/data/create-data-provider.cjs.map +1 -1
  65. package/dist/data/create-data-provider.js +32 -21
  66. package/dist/data/create-data-provider.js.map +1 -1
  67. package/dist/http/api-client.cjs +1 -1
  68. package/dist/http/api-client.cjs.map +1 -1
  69. package/dist/http/api-client.js +81 -73
  70. package/dist/http/api-client.js.map +1 -1
  71. package/dist/http/errors.cjs +1 -1
  72. package/dist/http/errors.cjs.map +1 -1
  73. package/dist/http/errors.js +39 -35
  74. package/dist/http/errors.js.map +1 -1
  75. package/dist/http/resumable-upload.cjs +1 -1
  76. package/dist/http/resumable-upload.cjs.map +1 -1
  77. package/dist/http/resumable-upload.js +141 -105
  78. package/dist/http/resumable-upload.js.map +1 -1
  79. package/dist/http/retry.cjs +1 -1
  80. package/dist/http/retry.cjs.map +1 -1
  81. package/dist/http/retry.js +5 -6
  82. package/dist/http/retry.js.map +1 -1
  83. package/dist/oauth/callback.cjs +2 -0
  84. package/dist/oauth/callback.cjs.map +1 -0
  85. package/dist/oauth/callback.js +42 -0
  86. package/dist/oauth/callback.js.map +1 -0
  87. package/dist/oauth/use-oauth-callback.cjs +1 -1
  88. package/dist/oauth/use-oauth-callback.cjs.map +1 -1
  89. package/dist/oauth/use-oauth-callback.js +13 -20
  90. package/dist/oauth/use-oauth-callback.js.map +1 -1
  91. package/dist/push/use-push-subscription.cjs +1 -1
  92. package/dist/push/use-push-subscription.cjs.map +1 -1
  93. package/dist/push/use-push-subscription.js +42 -42
  94. package/dist/push/use-push-subscription.js.map +1 -1
  95. package/dist/push/web-push-client.cjs +1 -1
  96. package/dist/push/web-push-client.cjs.map +1 -1
  97. package/dist/push/web-push-client.js +23 -3
  98. package/dist/push/web-push-client.js.map +1 -1
  99. package/dist/query/offline-persistence.cjs +1 -1
  100. package/dist/query/offline-persistence.cjs.map +1 -1
  101. package/dist/query/offline-persistence.js +68 -23
  102. package/dist/query/offline-persistence.js.map +1 -1
  103. package/dist/styles/component/Toast.css +6 -1
  104. package/dist/styles/feedback.css +6 -1
  105. package/dist/styles.css +1 -1
  106. package/dist/sw/background-sync.cjs +1 -1
  107. package/dist/sw/background-sync.cjs.map +1 -1
  108. package/dist/sw/background-sync.js +272 -60
  109. package/dist/sw/background-sync.js.map +1 -1
  110. package/dist/sw/cache-inspect.cjs.map +1 -1
  111. package/dist/sw/cache-inspect.js.map +1 -1
  112. package/dist/sw/cache.cjs +1 -1
  113. package/dist/sw/cache.cjs.map +1 -1
  114. package/dist/sw/cache.js +91 -64
  115. package/dist/sw/cache.js.map +1 -1
  116. package/dist/sw/credential-headers.cjs +2 -0
  117. package/dist/sw/credential-headers.cjs.map +1 -0
  118. package/dist/sw/credential-headers.js +17 -0
  119. package/dist/sw/credential-headers.js.map +1 -0
  120. package/dist/sw/session-data.cjs +2 -0
  121. package/dist/sw/session-data.cjs.map +1 -0
  122. package/dist/sw/session-data.js +17 -0
  123. package/dist/sw/session-data.js.map +1 -0
  124. package/dist/sw.cjs +1 -1
  125. package/dist/sw.d.ts +309 -23
  126. package/dist/sw.js +7 -5
  127. package/dist/telemetry/app-error-queue.cjs +1 -1
  128. package/dist/telemetry/app-error-queue.cjs.map +1 -1
  129. package/dist/telemetry/app-error-queue.js +19 -14
  130. package/dist/telemetry/app-error-queue.js.map +1 -1
  131. package/dist/telemetry/app-error-reporter.cjs +1 -1
  132. package/dist/telemetry/app-error-reporter.cjs.map +1 -1
  133. package/dist/telemetry/app-error-reporter.js +75 -34
  134. package/dist/telemetry/app-error-reporter.js.map +1 -1
  135. package/dist/telemetry/app-error-transport.cjs +1 -1
  136. package/dist/telemetry/app-error-transport.cjs.map +1 -1
  137. package/dist/telemetry/app-error-transport.js +16 -3
  138. package/dist/telemetry/app-error-transport.js.map +1 -1
  139. package/dist/tempest-react-sdk.cjs +1 -1
  140. package/dist/tempest-react-sdk.d.ts +1183 -64
  141. package/dist/tempest-react-sdk.js +160 -156
  142. package/dist/utils/csv.cjs +3 -3
  143. package/dist/utils/csv.cjs.map +1 -1
  144. package/dist/utils/csv.js +18 -14
  145. package/dist/utils/csv.js.map +1 -1
  146. package/dist/utils/owner-fingerprint.cjs +2 -0
  147. package/dist/utils/owner-fingerprint.cjs.map +1 -0
  148. package/dist/utils/owner-fingerprint.js +18 -0
  149. package/dist/utils/owner-fingerprint.js.map +1 -0
  150. package/dist/utils/safe-url.cjs +2 -0
  151. package/dist/utils/safe-url.cjs.map +1 -0
  152. package/dist/utils/safe-url.js +39 -0
  153. package/dist/utils/safe-url.js.map +1 -0
  154. package/dist/vite/tempest-csp.cjs +2 -2
  155. package/dist/vite/tempest-csp.cjs.map +1 -1
  156. package/dist/vite/tempest-csp.js +130 -64
  157. package/dist/vite/tempest-csp.js.map +1 -1
  158. package/dist/vite.d.ts +23 -5
  159. package/dist/webrtc/peer-link.cjs +1 -1
  160. package/dist/webrtc/peer-link.cjs.map +1 -1
  161. package/dist/webrtc/peer-link.js +2 -10
  162. package/dist/webrtc/peer-link.js.map +1 -1
  163. package/dist/webrtc/peer-mesh.cjs +1 -1
  164. package/dist/webrtc/peer-mesh.cjs.map +1 -1
  165. package/dist/webrtc/peer-mesh.js +110 -72
  166. package/dist/webrtc/peer-mesh.js.map +1 -1
  167. package/package.json +1 -1
  168. package/template-pwa/src/pwa.tsx +22 -0
  169. package/template-pwa/src/sw.ts +27 -14
  170. package/dist/components/Markdown/markdown-url.cjs +0 -2
  171. package/dist/components/Markdown/markdown-url.cjs.map +0 -1
  172. package/dist/components/Markdown/markdown-url.js +0 -28
  173. package/dist/components/Markdown/markdown-url.js.map +0 -1
@@ -0,0 +1,336 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ mkdtempSync,
6
+ readdirSync,
7
+ readFileSync,
8
+ rmSync,
9
+ symlinkSync,
10
+ writeFileSync,
11
+ } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { dirname, join, resolve } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+
16
+ import { ESLint } from "eslint";
17
+ import ts from "typescript";
18
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
19
+
20
+ import { componentFiles, normalizeComponentName } from "./generate.mjs";
21
+
22
+ const HERE = dirname(fileURLToPath(import.meta.url));
23
+ const REPO = resolve(HERE, "..", "..", "..");
24
+ const CLI = join(REPO, "bin", "tempest.mjs");
25
+ const TEMPLATE = join(REPO, "template");
26
+ /**
27
+ * Component names that collide with something a generated file declares or
28
+ * imports: React built-in hooks (`use<Name>` would be `useMemo`), the `React`
29
+ * namespace, and globals the component function would shadow.
30
+ */
31
+ const COLLIDING = [
32
+ "UserCard",
33
+ "Memo",
34
+ "State",
35
+ "Effect",
36
+ "Callback",
37
+ "LayoutEffect",
38
+ "React",
39
+ "ReactMemo",
40
+ "UseMemo",
41
+ "Object",
42
+ "Promise",
43
+ "Error",
44
+ ];
45
+ const FILES = ["constants.ts", "hooks.ts", "index.tsx", "styles.module.css", "utils.ts"];
46
+
47
+ describe("normalizeComponentName", () => {
48
+ it.each([
49
+ ["user card", "UserCard"],
50
+ ["user-card", "UserCard"],
51
+ ["user_card", "UserCard"],
52
+ ["userCard", "UserCard"],
53
+ ["UserCard", "UserCard"],
54
+ [" button ", "Button"],
55
+ ["api client", "ApiClient"],
56
+ ["APIClient", "APIClient"],
57
+ ["card2 header", "Card2Header"],
58
+ ])("%j → %s", (raw, expected) => {
59
+ expect(normalizeComponentName(raw)).toBe(expected);
60
+ });
61
+
62
+ it.each([
63
+ ["", /Missing component name/],
64
+ [" ", /Missing component name/],
65
+ ["2fa", /must start with a letter/],
66
+ ["-card", /must start with a letter/],
67
+ ["user/card", /use ASCII letters, digits/],
68
+ ["../evil", /use ASCII letters, digits/],
69
+ ['card"; alert(1)', /use ASCII letters, digits/],
70
+ ["cartão", /use ASCII letters, digits/],
71
+ ["con", /reserved device name on Windows/],
72
+ ["NUL", /reserved device name on Windows/],
73
+ ["aux", /reserved device name on Windows/],
74
+ ["prn", /reserved device name on Windows/],
75
+ ["com1", /reserved device name on Windows/],
76
+ ["Lpt9", /reserved device name on Windows/],
77
+ ])("refuses %j", (raw, message) => {
78
+ expect(() => normalizeComponentName(raw)).toThrow(message);
79
+ });
80
+ });
81
+
82
+ describe("componentFiles", () => {
83
+ it("emits exactly the five files the issue asks for", () => {
84
+ const { name, files } = componentFiles("user card");
85
+ expect(name).toBe("UserCard");
86
+ expect(Object.keys(files).sort()).toEqual(FILES);
87
+ });
88
+
89
+ it("wires every file into index.tsx so none starts as dead code", () => {
90
+ const { files } = componentFiles("UserCard");
91
+ expect(files["index.tsx"]).toContain('from "./hooks"');
92
+ expect(files["index.tsx"]).toContain('from "./utils"');
93
+ expect(files["index.tsx"]).toContain('from "./styles.module.css"');
94
+ expect(files["hooks.ts"]).toContain('from "./constants"');
95
+ });
96
+
97
+ it("renames the hook when use<Name> is a React built-in hook", () => {
98
+ expect(componentFiles("Memo").files["hooks.ts"]).toContain(
99
+ "export function useMemoComponent(",
100
+ );
101
+ expect(componentFiles("State").files["index.tsx"]).toContain("useStateComponent(title)");
102
+ expect(componentFiles("Card").files["hooks.ts"]).toContain("export function useCard(");
103
+ });
104
+
105
+ it("writes no comment and no single-quoted string", () => {
106
+ const { files } = componentFiles("UserCard");
107
+ for (const [file, contents] of Object.entries(files)) {
108
+ expect(contents, file).not.toMatch(/\/\/|\/\*/);
109
+ expect(contents, file).not.toContain("'");
110
+ }
111
+ });
112
+
113
+ it.each(COLLIDING)(
114
+ "passes the app template's ESLint config as generated for %s",
115
+ async (componentName) => {
116
+ const dir = mkdtempSync(join(tmpdir(), "tempest-component-lint-"));
117
+ try {
118
+ const { files } = componentFiles(componentName);
119
+ for (const [file, contents] of Object.entries(files)) {
120
+ writeFileSync(join(dir, file), contents);
121
+ }
122
+ const eslint = new ESLint({
123
+ cwd: dir,
124
+ overrideConfigFile: join(TEMPLATE, "eslint.config.js"),
125
+ });
126
+ const results = await eslint.lintFiles(["*.ts", "*.tsx"]);
127
+ const messages = results.flatMap((r) =>
128
+ r.messages.map((m) => `${r.filePath}:${m.line} ${m.ruleId} ${m.message}`),
129
+ );
130
+ expect(results).toHaveLength(4);
131
+ expect(messages).toEqual([]);
132
+ } finally {
133
+ rmSync(dir, { recursive: true, force: true });
134
+ }
135
+ },
136
+ );
137
+
138
+ it.each(COLLIDING)(
139
+ "type-checks %s under the app template's strict tsconfig",
140
+ (componentName) => {
141
+ const { files } = componentFiles(componentName);
142
+ const tsconfig = ts.parseConfigFileTextToJson(
143
+ "tsconfig.json",
144
+ readFileSync(join(TEMPLATE, "tsconfig.json"), "utf8"),
145
+ ).config;
146
+ const { options } = ts.convertCompilerOptionsFromJson(
147
+ { ...tsconfig.compilerOptions, types: [], paths: undefined },
148
+ REPO,
149
+ );
150
+ const root = join(REPO, "__virtual_component__");
151
+ const virtual = new Map(
152
+ Object.entries(files)
153
+ .filter(([file]) => !file.endsWith(".css"))
154
+ .map(([file, contents]) => [join(root, file), contents]),
155
+ );
156
+ virtual.set(
157
+ join(root, "css-modules.d.ts"),
158
+ 'declare module "*.module.css" {\n const classes: Readonly<Record<string, string>>;\n export default classes;\n}\n',
159
+ );
160
+ const host = ts.createCompilerHost(options);
161
+ const readFile = host.readFile.bind(host);
162
+ const fileExists = host.fileExists.bind(host);
163
+ host.readFile = (f) => virtual.get(f) ?? readFile(f);
164
+ host.fileExists = (f) => virtual.has(f) || fileExists(f);
165
+ const directoryExists = host.directoryExists?.bind(host);
166
+ host.directoryExists = (d) => d === root || (directoryExists?.(d) ?? true);
167
+ const getSourceFile = host.getSourceFile.bind(host);
168
+ host.getSourceFile = (f, lang) =>
169
+ virtual.has(f)
170
+ ? ts.createSourceFile(f, virtual.get(f), lang)
171
+ : getSourceFile(f, lang);
172
+ const program = ts.createProgram([...virtual.keys()], options, host);
173
+ const diagnostics = ts
174
+ .getPreEmitDiagnostics(program)
175
+ .map((d) => ts.flattenDiagnosticMessageText(d.messageText, "\n"));
176
+ expect(diagnostics).toEqual([]);
177
+ },
178
+ );
179
+ });
180
+
181
+ describe("tempest gen component (CLI)", () => {
182
+ let cwd;
183
+
184
+ beforeEach(() => {
185
+ cwd = mkdtempSync(join(tmpdir(), "tempest-component-cli-"));
186
+ });
187
+
188
+ afterEach(() => {
189
+ rmSync(cwd, { recursive: true, force: true });
190
+ });
191
+
192
+ /**
193
+ * Run the `tempest` CLI in the temporary project.
194
+ *
195
+ * @param {string[]} args - CLI arguments.
196
+ * @returns {{ status: number | null, out: string }}
197
+ */
198
+ function tempest(...args) {
199
+ const result = spawnSync(process.execPath, [CLI, ...args], { cwd, encoding: "utf8" });
200
+ return { status: result.status, out: result.stdout + result.stderr };
201
+ }
202
+
203
+ it("creates src/components/<Name>/ with the five files by default", () => {
204
+ const { status, out } = tempest("gen", "component", "user card");
205
+ expect(status).toBe(0);
206
+ expect(readdirSync(join(cwd, "src", "components", "UserCard")).sort()).toEqual(FILES);
207
+ expect(out).toContain('import { UserCard } from "@/components/UserCard";');
208
+ });
209
+
210
+ it("accepts `tempest new component`, the spelling from the issue", () => {
211
+ const { status } = tempest("new", "component", "Button");
212
+ expect(status).toBe(0);
213
+ expect(existsSync(join(cwd, "src", "components", "Button", "index.tsx"))).toBe(true);
214
+ });
215
+
216
+ it("refuses another `new` target instead of growing a second generator name", () => {
217
+ const { status, out } = tempest("new", "api");
218
+ expect(status).toBe(1);
219
+ expect(out).toMatch(/Unknown new target: api/);
220
+ });
221
+
222
+ it("honors --out", () => {
223
+ const { status, out } = tempest("gen", "component", "Card", "--out", "src/ui");
224
+ expect(status).toBe(0);
225
+ expect(existsSync(join(cwd, "src", "ui", "Card", "hooks.ts"))).toBe(true);
226
+ expect(out).toContain('from "@/ui/Card"');
227
+ });
228
+
229
+ it("refuses to touch an existing folder without --force", () => {
230
+ const dir = join(cwd, "src", "components", "Card");
231
+ mkdirSync(dir, { recursive: true });
232
+ writeFileSync(join(dir, "index.tsx"), "mine");
233
+
234
+ const { status, out } = tempest("gen", "component", "Card");
235
+
236
+ expect(status).toBe(1);
237
+ expect(out).toMatch(/already exists.*--force/);
238
+ expect(readFileSync(join(dir, "index.tsx"), "utf8")).toBe("mine");
239
+ });
240
+
241
+ it("--force rewrites the five files and leaves the rest of the folder alone", () => {
242
+ const dir = join(cwd, "src", "components", "Card");
243
+ mkdirSync(dir, { recursive: true });
244
+ writeFileSync(join(dir, "index.tsx"), "mine");
245
+ writeFileSync(join(dir, "Card.test.tsx"), "keep");
246
+
247
+ const { status } = tempest("gen", "component", "Card", "--force");
248
+
249
+ expect(status).toBe(0);
250
+ expect(readFileSync(join(dir, "index.tsx"), "utf8")).toContain("export function Card(");
251
+ expect(readFileSync(join(dir, "Card.test.tsx"), "utf8")).toBe("keep");
252
+ });
253
+
254
+ it("refuses to write through a symlinked folder even with --force", () => {
255
+ const outside = join(cwd, "outside");
256
+ mkdirSync(outside);
257
+ mkdirSync(join(cwd, "src", "components"), { recursive: true });
258
+ symlinkSync(outside, join(cwd, "src", "components", "Card"));
259
+
260
+ const { status, out } = tempest("gen", "component", "Card", "--force");
261
+
262
+ expect(status).toBe(1);
263
+ expect(out).toMatch(/symlink/);
264
+ expect(readdirSync(outside)).toEqual([]);
265
+ });
266
+
267
+ it.each([
268
+ [["gen", "component", "Card", "--out", "../x"], /outside the project/],
269
+ [["gen", "component", "Card", "--out", "/tmp/tempest-outside-abs"], /outside the project/],
270
+ [["gen", "component", "Card", "--out", "--force"], /--out needs a folder/],
271
+ [["gen", "component", "Card", "--out"], /--out needs a folder/],
272
+ [["gen", "component", "Card", "--out", ""], /--out needs a folder/],
273
+ ])("refuses an --out of %j", (args, message) => {
274
+ const { status, out } = tempest(...args);
275
+ expect(status).toBe(1);
276
+ expect(out).toMatch(message);
277
+ expect(existsSync(join(cwd, "src"))).toBe(false);
278
+ expect(existsSync(join(cwd, "--force"))).toBe(false);
279
+ expect(existsSync(join(dirname(cwd), "x"))).toBe(false);
280
+ expect(existsSync("/tmp/tempest-outside-abs")).toBe(false);
281
+ });
282
+
283
+ it("refuses an --out whose existing ancestor is a symlink leaving the project", () => {
284
+ const outside = mkdtempSync(join(tmpdir(), "tempest-component-outside-"));
285
+ try {
286
+ mkdirSync(join(cwd, "src"));
287
+ symlinkSync(outside, join(cwd, "src", "components"));
288
+
289
+ const { status, out } = tempest("gen", "component", "Card");
290
+
291
+ expect(status).toBe(1);
292
+ expect(out).toMatch(/outside the project/);
293
+ expect(readdirSync(outside)).toEqual([]);
294
+ } finally {
295
+ rmSync(outside, { recursive: true, force: true });
296
+ }
297
+ });
298
+
299
+ it("--force validates all five targets before writing any of them", () => {
300
+ const dir = join(cwd, "src", "components", "Card");
301
+ mkdirSync(join(dir, "utils.ts"), { recursive: true });
302
+ writeFileSync(join(dir, "index.tsx"), "mine");
303
+
304
+ const { status, out } = tempest("gen", "component", "Card", "--force");
305
+
306
+ expect(status).toBe(1);
307
+ expect(out).toMatch(/utils\.ts is not a regular file/);
308
+ expect(readFileSync(join(dir, "index.tsx"), "utf8")).toBe("mine");
309
+ expect(readdirSync(dir).sort()).toEqual(["index.tsx", "utils.ts"]);
310
+ });
311
+
312
+ it("prints the path with a warning instead of a wrong import outside src/", () => {
313
+ const { status, out } = tempest("gen", "component", "Card", "--out", "lib/ui");
314
+ expect(status).toBe(0);
315
+ expect(out).not.toMatch(/import \{ Card \}/);
316
+ expect(out).toMatch(/lib\/ui\/Card.*outside src\/.*@\//s);
317
+ });
318
+
319
+ it("keeps the @/ hint for a folder whose name merely starts with two dots", () => {
320
+ const { status, out } = tempest("gen", "component", "Card", "--out", "src/..ui");
321
+ expect(status).toBe(0);
322
+ expect(out).toContain('import { Card } from "@/..ui/Card";');
323
+ });
324
+
325
+ it.each([
326
+ [["gen", "component"], /Missing component name/],
327
+ [["gen", "component", "2fa"], /must start with a letter/],
328
+ [["gen", "component", "user", "card"], /Expected one component name, got 2/],
329
+ [["gen", "component", "Card", "--dry-run"], /Unknown option: --dry-run/],
330
+ ])("fails with a clear message for %j", (args, message) => {
331
+ const { status, out } = tempest(...args);
332
+ expect(status).toBe(1);
333
+ expect(out).toMatch(message);
334
+ expect(existsSync(join(cwd, "src"))).toBe(false);
335
+ });
336
+ });
@@ -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
+ });