@akanjs/devkit 3.1.1-rc.0 → 3.1.1-rc.10

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 (46) hide show
  1. package/akanConfig/akanConfig.test.ts +13 -2
  2. package/akanConfig/akanConfig.ts +5 -5
  3. package/akanConfig/productionLockfile.test.ts +80 -0
  4. package/akanConfig/productionLockfile.ts +32 -0
  5. package/akanContext.ts +25 -0
  6. package/applicationBuildRunner.test.ts +41 -0
  7. package/applicationBuildRunner.ts +23 -3
  8. package/biomeBase.test.ts +2 -2
  9. package/cloud/cloudApi.test.ts +46 -0
  10. package/cloud/cloudApi.ts +23 -13
  11. package/cloud/cloudHttpError.test.ts +8 -0
  12. package/cloud/cloudHttpError.ts +11 -1
  13. package/cloud/cloudUpdates.ts +48 -2
  14. package/cloud/updatePackUpload.test.ts +38 -2
  15. package/cloud/updatePackUpload.ts +5 -1
  16. package/commandDecorators/command.ts +3 -14
  17. package/commandDecorators/commandDecorators.test.ts +1 -1
  18. package/commandDecorators/workspaceVersionCheck.test.ts +70 -0
  19. package/commandDecorators/workspaceVersionCheck.ts +50 -0
  20. package/executors.test.ts +21 -1
  21. package/index.ts +0 -1
  22. package/lint/gritRules.test.ts +3 -3
  23. package/linter.test.ts +18 -1
  24. package/linter.ts +10 -6
  25. package/mobile/desktopRelease.test.ts +473 -0
  26. package/mobile/desktopRelease.ts +307 -0
  27. package/mobile/desktopServerStage.ts +4 -2
  28. package/mobile/index.ts +2 -0
  29. package/mobile/nativeApi.test.ts +4 -1
  30. package/mobile/nativeApi.ts +3 -3
  31. package/mobile/nativeApp.test.ts +2 -2
  32. package/mobile/nativeApp.ts +29 -5
  33. package/mobile/nativeConfig.test.ts +16 -8
  34. package/mobile/nativeConfig.ts +1 -1
  35. package/mobile/tarWriter.test.ts +89 -0
  36. package/mobile/tarWriter.ts +93 -0
  37. package/mobile/updateUploader.test.ts +34 -0
  38. package/mobile/updateUploader.ts +9 -6
  39. package/package.json +2 -2
  40. package/renderMutationScanner.test.ts +223 -0
  41. package/renderMutationScanner.ts +209 -0
  42. package/signalMutationIndex.test.ts +120 -0
  43. package/signalMutationIndex.ts +127 -0
  44. package/ssrScanner.test.ts +31 -0
  45. package/ssrScanner.ts +31 -12
  46. package/uploadRelease.ts +0 -80
@@ -0,0 +1,93 @@
1
+ import { createHash } from "node:crypto";
2
+ import { type FileHandle, open } from "node:fs/promises";
3
+
4
+ //* An uncompressed POSIX tar of regular files, the same bytes on every OS. A system tar also writes what the host keeps
5
+ //* beside a file (macOS bsdtar an AppleDouble `._` entry for extended attributes), which a receiver refusing unlisted
6
+ //* entries rejects. A name past the ustar field's 100 bytes travels in a pax `path` record before its header.
7
+ export class TarWriter {
8
+ static readonly #block = 512;
9
+ static readonly #nameBytes = 100;
10
+ //? The size field holds 11 octal digits.
11
+ static readonly #maxSize = 8 ** 11 - 1;
12
+ readonly #handle: FileHandle;
13
+ readonly #mtime: number;
14
+
15
+ private constructor(handle: FileHandle, mtime: number) {
16
+ this.#handle = handle;
17
+ this.#mtime = mtime;
18
+ }
19
+
20
+ //? `mtime` (seconds) goes on every entry, so the same files make the same archive.
21
+ static async create(file: string, { mtime = 0 }: { mtime?: number } = {}) {
22
+ return new TarWriter(await open(file, "wx"), mtime);
23
+ }
24
+
25
+ async add(name: string, source: Blob): Promise<string> {
26
+ const { size } = source;
27
+ if (size > TarWriter.#maxSize) throw new Error(`${name} is ${size} bytes, more than a tar entry holds.`);
28
+ if (Buffer.byteLength(name) > TarWriter.#nameBytes) {
29
+ const pax = Buffer.from(TarWriter.#paxRecord("path", name));
30
+ await this.#write(
31
+ TarWriter.#header(`./PaxHeaders/${name.split("/").at(-1) ?? ""}`, pax.length, this.#mtime, "x"),
32
+ );
33
+ await this.#write(pax);
34
+ await this.#pad(pax.length);
35
+ }
36
+ await this.#write(TarWriter.#header(name, size, this.#mtime, "0"));
37
+ const hasher = createHash("sha256");
38
+ let written = 0;
39
+ for await (const chunk of source.stream()) {
40
+ hasher.update(chunk);
41
+ await this.#write(chunk);
42
+ written += chunk.length;
43
+ }
44
+ if (written !== size) throw new Error(`${name} changed while it was archived: ${written} bytes, not ${size}.`);
45
+ await this.#pad(size);
46
+ return hasher.digest("hex");
47
+ }
48
+
49
+ async close() {
50
+ await this.#write(new Uint8Array(TarWriter.#block * 2));
51
+ await this.#handle.close();
52
+ }
53
+
54
+ async discard() {
55
+ await this.#handle.close().catch(() => undefined);
56
+ }
57
+
58
+ async #write(bytes: Uint8Array) {
59
+ for (let at = 0; at < bytes.length; ) at += (await this.#handle.write(bytes, at)).bytesWritten;
60
+ }
61
+
62
+ async #pad(size: number) {
63
+ const rest = size % TarWriter.#block;
64
+ if (rest) await this.#write(new Uint8Array(TarWriter.#block - rest));
65
+ }
66
+
67
+ //? A record's length counts its own digits: `<length> <key>=<value>\n`.
68
+ static #paxRecord(key: string, value: string) {
69
+ const body = Buffer.byteLength(` ${key}=${value}\n`);
70
+ let length = body + 1;
71
+ while (body + String(length).length !== length) length = body + String(length).length;
72
+ return `${length} ${key}=${value}\n`;
73
+ }
74
+
75
+ static #header(name: string, size: number, mtime: number, type: "0" | "x") {
76
+ const header = Buffer.alloc(TarWriter.#block);
77
+ header.write(name, 0, TarWriter.#nameBytes, "utf8");
78
+ const octal = (value: number, at: number, width: number) =>
79
+ header.write(`${value.toString(8).padStart(width - 1, "0")}\0`, at, width, "latin1");
80
+ octal(0o644, 100, 8);
81
+ octal(0, 108, 8);
82
+ octal(0, 116, 8);
83
+ octal(size, 124, 12);
84
+ octal(mtime, 136, 12);
85
+ header.write(" ", 148, 8, "latin1");
86
+ header.write(type, 156, 1, "latin1");
87
+ header.write("ustar\0", 257, 6, "latin1");
88
+ header.write("00", 263, 2, "latin1");
89
+ const checksum = header.reduce((sum, byte) => sum + byte, 0);
90
+ header.write(`${checksum.toString(8).padStart(6, "0")}\0 `, 148, 8, "latin1");
91
+ return header;
92
+ }
93
+ }
@@ -83,6 +83,40 @@ describe("UpdateUploader", () => {
83
83
  );
84
84
  });
85
85
 
86
+ test("uploads a desktop release's envelope, with the v1 pair only when the pair is that release", async () => {
87
+ const manifest = { kind: "app", bundle: "1.3.0-3000", archive: { url: `app/${sha("c")}.tar.gz` }, patches: [] };
88
+ await inUpdatesDir(
89
+ {
90
+ [`macos-arm64/app/${sha("c")}.tar.gz`]: "tar",
91
+ "macos-arm64/main/app.json": envelope(manifest),
92
+ "macos-arm64/main.json": JSON.stringify({ ...manifest, bundle: "1.2.0-2000" }),
93
+ "macos-arm64/main.json.sig": "sig",
94
+ "macos-arm64/beta/app.json": envelope({ ...manifest, bundle: "1.4.0-4000" }),
95
+ "macos-arm64/beta.json": JSON.stringify({ ...manifest, bundle: "1.4.0-4000" }),
96
+ "macos-arm64/beta.json.sig": "sig",
97
+ },
98
+ async (updatesDir) => {
99
+ const dir = path.join(updatesDir, "macos-arm64");
100
+ const upload = async (channel: string, bundle: string) => {
101
+ const { bucket, writes } = fakeBucket();
102
+ await new UpdateUploader(bucket, "").upload(updatesDir, { dir, channel, bundle });
103
+ return writes.map(({ key }) => key);
104
+ };
105
+
106
+ expect(await upload("main", "1.3.0-3000")).toEqual([
107
+ `macos-arm64/app/${sha("c")}.tar.gz`,
108
+ "macos-arm64/main/app.json",
109
+ ]);
110
+ expect(await upload("beta", "1.4.0-4000")).toEqual([
111
+ `macos-arm64/app/${sha("c")}.tar.gz`,
112
+ "macos-arm64/beta.json.sig",
113
+ "macos-arm64/beta.json",
114
+ "macos-arm64/beta/app.json",
115
+ ]);
116
+ },
117
+ );
118
+ });
119
+
86
120
  test("uploads a phone release's own files and envelope, never pack records or another manifest beside them", async () => {
87
121
  await inUpdatesDir(
88
122
  {
@@ -51,12 +51,8 @@ export class UpdateUploader {
51
51
  //* APIs, which a later publish from another machine may have replaced.
52
52
  static async releaseFiles(updatesDir: string, { dir, channel, bundle }: PublishedUpdate): Promise<string[]> {
53
53
  const keyOf = (file: unknown) => UpdateUploader.#keyOf(updatesDir, dir, file);
54
- const desktop = await UpdateUploader.#read<UpdateManifest>(path.join(dir, `${channel}.json`));
55
- if (desktop?.bundle === bundle) {
56
- const archive = desktop.archive?.url;
57
- const patches = UpdateUploader.#field(desktop.patches, "url");
58
- return [archive, ...patches, `${channel}.json.sig`, `${channel}.json`].map(keyOf);
59
- }
54
+ const v1 = await UpdateUploader.#read<UpdateManifest>(path.join(dir, `${channel}.json`));
55
+ const pair = v1?.bundle === bundle ? [`${channel}.json.sig`, `${channel}.json`] : [];
60
56
  const names = await readdir(path.join(dir, channel)).catch(() => [] as string[]);
61
57
  for (const name of names.filter((name) => name.endsWith(".json"))) {
62
58
  const envelope = await UpdateUploader.#read<UpdateEnvelope>(path.join(dir, channel, name));
@@ -65,6 +61,8 @@ export class UpdateUploader {
65
61
  ? UpdateUploader.#parse<UpdateManifest>(Buffer.from(envelope.manifest, "base64").toString("utf8"))
66
62
  : null;
67
63
  if (manifest?.bundle !== bundle) continue;
64
+ if (manifest.kind === "app")
65
+ return [...UpdateUploader.#appFiles(manifest), ...pair, `${channel}/${name}`].map(keyOf);
68
66
  const hashes = [...new Set(UpdateUploader.#field(manifest.files, "sha256"))];
69
67
  if (!hashes.every((hash): hash is string => typeof hash === "string" && /^[0-9a-f]{64}$/.test(hash)))
70
68
  throw new Error(
@@ -72,9 +70,14 @@ export class UpdateUploader {
72
70
  );
73
71
  return [...hashes.map((hash) => `${UpdatePackFolder.filesDir}/${hash}`), `${channel}/${name}`].map(keyOf);
74
72
  }
73
+ if (v1 && pair.length) return [...UpdateUploader.#appFiles(v1), ...pair].map(keyOf);
75
74
  throw new Error(`${dir} holds no ${channel} manifest of release ${bundle}; nothing was uploaded.`);
76
75
  }
77
76
 
77
+ static #appFiles(manifest: Partial<UpdateManifest>): unknown[] {
78
+ return [manifest.archive?.url, ...UpdateUploader.#field(manifest.patches, "url")];
79
+ }
80
+
78
81
  static #keyOf(updatesDir: string, dir: string, file: unknown) {
79
82
  const resolved = typeof file === "string" ? path.resolve(dir, file) : "";
80
83
  const key = path.relative(updatesDir, resolved);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "3.1.1-rc.0",
3
+ "version": "3.1.1-rc.10",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -48,7 +48,7 @@
48
48
  "@earendil-works/pi-tui": "1.3.0",
49
49
  "@inquirer/prompts": "^8.7.3",
50
50
  "@tailwindcss/node": "^4.3.3",
51
- "akanjs": "3.1.1-rc.0",
51
+ "akanjs": "3.1.1-rc.10",
52
52
  "chalk": "^6.0.1",
53
53
  "commander": "^15.0.0",
54
54
  "dayjs": "^1.11.23",
@@ -0,0 +1,223 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import ts from "typescript";
3
+ import type { SourceFileInfo } from "./qualityScanner";
4
+ import { RenderMutationScanner } from "./renderMutationScanner";
5
+ import { SignalMutationIndex } from "./signalMutationIndex";
6
+
7
+ const fileOf = (file: string, content: string): SourceFileInfo => ({
8
+ file,
9
+ absolutePath: `/repo/${file}`,
10
+ content,
11
+ sourceFile: ts.createSourceFile(
12
+ file,
13
+ content,
14
+ ts.ScriptTarget.Latest,
15
+ true,
16
+ file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
17
+ ),
18
+ });
19
+
20
+ const signal = fileOf(
21
+ "apps/demo/lib/item/item.signal.ts",
22
+ `
23
+ export class ItemSlice extends slice(srv.item, { guards: { root: Admin, get: Public, cru: User } }, () => ({})) {}
24
+ export class ItemEndpoint extends endpoint(srv.item, ({ query, mutation }) => ({
25
+ approveItem: mutation(cnst.Item, { guards: [User] }).param("itemId", ID).exec(async function (itemId) {
26
+ return await this.itemService.approve(itemId);
27
+ }),
28
+ itemCount: query(Int, { guards: [User] }).exec(async function () {
29
+ return 0;
30
+ }),
31
+ })) {}
32
+ `,
33
+ );
34
+
35
+ const PAGE = "apps/demo/page/item/[itemId].tsx";
36
+ const pageOf = (chain: string, file = PAGE) =>
37
+ fileOf(
38
+ file,
39
+ `import { fetch, usePage } from "@apps/demo/client";\nimport { ID } from "akanjs/base";\nimport { page } from "akanjs/client";\n\nexport default ${chain};\n`,
40
+ );
41
+
42
+ const scan = (...files: SourceFileInfo[]) =>
43
+ new RenderMutationScanner(new SignalMutationIndex([signal, ...files])).scan(files);
44
+
45
+ describe("akan.ssr.render-mutation", () => {
46
+ test("flags one diagnostic per mutation a render sends, awaited or not", () => {
47
+ const warnings = scan(
48
+ pageOf(`page()
49
+ .param("itemId", ID)
50
+ .render(async ({ itemId }) => {
51
+ const [item] = await Promise.all([fetch.approveItem(itemId), fetch.itemCount()]);
52
+ void fetch.updateItem(itemId, {});
53
+ return <p>{item.id}</p>;
54
+ })`),
55
+ );
56
+ expect(warnings.map(({ rule, line }) => ({ rule, line }))).toEqual([
57
+ { rule: "akan.ssr.render-mutation", line: 8 },
58
+ { rule: "akan.ssr.render-mutation", line: 9 },
59
+ ]);
60
+ expect(warnings[0]?.message).toContain("fetch.approveItem()");
61
+ expect(warnings[0]?.fix).toContain("{ duringRender: true }");
62
+ });
63
+
64
+ test("a query, a store action and a client loader are not findings", () => {
65
+ const warnings = scan(
66
+ pageOf(`page()
67
+ .param("itemId", ID)
68
+ .render(({ itemId }) => {
69
+ const { itemInitInOrg } = fetch.initItemInOrg(itemId);
70
+ return <Item.Zone.Card init={itemInitInOrg} count={fetch.itemCount()} />;
71
+ })`),
72
+ );
73
+ expect(warnings).toEqual([]);
74
+ });
75
+
76
+ test("a JSX event handler runs on a user action, while a mapped call still runs in the render", () => {
77
+ const warnings = scan(
78
+ pageOf(`page()
79
+ .param("itemId", ID)
80
+ .render(({ itemId }) => {
81
+ return (
82
+ <form onSubmit={async () => { await fetch.approveItem(itemId); }}>
83
+ {[itemId].map((id) => <p key={id}>{String(fetch.approveItem(id))}</p>)}
84
+ </form>
85
+ );
86
+ })`),
87
+ );
88
+ expect(warnings.map(({ line }) => line)).toEqual([10]);
89
+ });
90
+
91
+ test("a call that opts in with { duringRender: true } is not a finding", () => {
92
+ expect(
93
+ scan(
94
+ pageOf(
95
+ `page().param("itemId", ID).render(async ({ itemId }) => { await fetch.approveItem(itemId, { duringRender: true }); return null; })`,
96
+ ),
97
+ ),
98
+ ).toEqual([]);
99
+ expect(
100
+ scan(
101
+ pageOf(`page().render(async () => { await fetch.approveItem("x", { duringRender: false }); return null; })`),
102
+ ),
103
+ ).toHaveLength(1);
104
+ });
105
+
106
+ test("reads the opt-in only from the slot the runtime reads, right after the declared arguments", () => {
107
+ const renderOf = (call: string) =>
108
+ scan(pageOf(`page().param("itemId", ID).render(async ({ itemId }) => { await ${call}; return null; })`)).length;
109
+ expect(renderOf(`fetch.updateItem(itemId, { duringRender: true })`)).toBe(1);
110
+ expect(renderOf(`fetch.updateItem(itemId, { title: "x", duringRender: true })`)).toBe(1);
111
+ expect(renderOf(`fetch.updateItem(itemId, { title: "x" }, { duringRender: true })`)).toBe(0);
112
+ expect(renderOf(`fetch.mergeItem(itemId, {}, { duringRender: true } as const)`)).toBe(0);
113
+ expect(renderOf(`fetch.addItemFiles([], { duringRender: true })`)).toBe(1);
114
+ expect(renderOf(`fetch.addItemFiles([], undefined, { duringRender: true })`)).toBe(0);
115
+ expect(renderOf(`fetch.createItem({ duringRender: true })`)).toBe(1);
116
+ expect(renderOf(`fetch.approveItem(itemId, { "duringRender": true })`)).toBe(0);
117
+ expect(renderOf(`fetch.approveItem(itemId, { duringRender: undefined })`)).toBe(1);
118
+ });
119
+
120
+ test("takes a policy it cannot evaluate as an opt-in, and a value that cannot be one as none", () => {
121
+ const renderOf = (call: string) =>
122
+ scan(
123
+ pageOf(
124
+ `page().param("itemId", ID).render(async ({ itemId }) => { const warm = { duringRender: true }; await ${call}; return null; })`,
125
+ ),
126
+ ).length;
127
+ for (const call of [
128
+ "fetch.approveItem(itemId, warm)",
129
+ "fetch.approveItem(itemId, { ...warm })",
130
+ "fetch.approveItem(itemId, policyOf(itemId))",
131
+ "fetch.approveItem(...[itemId, warm])",
132
+ ])
133
+ expect(renderOf(call)).toBe(0);
134
+ for (const call of [
135
+ "fetch.approveItem(itemId, undefined)",
136
+ "fetch.approveItem(itemId, null)",
137
+ 'fetch.approveItem(itemId, "warm")',
138
+ "fetch.updateItem(itemId, warm)",
139
+ ])
140
+ expect(renderOf(call)).toBe(1);
141
+ });
142
+
143
+ test("follows a same-file function the stage calls or renders, one level deep", () => {
144
+ const file = fileOf(
145
+ PAGE,
146
+ `import { fetch } from "@apps/demo/client";
147
+ import { ID } from "akanjs/base";
148
+ import { page } from "akanjs/client";
149
+
150
+ const approve = async (id: string) => await fetch.approveItem(id);
151
+ function Approval({ id }: { id: string }) {
152
+ void fetch.removeItem(id);
153
+ return null;
154
+ }
155
+ const unused = async () => await fetch.createItem({});
156
+ const onClickOnly = () => fetch.updateItem("x", {});
157
+
158
+ export default page()
159
+ .param("itemId", ID)
160
+ .render(async ({ itemId }) => {
161
+ await approve(itemId);
162
+ return <Approval id={itemId} onSelect={onClickOnly} />;
163
+ });
164
+ `,
165
+ );
166
+ expect(scan(file).map(({ line }) => line)).toEqual([5, 7]);
167
+ });
168
+
169
+ test("reads a page exported by name, a stage given by name and a call by string key", () => {
170
+ const legacy = fileOf(
171
+ PAGE,
172
+ `import { fetch } from "@apps/demo/client";\nconst Page = async () => {\n await fetch.approveItem("x");\n return null;\n};\nexport default Page;\n`,
173
+ );
174
+ expect(scan(legacy).map(({ line }) => line)).toEqual([3]);
175
+ const named = fileOf(
176
+ PAGE,
177
+ `import { fetch } from "@apps/demo/client";\nimport { page } from "akanjs/client";\nconst render = async () => {\n await fetch["approveItem"]("x");\n return null;\n};\nexport default page().render(render);\n`,
178
+ );
179
+ expect(scan(named).map(({ line, message }) => [line, message.split("()")[0]])).toEqual([
180
+ [4, 'fetch["approveItem"]'],
181
+ ]);
182
+ });
183
+
184
+ test("reads the guard, the head and a layout's render too", () => {
185
+ const guarded = pageOf(`page()
186
+ .guard(async () => { await fetch.removeItem("x"); })
187
+ .head(async () => { await fetch.approveItem("x"); return <title>x</title>; })
188
+ .render(() => null)`);
189
+ const wrapping = pageOf(
190
+ `layout().render(async ({ children }) => { await fetch.createItem({}); return children; })`,
191
+ "apps/demo/page/item/_layout.tsx",
192
+ );
193
+ expect(scan(guarded, wrapping).map(({ file, line }) => `${file}:${line}`)).toEqual([
194
+ `${PAGE}:6`,
195
+ `${PAGE}:7`,
196
+ "apps/demo/page/item/_layout.tsx:5",
197
+ ]);
198
+ });
199
+
200
+ test("reads a legacy default-exported page function", () => {
201
+ expect(scan(pageOf(`async function Page() { await fetch.approveItem("x"); return null; }`))).toHaveLength(1);
202
+ });
203
+
204
+ test("an app load, a file outside page/ and a page with no fetch import are not read", () => {
205
+ expect(
206
+ scan(
207
+ pageOf(
208
+ `page().app("showItem", "Show the item.", async () => { await fetch.approveItem("x"); return null; }).render(() => null)`,
209
+ ),
210
+ ),
211
+ ).toEqual([]);
212
+ expect(
213
+ scan(
214
+ pageOf(`page().render(async () => { await fetch.approveItem("x"); return null; })`, "apps/demo/ui/Item.tsx"),
215
+ ),
216
+ ).toEqual([]);
217
+ const global = fileOf(
218
+ PAGE,
219
+ `export default page().render(async () => { await fetch.approveItem("x"); return null; });`,
220
+ );
221
+ expect(scan(global)).toEqual([]);
222
+ });
223
+ });
@@ -0,0 +1,209 @@
1
+ import ts from "typescript";
2
+ import type { QualityWarning, SourceFileInfo } from "./qualityScanner";
3
+ import type { SignalMutationIndex } from "./signalMutationIndex";
4
+
5
+ //? A server render sends the visitor's credential with no Origin, so its writes run for whoever opens a link.
6
+ export class RenderMutationScanner {
7
+ static readonly rule = "akan.ssr.render-mutation";
8
+ static readonly fix =
9
+ "Send the write from an explicit user action, such as a button that calls a store action. Only a write that is harmless to repeat and safe for anyone to trigger may stay in the render, with `{ duringRender: true }` as the call's option.";
10
+ static #builders = new Set(["page", "layout", "rootLayout"]);
11
+ static #stages = new Set(["render", "guard", "head", "loading", "notFound", "error"]);
12
+ readonly #mutations: SignalMutationIndex;
13
+
14
+ constructor(mutations: SignalMutationIndex) {
15
+ this.#mutations = mutations;
16
+ }
17
+
18
+ scan(sourceFiles: SourceFileInfo[]): QualityWarning[] {
19
+ return sourceFiles
20
+ .filter(({ file }) => /^(apps|libs)\/[^/]+\/page\/.+(?<!\.test|\.spec)\.tsx$/.test(file))
21
+ .flatMap((sourceFile) => this.#scanRoute(sourceFile));
22
+ }
23
+
24
+ #scanRoute({ file, sourceFile }: SourceFileInfo): QualityWarning[] {
25
+ const fetchNames = RenderMutationScanner.#fetchBindingsOf(sourceFile);
26
+ if (!fetchNames.size) return [];
27
+ const functions = RenderMutationScanner.#functionsOf(sourceFile);
28
+ const calls = new Set<ts.CallExpression>();
29
+ const helpers = new Set<ts.Node>();
30
+ const visit = (node: ts.Node, follow: boolean) => {
31
+ if (RenderMutationScanner.#isEventHandler(node)) return;
32
+ if (this.#isRenderMutation(file, node, fetchNames)) calls.add(node);
33
+ const helper = follow ? RenderMutationScanner.#helperOf(node, functions) : undefined;
34
+ if (helper) helpers.add(helper);
35
+ ts.forEachChild(node, (child) => visit(child, follow));
36
+ };
37
+ for (const body of RenderMutationScanner.#renderBodiesOf(sourceFile, functions)) visit(body, true);
38
+ for (const helper of helpers) visit(helper, false);
39
+ return [...calls]
40
+ .sort((a, b) => a.getStart(sourceFile) - b.getStart(sourceFile))
41
+ .map((call) => ({
42
+ rule: RenderMutationScanner.rule,
43
+ scope: "ssr",
44
+ severity: "warning",
45
+ file,
46
+ line: sourceFile.getLineAndCharacterOfPosition(call.getStart(sourceFile)).line + 1,
47
+ message: `${call.expression.getText(sourceFile)}() is a mutation sent while the route renders: anyone who opens a link to this page would perform it, and the call is refused unless it passes { duringRender: true }.`,
48
+ fix: RenderMutationScanner.fix,
49
+ }));
50
+ }
51
+
52
+ #isRenderMutation(file: string, node: ts.Node, fetchNames: Set<string>): node is ts.CallExpression {
53
+ if (!ts.isCallExpression(node)) return false;
54
+ const name = RenderMutationScanner.#endpointOf(node.expression, fetchNames);
55
+ if (!name || !this.#mutations.isMutation(file, name)) return false;
56
+ return !RenderMutationScanner.#optsIn(node, this.#mutations.policyIndexOf(file, name));
57
+ }
58
+
59
+ static #isEventHandler(node: ts.Node) {
60
+ return ts.isJsxAttribute(node) && ts.isIdentifier(node.name) && /^on[A-Z]/.test(node.name.text);
61
+ }
62
+
63
+ static #endpointOf(callee: ts.Expression, fetchNames: Set<string>) {
64
+ if (ts.isPropertyAccessExpression(callee))
65
+ return RenderMutationScanner.#isFetch(callee.expression, fetchNames) ? callee.name.text : null;
66
+ if (ts.isElementAccessExpression(callee) && ts.isStringLiteralLike(callee.argumentExpression))
67
+ return RenderMutationScanner.#isFetch(callee.expression, fetchNames) ? callee.argumentExpression.text : null;
68
+ return null;
69
+ }
70
+
71
+ static #isFetch(expression: ts.Expression, fetchNames: Set<string>) {
72
+ return ts.isIdentifier(expression) && fetchNames.has(expression.text);
73
+ }
74
+
75
+ //? The runtime reads only the policy slot, and a value there this cannot evaluate (a variable, a call) may opt in.
76
+ static #optsIn(call: ts.CallExpression, policyIndex: number | null) {
77
+ if (policyIndex === null) return false;
78
+ const leading = call.arguments.slice(0, policyIndex + 1);
79
+ if (leading.some((argument) => ts.isSpreadElement(argument))) return true;
80
+ const policy = leading[policyIndex];
81
+ return policy ? RenderMutationScanner.#policyOptsIn(policy) : false;
82
+ }
83
+
84
+ static #policyOptsIn(argument: ts.Expression): boolean {
85
+ const policy = RenderMutationScanner.#unwrap(argument);
86
+ if (!ts.isObjectLiteralExpression(policy)) return !RenderMutationScanner.#cannotBePolicy(policy);
87
+ return policy.properties.some((property) => {
88
+ if (ts.isSpreadAssignment(property)) return true;
89
+ if (!ts.isPropertyAssignment(property) && !ts.isShorthandPropertyAssignment(property)) return false;
90
+ if (!(ts.isIdentifier(property.name) || ts.isStringLiteral(property.name))) return false;
91
+ if (property.name.text !== "duringRender") return false;
92
+ if (ts.isShorthandPropertyAssignment(property)) return true;
93
+ const value = RenderMutationScanner.#unwrap(property.initializer);
94
+ return value.kind !== ts.SyntaxKind.FalseKeyword && !(ts.isIdentifier(value) && value.text === "undefined");
95
+ });
96
+ }
97
+
98
+ static #unwrap(expression: ts.Expression): ts.Expression {
99
+ let current = expression;
100
+ while (
101
+ ts.isParenthesizedExpression(current) ||
102
+ ts.isAsExpression(current) ||
103
+ ts.isSatisfiesExpression(current) ||
104
+ ts.isNonNullExpression(current) ||
105
+ ts.isTypeAssertionExpression(current)
106
+ )
107
+ current = current.expression;
108
+ return current;
109
+ }
110
+
111
+ static #cannotBePolicy(expression: ts.Expression) {
112
+ if (ts.isIdentifier(expression)) return expression.text === "undefined";
113
+ return (
114
+ ts.isLiteralExpression(expression) ||
115
+ ts.isNoSubstitutionTemplateLiteral(expression) ||
116
+ ts.isTemplateExpression(expression) ||
117
+ ts.isArrayLiteralExpression(expression) ||
118
+ ts.isArrowFunction(expression) ||
119
+ ts.isFunctionExpression(expression) ||
120
+ ts.isVoidExpression(expression) ||
121
+ ts.isPrefixUnaryExpression(expression) ||
122
+ [ts.SyntaxKind.NullKeyword, ts.SyntaxKind.TrueKeyword, ts.SyntaxKind.FalseKeyword].includes(expression.kind)
123
+ );
124
+ }
125
+
126
+ static #fetchBindingsOf(sourceFile: ts.SourceFile) {
127
+ const names = new Set<string>();
128
+ for (const statement of sourceFile.statements) {
129
+ if (!ts.isImportDeclaration(statement) || statement.importClause?.isTypeOnly) continue;
130
+ const bindings = statement.importClause?.namedBindings;
131
+ if (!bindings || !ts.isNamedImports(bindings)) continue;
132
+ for (const element of bindings.elements)
133
+ if (!element.isTypeOnly && (element.propertyName ?? element.name).text === "fetch")
134
+ names.add(element.name.text);
135
+ }
136
+ return names;
137
+ }
138
+
139
+ static #functionsOf(sourceFile: ts.SourceFile) {
140
+ const functions = new Map<string, ts.Node>();
141
+ for (const statement of sourceFile.statements) {
142
+ if (ts.isFunctionDeclaration(statement) && statement.name && statement.body)
143
+ functions.set(statement.name.text, statement.body);
144
+ if (!ts.isVariableStatement(statement)) continue;
145
+ for (const declaration of statement.declarationList.declarations) {
146
+ const initializer = declaration.initializer && RenderMutationScanner.#unwrap(declaration.initializer);
147
+ if (!ts.isIdentifier(declaration.name) || !initializer) continue;
148
+ if (ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer))
149
+ functions.set(declaration.name.text, initializer.body);
150
+ }
151
+ }
152
+ return functions;
153
+ }
154
+
155
+ //? One level only: a function the stage calls, or renders as a component, runs while the route renders.
156
+ static #helperOf(node: ts.Node, functions: Map<string, ts.Node>) {
157
+ const callee = ts.isCallExpression(node)
158
+ ? node.expression
159
+ : ts.isJsxSelfClosingElement(node) || ts.isJsxOpeningElement(node)
160
+ ? node.tagName
161
+ : undefined;
162
+ return callee && ts.isIdentifier(callee) ? functions.get(callee.text) : undefined;
163
+ }
164
+
165
+ static #renderBodiesOf(sourceFile: ts.SourceFile, functions: Map<string, ts.Node>) {
166
+ const bodies: ts.Node[] = [];
167
+ const bodyOf = (node: ts.Node) => (ts.isIdentifier(node) ? functions.get(node.text) : node);
168
+ const visit = (node: ts.Node) => {
169
+ if (
170
+ ts.isCallExpression(node) &&
171
+ ts.isPropertyAccessExpression(node.expression) &&
172
+ RenderMutationScanner.#stages.has(node.expression.name.text) &&
173
+ RenderMutationScanner.#isRouteChain(node.expression.expression)
174
+ )
175
+ for (const argument of node.arguments) {
176
+ const body = bodyOf(argument);
177
+ if (body) bodies.push(body);
178
+ }
179
+ ts.forEachChild(node, visit);
180
+ };
181
+ visit(sourceFile);
182
+ for (const statement of sourceFile.statements) {
183
+ const legacy = RenderMutationScanner.#legacyPageOf(statement, functions);
184
+ if (legacy) bodies.push(legacy);
185
+ }
186
+ return bodies;
187
+ }
188
+
189
+ static #isRouteChain(expression: ts.Expression) {
190
+ let current = expression;
191
+ while (ts.isCallExpression(current)) {
192
+ if (ts.isIdentifier(current.expression)) return RenderMutationScanner.#builders.has(current.expression.text);
193
+ if (!ts.isPropertyAccessExpression(current.expression)) return false;
194
+ current = current.expression.expression;
195
+ }
196
+ return false;
197
+ }
198
+
199
+ static #legacyPageOf(statement: ts.Statement, functions: Map<string, ts.Node>) {
200
+ if (ts.isFunctionDeclaration(statement))
201
+ return statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword)
202
+ ? statement.body
203
+ : undefined;
204
+ if (!ts.isExportAssignment(statement)) return undefined;
205
+ const expression = RenderMutationScanner.#unwrap(statement.expression);
206
+ if (ts.isIdentifier(expression)) return functions.get(expression.text);
207
+ return ts.isArrowFunction(expression) || ts.isFunctionExpression(expression) ? expression.body : undefined;
208
+ }
209
+ }