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

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.
package/biomeBase.test.ts CHANGED
@@ -7,7 +7,7 @@ import { biomeBaseConfig } from "./biomeBase";
7
7
  import { Linter, parseBiomeReport } from "./linter";
8
8
 
9
9
  const devkitDir = import.meta.dir;
10
- const biomeBin = path.resolve(devkitDir, "../../../node_modules/.bin", Linter.biomeBinName);
10
+ const [biome, ...biomeArgs] = Linter.biomeCommand(path.resolve(devkitDir, "../../.."));
11
11
 
12
12
  const hookProbe = `export const Early = ({ id }: { id?: string }) => {
13
13
  if (!id) return null;
@@ -28,7 +28,7 @@ afterAll(async () => {
28
28
 
29
29
  const runBiome = (args: string[], cwd: string) =>
30
30
  new Promise<string>((resolve, reject) => {
31
- const proc = spawn(biomeBin, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
31
+ const proc = spawn(biome, [...biomeArgs, ...args], { cwd, stdio: ["ignore", "pipe", "pipe"] });
32
32
  let stdout = "";
33
33
  let stderr = "";
34
34
  proc.stdout.on("data", (chunk: Buffer) => (stdout += chunk.toString()));
@@ -1,7 +1,9 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
2
  import { readFile, rm } from "node:fs/promises";
3
3
  import path from "node:path";
4
+ import type { Workspace } from "../commandDecorators";
4
5
  import { tempDirs, writeJson, writeText } from "../testHelpers";
6
+ import { CloudApi } from "./cloudApi";
5
7
 
6
8
  // The cloud rotates a refresh token on every use and treats a second use as theft, revoking every session.
7
9
  class FakeCloud {
@@ -10,6 +12,8 @@ class FakeCloud {
10
12
  reused = false;
11
13
  delayMs = 0;
12
14
  hang: boolean = false;
15
+ readonly remoteAuthPolls: string[] = [];
16
+ remoteAuthAnswers: (Response | { jwt: string; refreshToken?: string } | null)[] = [];
13
17
  readonly #refreshTokens = new Set(["refresh-0"]);
14
18
  readonly #spent = new Set<string>();
15
19
  readonly #jwts = new Set(["jwt-0"]);
@@ -29,6 +33,14 @@ class FakeCloud {
29
33
  const jwt = request.headers.get("authorization")?.replace(/^Bearer /, "") ?? "";
30
34
  return Response.json(this.#jwts.has(jwt) ? { id: "u1", nickname: "akan" } : null);
31
35
  }
36
+ const remoteAuthPrefix = "/api/getRemoteAuthToken/";
37
+ if (url.pathname.startsWith(remoteAuthPrefix)) {
38
+ this.remoteAuthPolls.push(decodeURIComponent(url.pathname.slice(remoteAuthPrefix.length)));
39
+ const answer = this.remoteAuthAnswers.shift() ?? null;
40
+ if (answer instanceof Response) return answer;
41
+ if (answer) this.#jwts.add(answer.jwt);
42
+ return Response.json(answer);
43
+ }
32
44
  if (url.pathname !== "/api/refreshAuthToken") return new Response("not found", { status: 404 });
33
45
  const { refreshToken } = (await request.json()) as { refreshToken: string };
34
46
  this.refreshes.push(refreshToken);
@@ -200,3 +212,37 @@ describe("CloudApi.fromHost token refresh", () => {
200
212
  expect(await storedToken(home)).toMatchObject({ jwt: "jwt-0", refreshToken: "refresh-0" });
201
213
  }, 30_000);
202
214
  });
215
+
216
+ describe("CloudApi remote sign-in", () => {
217
+ const remoteId = "0f6a1d2e-8c43-4b7a-9e11-5d2c3b4a6f70";
218
+ const apiOf = () => new CloudApi({ workspaceRoot: "/unused" } as unknown as Workspace, { host: cloud.host });
219
+
220
+ test("reads the server's null as pending, then takes the session it hands out and calls with it", async () => {
221
+ cloud = new FakeCloud();
222
+ cloud.remoteAuthAnswers = [null, { jwt: "jwt-remote", refreshToken: "refresh-remote" }];
223
+ const api = apiOf();
224
+
225
+ expect(await api.getRemoteAuthToken(remoteId)).toBeNull();
226
+ expect(await api.getRemoteSelf()).toBeNull();
227
+ expect(await api.getRemoteAuthToken(remoteId)).toMatchObject({ jwt: "jwt-remote", refreshToken: "refresh-remote" });
228
+ expect(await api.getRemoteSelf()).toEqual({ id: "u1", nickname: "akan" });
229
+ expect(cloud.remoteAuthPolls).toEqual([remoteId, remoteId]);
230
+ });
231
+
232
+ test("throws a refusal, a server error, an answer that is no session, and an unreachable server", async () => {
233
+ cloud = new FakeCloud();
234
+ cloud.remoteAuthAnswers = [
235
+ new Response("<html>Not Found</html>", { status: 404 }),
236
+ Response.json({ error: "base.error.serverUnavailable" }, { status: 503 }),
237
+ Response.json({ token: "not-a-session" }),
238
+ ];
239
+ const api = apiOf();
240
+
241
+ await expect(api.getRemoteAuthToken(remoteId)).rejects.toMatchObject({ name: "CloudHttpError", status: 404 });
242
+ await expect(api.getRemoteAuthToken(remoteId)).rejects.toMatchObject({ name: "CloudHttpError", status: 503 });
243
+ await expect(api.getRemoteAuthToken(remoteId)).rejects.toThrow("something other than a session");
244
+ cloud.server.stop(true);
245
+ await expect(api.getRemoteAuthToken(remoteId)).rejects.toThrow();
246
+ await expect(api.getRemoteSelf()).rejects.toThrow();
247
+ });
248
+ });
package/cloud/cloudApi.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { isRecord } from "akanjs/common";
1
2
  import type { Workspace } from "../commandDecorators";
2
3
  import { CloudHttpError } from "./cloudHttpError";
3
4
  import type { AccessToken, AccessTokenDto, HostConfig } from "./constants";
@@ -127,6 +128,7 @@ export class CloudApi {
127
128
  return new Error(`${unavailable}: ${errKey}.`, { cause: error });
128
129
  return error;
129
130
  }
131
+ static readonly #remoteAuthTimeoutMs = 20_000;
130
132
  readonly #api: HttpClient;
131
133
  #accessToken: AccessToken | null = null;
132
134
  #workspace: Workspace;
@@ -212,12 +214,15 @@ export class CloudApi {
212
214
  await this.#api.getFile(`/downloadEnv/${devProjectId}`, localPath);
213
215
  return localPath;
214
216
  }
217
+ //* The server answers `null` until the login is approved, so a failed request throws rather than read as "not yet".
215
218
  async getRemoteAuthToken(remoteId: string): Promise<AccessToken | null> {
216
- try {
217
- return this.#authorize(await this.#api.get<AccessTokenDto>(`/getRemoteAuthToken/${remoteId}`));
218
- } catch (_) {
219
- return null;
220
- }
219
+ const answer = await this.#api.get<unknown>(`/getRemoteAuthToken/${encodeURIComponent(remoteId)}`, {
220
+ signal: AbortSignal.timeout(CloudApi.#remoteAuthTimeoutMs),
221
+ });
222
+ if (answer === null) return null;
223
+ if (!isRecord(answer) || typeof answer.jwt !== "string" || !answer.jwt)
224
+ throw new Error(`${this.host} answered the sign-in poll with something other than a session.`);
225
+ return this.#authorize(answer as unknown as AccessTokenDto);
221
226
  }
222
227
  async refreshAuthToken(refreshToken: string): Promise<AccessToken> {
223
228
  return this.#authorize(
@@ -241,11 +246,10 @@ export class CloudApi {
241
246
  async revokeTunnel(code: string): Promise<boolean> {
242
247
  return await this.#api.post<boolean>(`/tunnel/revokeTunnel`, { code }, { signal: AbortSignal.timeout(5_000) });
243
248
  }
249
+ /** `null` when the server takes no sign-in from this session; a request that fails throws. */
244
250
  async getRemoteSelf(): Promise<{ id: string; nickname: string } | null> {
245
- try {
246
- return await this.#api.get<{ id: string; nickname: string }>(`/getRemoteSelf`);
247
- } catch {
248
- return null;
249
- }
251
+ return await this.#api.get<{ id: string; nickname: string } | null>(`/getRemoteSelf`, {
252
+ signal: AbortSignal.timeout(CloudApi.#remoteAuthTimeoutMs),
253
+ });
250
254
  }
251
255
  }
package/executors.test.ts CHANGED
@@ -1383,7 +1383,13 @@ describe("SysExecutor scan", () => {
1383
1383
  expect(await lib.readFile("srvkit/index.ts")).toBe(
1384
1384
  ["a9", "a10", "aa", "aB"].map((name) => `export * from "./${name}";\n`).join(""),
1385
1385
  );
1386
- for (const file of ["ui/index.ts", "srvkit/index.ts", "lib/post/index.ts", "akan.lib.json"]) {
1386
+ for (const file of [
1387
+ "ui/index.ts",
1388
+ "srvkit/index.ts",
1389
+ "lib/post/index.ts",
1390
+ "akan.lib.json",
1391
+ "env/env.server.type.ts",
1392
+ ]) {
1387
1393
  const generated = await lib.readFile(file);
1388
1394
  expect(await formatWithBiome(generated, `libs/biomestable/${file}`)).toBe(generated);
1389
1395
  }
@@ -1392,6 +1398,20 @@ describe("SysExecutor scan", () => {
1392
1398
  expect(await devSync.syncForBatch(events)).toEqual({ changedFiles: [], errors: [] });
1393
1399
  });
1394
1400
 
1401
+ test.skipIf(!hasBiome)(
1402
+ "writes env.server.type.ts the way Biome prints it, spreading libs in dependency order",
1403
+ async () => {
1404
+ const { default: getContent } = (await import("../cli/templates/env/_env.server.type")) as {
1405
+ default: (scanInfo: unknown) => { content: string };
1406
+ };
1407
+ const libs = ["util", "shared", "shopAdmin", "shop", "a10", "a9"];
1408
+ const { content } = getContent({ getLibs: () => libs });
1409
+
1410
+ expect(await formatWithBiome(content, "apps/biomestable/env/env.server.type.ts")).toBe(content);
1411
+ expect([...content.matchAll(/\.\.\.(\w+)Option/g)].map(([, lib]) => lib)).toEqual(libs);
1412
+ },
1413
+ );
1414
+
1395
1415
  test("refuses a hand-written ui/index.tsx beside the generated barrel, naming the file and the fix", async () => {
1396
1416
  const { lib } = await createScannableLib("shadowedbarrel", {
1397
1417
  files: {
package/index.ts CHANGED
@@ -31,5 +31,4 @@ export type * from "./stripAnsi";
31
31
  export type * from "./transforms";
32
32
  export type * from "./typeChecker";
33
33
  export type * from "./types";
34
- export type * from "./uploadRelease";
35
34
  export type * from "./workflow";
@@ -11,7 +11,7 @@ import { Linter } from "../linter";
11
11
  const lintDir = import.meta.dir;
12
12
  const fixturesDir = path.join(lintDir, "__fixtures__");
13
13
  const workspaceRoot = path.resolve(lintDir, "../../../..");
14
- const biomeBin = path.join(workspaceRoot, "node_modules", ".bin", Linter.biomeBinName);
14
+ const [biome, ...biomeArgs] = Linter.biomeCommand(workspaceRoot);
15
15
 
16
16
  interface BiomeDiagnostic {
17
17
  severity: string;
@@ -42,7 +42,7 @@ afterAll(async () => {
42
42
 
43
43
  const runBiome = (args: string[], cwd: string) =>
44
44
  new Promise<string>((resolve, reject) => {
45
- const proc = spawn(biomeBin, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
45
+ const proc = spawn(biome, [...biomeArgs, ...args], { cwd, stdio: ["ignore", "pipe", "pipe"] });
46
46
  let stdout = "";
47
47
  let stderr = "";
48
48
  proc.stdout.on("data", (chunk: Buffer) => (stdout += chunk.toString()));
@@ -127,7 +127,7 @@ const fixtures = await collectCases();
127
127
 
128
128
  describe("grit lint rules", () => {
129
129
  test("the harness found the biome binary and at least one fixture", () => {
130
- expect(existsSync(biomeBin)).toBe(true);
130
+ expect(biome).toBe(process.execPath);
131
131
  expect(fixtures.length).toBeGreaterThan(0);
132
132
  });
133
133
 
package/linter.test.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { type BiomeReport, parseBiomeReport } from "./linter";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ import { type BiomeReport, Linter, parseBiomeReport } from "./linter";
5
+ import { hasBiome } from "./testHelpers";
3
6
 
4
7
  const reportOf = (diagnostics: BiomeReport["diagnostics"]): string =>
5
8
  JSON.stringify({ summary: { errors: diagnostics?.length ?? 0, warnings: 0 }, diagnostics });
@@ -77,3 +80,17 @@ describe("parseBiomeReport", () => {
77
80
  expect(() => parseBiomeReport(" \n")).toThrow("No Biome JSON output");
78
81
  });
79
82
  });
83
+
84
+ describe("Linter", () => {
85
+ test.skipIf(!hasBiome || process.platform === "win32")("runs Biome where no node is on PATH", async () => {
86
+ const pathBefore = process.env.PATH;
87
+ process.env.PATH = path.join(tmpdir(), "akan-path-without-node");
88
+ try {
89
+ const { results, errors } = await new Linter(import.meta.dir).lintFile(path.join(import.meta.dir, "linter.ts"));
90
+ expect(results).toHaveLength(1);
91
+ expect(errors).toEqual([]);
92
+ } finally {
93
+ process.env.PATH = pathBefore;
94
+ }
95
+ });
96
+ });
package/linter.ts CHANGED
@@ -113,18 +113,21 @@ const emptyResult = (filePath: string): LintResult => ({
113
113
  });
114
114
 
115
115
  export class Linter {
116
- //? Bun links a package bin as `<name>.exe` (plus a `.bunx` stub) on Windows, and a bare `<name>` elsewhere.
117
- static readonly biomeBinName = process.platform === "win32" ? "biome.exe" : "biome";
116
+ //? The package's bin is npm's `#!/usr/bin/env node` wrapper, which picks the platform binary; run through the Bun
117
+ //? running akan, it works in an image with no Node (exit 127 otherwise). A Biome on PATH is the fallback.
118
+ static biomeCommand(root: string): [string, ...string[]] {
119
+ const wrapper = path.join(root, "node_modules", "@biomejs", "biome", "bin", "biome");
120
+ return existsSync(wrapper) ? [process.execPath, wrapper] : ["biome"];
121
+ }
118
122
 
119
123
  lintRoot: string;
120
124
  configPath: string;
121
- #biomeBin: string;
125
+ #biomeCommand: [string, ...string[]];
122
126
 
123
127
  constructor(cwdPath: string) {
124
128
  this.lintRoot = this.#findBiomeRootPath(cwdPath);
125
129
  this.configPath = Linter.#configPathIn(this.lintRoot) ?? path.join(this.lintRoot, "biome.json");
126
- const localBiomeBin = path.join(this.lintRoot, "node_modules", ".bin", Linter.biomeBinName);
127
- this.#biomeBin = existsSync(localBiomeBin) ? localBiomeBin : "biome";
130
+ this.#biomeCommand = Linter.biomeCommand(this.lintRoot);
128
131
  }
129
132
 
130
133
  static #configPathIn(dir: string): string | null {
@@ -158,7 +161,8 @@ export class Linter {
158
161
  stderr: string;
159
162
  code: number | null;
160
163
  }>((resolve, reject) => {
161
- const proc = spawn(this.#biomeBin, args, {
164
+ const [command, ...prefixArgs] = this.#biomeCommand;
165
+ const proc = spawn(command, [...prefixArgs, ...args], {
162
166
  cwd: this.lintRoot,
163
167
  stdio: ["pipe", "pipe", "pipe"],
164
168
  });
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.2",
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.2",
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
+ }
@@ -0,0 +1,120 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import ts from "typescript";
3
+ import type { SourceFileInfo } from "./qualityScanner";
4
+ import { SignalMutationIndex } from "./signalMutationIndex";
5
+
6
+ const fileOf = (file: string, content: string): SourceFileInfo => ({
7
+ file,
8
+ absolutePath: `/repo/${file}`,
9
+ content,
10
+ sourceFile: ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS),
11
+ });
12
+
13
+ const ticketSignal = fileOf(
14
+ "apps/demo/lib/ticket/ticket.signal.ts",
15
+ `
16
+ export class TicketSlice extends slice(srv.ticket, { guards: { root: Admin, get: Public, cru: User } }, () => ({})) {}
17
+
18
+ export class TicketEndpoint extends endpoint(srv.ticket.with(srv.util.security), ({ query, mutation: write, message }) => ({
19
+ approveTicket: write(cnst.Ticket, { guards: [User] })
20
+ .param("ticketId", ID)
21
+ .exec(async function (ticketId) {
22
+ return await this.ticketService.approve(ticketId);
23
+ }),
24
+ ticketCount: query(Int, { guards: [User] }).exec(async function () {
25
+ return 0;
26
+ }),
27
+ nudgeTicket: message(Boolean, { guards: [User] })
28
+ .msg("ticketId", ID)
29
+ .exec(function (ticketId) {
30
+ return !!ticketId;
31
+ }),
32
+ }))
33
+ {}
34
+ `,
35
+ );
36
+
37
+ describe("SignalMutationIndex", () => {
38
+ test("reads each endpoint's kind from its declaration, aliased builders included", () => {
39
+ const index = new SignalMutationIndex([ticketSignal]);
40
+ expect(index.isMutation("apps/demo/page/x.tsx", "approveTicket")).toBe(true);
41
+ expect(index.isMutation("apps/demo/page/x.tsx", "ticketCount")).toBe(false);
42
+ expect(index.isMutation("apps/demo/page/x.tsx", "initTicketInOrg")).toBe(false);
43
+ });
44
+
45
+ test("counts a socket message as a mutation, its policy after the message arguments", () => {
46
+ const index = new SignalMutationIndex([ticketSignal]);
47
+ expect(index.isMutation("apps/demo/page/x.tsx", "nudgeTicket")).toBe(true);
48
+ expect(index.policyIndexOf("apps/demo/page/x.tsx", "nudgeTicket")).toBe(1);
49
+ });
50
+
51
+ test("counts the CRUD a model's slice generates as mutations", () => {
52
+ const index = new SignalMutationIndex([ticketSignal]);
53
+ for (const name of ["createTicket", "updateTicket", "removeTicket", "mergeTicket", "addTicketFiles"])
54
+ expect(index.isMutation("apps/demo/page/x.tsx", name)).toBe(true);
55
+ expect(index.isMutation("apps/demo/page/x.tsx", "ticket")).toBe(false);
56
+ });
57
+
58
+ test("places each mutation's policy right after the arguments the client takes", () => {
59
+ const signal = fileOf(
60
+ "apps/demo/lib/item/item.signal.ts",
61
+ `export class ItemSlice extends slice(srv.item, { guards: { root: Admin, cru: User } }, () => ({})) {}
62
+ export class ItemEndpoint extends endpoint(srv.item, ({ query, mutation }) => ({
63
+ noteItem: mutation(cnst.Item, { guards: [User] })
64
+ .param("itemId", ID)
65
+ .body("note", String)
66
+ .search("tag", String)
67
+ .with(Ip)
68
+ .exec(async function (itemId, note, tag, ip) {
69
+ return await this.itemService.note(itemId, note, tag, ip);
70
+ }),
71
+ touchItems: mutation(Boolean, { guards: [User] }).exec(() => true),
72
+ itemCount: query(Int, { guards: [User] }).param("itemId", ID).exec(() => 0),
73
+ })) {}`,
74
+ );
75
+ const index = new SignalMutationIndex([signal]);
76
+ const policyOf = (name: string) => index.policyIndexOf("apps/demo/page/x.tsx", name);
77
+ expect(policyOf("noteItem")).toBe(3);
78
+ expect(policyOf("touchItems")).toBe(0);
79
+ expect([policyOf("createItem"), policyOf("removeItem")]).toEqual([1, 1]);
80
+ expect([policyOf("updateItem"), policyOf("mergeItem"), policyOf("addItemFiles")]).toEqual([2, 2, 2]);
81
+ expect(policyOf("itemCount")).toBeNull();
82
+ expect(policyOf("unknownItem")).toBeNull();
83
+ });
84
+
85
+ test("reads a builder given as one parameter and a block body", () => {
86
+ const signal = fileOf(
87
+ "libs/shared/lib/_util/util.signal.ts",
88
+ `export class UtilEndpoint extends endpoint(srv.util, (b) => {
89
+ return { ping: b.query(String, { guards: [Public] }).exec(() => "pong"), touch: b.mutation(Boolean, { guards: [User] }).exec(() => true) };
90
+ }) {}`,
91
+ );
92
+ const index = new SignalMutationIndex([signal]);
93
+ expect(index.isMutation("libs/shared/page/x.tsx", "touch")).toBe(true);
94
+ expect(index.isMutation("libs/shared/page/x.tsx", "ping")).toBe(false);
95
+ });
96
+
97
+ test("scopes an app's endpoints to that app and every lib's to all of them", () => {
98
+ const otherApp = fileOf(
99
+ "apps/other/lib/_other/other.signal.ts",
100
+ `export class OtherEndpoint extends endpoint(srv.other, ({ mutation }) => ({ approveTicket: mutation(Boolean, {}).exec(() => true) })) {}`,
101
+ );
102
+ const lib = fileOf(
103
+ "libs/shared/lib/_shared/shared.signal.ts",
104
+ `export class SharedEndpoint extends endpoint(srv.shared, ({ mutation }) => ({ ticketCount: mutation(Boolean, {}).exec(() => true), touchShared: mutation(Boolean, {}).exec(() => true) })) {}`,
105
+ );
106
+ const index = new SignalMutationIndex([ticketSignal, otherApp, lib]);
107
+ expect(index.isMutation("apps/demo/page/x.tsx", "touchShared")).toBe(true);
108
+ expect(index.isMutation("apps/demo/page/x.tsx", "ticketCount")).toBe(false);
109
+ expect(index.isMutation("libs/shared/page/x.tsx", "approveTicket")).toBe(false);
110
+ expect(index.isMutation("apps/third/page/x.tsx", "approveTicket")).toBe(false);
111
+ });
112
+
113
+ test("ignores signal tests and specs", () => {
114
+ const spec = fileOf(
115
+ "apps/demo/lib/ticket/ticket.signal.spec.ts",
116
+ `endpoint(srv.ticket, ({ mutation }) => ({ fixture: mutation(Boolean, {}).exec(() => true) }));`,
117
+ );
118
+ expect(new SignalMutationIndex([spec]).isMutation("apps/demo/page/x.tsx", "fixture")).toBe(false);
119
+ });
120
+ });
@@ -0,0 +1,127 @@
1
+ import ts from "typescript";
2
+ import type { SourceFileInfo } from "./qualityScanner";
3
+
4
+ interface EndpointShape {
5
+ kind: "query" | "mutation";
6
+ argCount: number;
7
+ }
8
+
9
+ export class SignalMutationIndex {
10
+ //? As the client generates them: `update<Model>(id, data, option?)`, `add<Model>Files(files, parentId?, option?)`.
11
+ static #generatedArgCounts = { create: 1, update: 2, remove: 1, merge: 2 } as const;
12
+ static #argMethods = new Set(["param", "body", "search", "room", "msg"]);
13
+ readonly #shapesByOwner = new Map<string, Map<string, EndpointShape>>();
14
+
15
+ constructor(sourceFiles: SourceFileInfo[]) {
16
+ for (const { file, sourceFile } of sourceFiles) {
17
+ if (!/^(apps|libs)\/[^/]+\/lib\/.+\.signal\.ts$/.test(file)) continue;
18
+ const owner = file.split("/").slice(0, 2).join("/");
19
+ const shapes = this.#shapesByOwner.get(owner) ?? new Map<string, EndpointShape>();
20
+ SignalMutationIndex.#collect(sourceFile, shapes);
21
+ this.#shapesByOwner.set(owner, shapes);
22
+ }
23
+ }
24
+
25
+ isMutation(consumerFile: string, name: string) {
26
+ return this.#shapeOf(consumerFile, name)?.kind === "mutation";
27
+ }
28
+
29
+ /** Where a call to the mutation passes its `FetchPolicy`: the argument right after the ones it declares. */
30
+ policyIndexOf(consumerFile: string, name: string): number | null {
31
+ const shape = this.#shapeOf(consumerFile, name);
32
+ return shape?.kind === "mutation" ? shape.argCount : null;
33
+ }
34
+
35
+ #shapeOf(consumerFile: string, name: string) {
36
+ const [root, owner] = consumerFile.split("/");
37
+ const own = root === "apps" ? this.#shapesByOwner.get(`apps/${owner}`)?.get(name) : undefined;
38
+ if (own) return own;
39
+ for (const [key, shapes] of this.#shapesByOwner) {
40
+ const shape = shapes.get(name);
41
+ if (key.startsWith("libs/") && shape?.kind === "mutation") return shape;
42
+ }
43
+ return undefined;
44
+ }
45
+
46
+ static #collect(sourceFile: ts.SourceFile, kinds: Map<string, EndpointShape>) {
47
+ const visit = (node: ts.Node) => {
48
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
49
+ if (node.expression.text === "slice") SignalMutationIndex.#collectGenerated(node, kinds);
50
+ else if (node.expression.text === "endpoint") SignalMutationIndex.#collectEndpoints(node, kinds);
51
+ }
52
+ ts.forEachChild(node, visit);
53
+ };
54
+ visit(sourceFile);
55
+ }
56
+
57
+ static #collectGenerated(slice: ts.CallExpression, kinds: Map<string, EndpointShape>) {
58
+ let service: ts.Expression | undefined = slice.arguments[0];
59
+ while (service && ts.isCallExpression(service) && ts.isPropertyAccessExpression(service.expression))
60
+ service = service.expression.expression;
61
+ if (!service || !ts.isPropertyAccessExpression(service) || !SignalMutationIndex.#isNamed(service.expression, "srv"))
62
+ return;
63
+ const model = service.name.text;
64
+ const capModel = `${model.charAt(0).toUpperCase()}${model.slice(1)}`;
65
+ for (const [verb, argCount] of Object.entries(SignalMutationIndex.#generatedArgCounts))
66
+ kinds.set(`${verb}${capModel}`, { kind: "mutation", argCount });
67
+ kinds.set(`add${capModel}Files`, { kind: "mutation", argCount: 2 });
68
+ }
69
+
70
+ static #collectEndpoints(endpoint: ts.CallExpression, kinds: Map<string, EndpointShape>) {
71
+ const builder = endpoint.arguments[1];
72
+ if (!builder || (!ts.isArrowFunction(builder) && !ts.isFunctionExpression(builder))) return;
73
+ const map = SignalMutationIndex.#returnedObjectOf(builder);
74
+ if (!map) return;
75
+ const verbOf = SignalMutationIndex.#verbReaderOf(builder.parameters[0]);
76
+ for (const property of map.properties) {
77
+ if (!ts.isPropertyAssignment(property)) continue;
78
+ if (!ts.isIdentifier(property.name) && !ts.isStringLiteral(property.name)) continue;
79
+ const { root, argCount } = SignalMutationIndex.#chainOf(property.initializer);
80
+ const verb = verbOf(root);
81
+ //? A socket message writes with the visitor's credential as a mutation does, and the client refuses it alike.
82
+ const kind = verb === "message" ? "mutation" : verb;
83
+ if (kind === "query" || kind === "mutation") kinds.set(property.name.text, { kind, argCount });
84
+ }
85
+ }
86
+
87
+ static #returnedObjectOf(builder: ts.ArrowFunction | ts.FunctionExpression) {
88
+ let body: ts.Node | undefined = builder.body;
89
+ if (ts.isBlock(body)) body = body.statements.find(ts.isReturnStatement)?.expression;
90
+ while (body && ts.isParenthesizedExpression(body)) body = body.expression;
91
+ return body && ts.isObjectLiteralExpression(body) ? body : null;
92
+ }
93
+
94
+ static #verbReaderOf(parameter: ts.ParameterDeclaration | undefined) {
95
+ const verbs = new Map<string, string>();
96
+ if (parameter && ts.isObjectBindingPattern(parameter.name))
97
+ for (const element of parameter.name.elements) {
98
+ const verb = element.propertyName ?? element.name;
99
+ if (ts.isIdentifier(verb) && ts.isIdentifier(element.name)) verbs.set(element.name.text, verb.text);
100
+ }
101
+ const holder = parameter && ts.isIdentifier(parameter.name) ? parameter.name.text : null;
102
+ return (callee: ts.Expression | undefined) => {
103
+ if (callee && ts.isIdentifier(callee)) return verbs.get(callee.text);
104
+ if (callee && ts.isPropertyAccessExpression(callee) && SignalMutationIndex.#isNamed(callee.expression, holder))
105
+ return callee.name.text;
106
+ return undefined;
107
+ };
108
+ }
109
+
110
+ static #isNamed(expression: ts.Expression, name: string | null) {
111
+ return ts.isIdentifier(expression) && expression.text === name;
112
+ }
113
+
114
+ static #chainOf(initializer: ts.Expression) {
115
+ let root = initializer;
116
+ let argCount = 0;
117
+ while (
118
+ ts.isCallExpression(root) &&
119
+ ts.isPropertyAccessExpression(root.expression) &&
120
+ ts.isCallExpression(root.expression.expression)
121
+ ) {
122
+ if (SignalMutationIndex.#argMethods.has(root.expression.name.text)) argCount += 1;
123
+ root = root.expression.expression;
124
+ }
125
+ return { root: ts.isCallExpression(root) ? root.expression : undefined, argCount };
126
+ }
127
+ }
@@ -176,6 +176,37 @@ describe("akan.ssr.client-mount-load", () => {
176
176
  test("a store action that is not a load is not a finding", () => {
177
177
  expect(scanOne(UI, mount("void st.do.openModal();"))).not.toContain("akan.ssr.client-mount-load");
178
178
  });
179
+
180
+ test("never sends a mutation to the route", () => {
181
+ const signal = fileOf(
182
+ "apps/demo/lib/task/task.signal.ts",
183
+ `export class TaskEndpoint extends endpoint(srv.task, ({ mutation }) => ({ markTaskSeen: mutation(Boolean, {}).exec(() => true) })) {}`,
184
+ );
185
+ const [load, write] = new SsrScanner().scan([
186
+ signal,
187
+ fileOf(UI, mount("void fetch.viewTask(id); void fetch.markTaskSeen(id);")),
188
+ ]).warnings;
189
+ expect(load?.message).toContain("The route can fetch this");
190
+ expect(load?.fix).toBeUndefined();
191
+ expect(write?.rule).toBe("akan.ssr.client-mount-load");
192
+ expect(write?.message).toContain("sends the mutation fetch.markTaskSeen()");
193
+ expect(write?.fix).toContain("explicit user action");
194
+ expect(write?.fix).not.toContain("init/view");
195
+ });
196
+ });
197
+
198
+ describe("akan.ssr.render-mutation", () => {
199
+ test("is reported for a route file beside the component rules", () => {
200
+ const signal = fileOf(
201
+ "apps/demo/lib/task/task.signal.ts",
202
+ `export class TaskEndpoint extends endpoint(srv.task, ({ mutation }) => ({ approveTask: mutation(Boolean, {}).exec(() => true) })) {}`,
203
+ );
204
+ const route = fileOf(
205
+ "apps/demo/page/task/_index.tsx",
206
+ `import { fetch } from "@apps/demo/client";\nexport default page().render(async () => { await fetch.approveTask("x"); return null; });\n`,
207
+ );
208
+ expect(rulesOf(signal, route)).toEqual(["akan.ssr.render-mutation"]);
209
+ });
179
210
  });
180
211
 
181
212
  describe("akan.ssr.template-client-state", () => {
package/ssrScanner.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import ts from "typescript";
2
2
  import type { QualityWarning, SourceFileInfo } from "./qualityScanner";
3
+ import { RenderMutationScanner } from "./renderMutationScanner";
4
+ import { SignalMutationIndex } from "./signalMutationIndex";
3
5
 
4
6
  /** Server/client render split for one app or lib, measured in JSX elements rather than files. */
5
7
  export interface SsrBalanceEntry {
@@ -50,19 +52,23 @@ export class SsrScanner {
50
52
  );
51
53
  // Runtime singletons that only exist in the client bundle; importing either is what forces the directive.
52
54
  static #clientRuntimeImports = new Set(["st", "fetch"]);
55
+ static #mountWriteFix =
56
+ "Send the write from an explicit user action, such as a button that calls a store action. Do not move it into the route: a write there runs for anyone who opens a link to the page.";
53
57
 
54
58
  scan(sourceFiles: SourceFileInfo[]): SsrScanResult {
59
+ const mutations = new SignalMutationIndex(sourceFiles);
55
60
  const componentFiles = sourceFiles.filter((sourceFile) => this.#isBalancedFile(sourceFile.file));
56
61
  return {
57
62
  warnings: [
58
- ...componentFiles.flatMap((sourceFile) => this.#scanFile(sourceFile)),
63
+ ...componentFiles.flatMap((sourceFile) => this.#scanFile(sourceFile, mutations)),
59
64
  ...this.#scanModules(componentFiles),
65
+ ...new RenderMutationScanner(mutations).scan(sourceFiles),
60
66
  ],
61
67
  balance: this.#measureBalance(componentFiles),
62
68
  };
63
69
  }
64
70
 
65
- #scanFile(sourceFile: SourceFileInfo): QualityWarning[] {
71
+ #scanFile(sourceFile: SourceFileInfo, mutations: SignalMutationIndex): QualityWarning[] {
66
72
  if (!this.#hasUseClient(sourceFile.sourceFile)) return [];
67
73
  const vendorNames = this.#getVendorNames(sourceFile.sourceFile);
68
74
  const hasVendorImport = this.#hasVendorImport(sourceFile.sourceFile);
@@ -118,7 +124,7 @@ export class SsrScanner {
118
124
  }
119
125
  }
120
126
 
121
- warnings.push(...this.#getMountLoadWarnings(sourceFile));
127
+ warnings.push(...this.#getMountLoadWarnings(sourceFile, mutations));
122
128
  warnings.push(...this.#getTemplateStateWarnings(sourceFile));
123
129
  return warnings;
124
130
  }
@@ -147,20 +153,33 @@ export class SsrScanner {
147
153
  }
148
154
 
149
155
  // Only an empty-dependency effect is a finding: a reactive effect answers client state and has no server equivalent.
150
- #getMountLoadWarnings(sourceFile: SourceFileInfo): QualityWarning[] {
156
+ #getMountLoadWarnings(sourceFile: SourceFileInfo, mutations: SignalMutationIndex): QualityWarning[] {
151
157
  const warnings: QualityWarning[] = [];
152
158
  SsrScanner.#walk(sourceFile.sourceFile, (node) => {
153
159
  if (!this.#isMountEffect(sourceFile.sourceFile, node)) return;
154
- for (const load of this.#getLoadCalls(sourceFile.sourceFile, node))
160
+ for (const load of this.#getLoadCalls(sourceFile.sourceFile, node)) {
161
+ const line = this.#getLine(sourceFile.sourceFile, load.node);
155
162
  warnings.push(
156
- fileWarning(
157
- "akan.ssr.client-mount-load",
158
- "ssr",
159
- sourceFile.file,
160
- `Mount-only effect loads server data with ${load.callee}(). The route can fetch this before the first byte instead.`,
161
- this.#getLine(sourceFile.sourceFile, load.node),
162
- ),
163
+ load.callee.startsWith("fetch.") && mutations.isMutation(sourceFile.file, load.callee.slice("fetch.".length))
164
+ ? {
165
+ ...fileWarning(
166
+ "akan.ssr.client-mount-load",
167
+ "ssr",
168
+ sourceFile.file,
169
+ `Mount-only effect sends the mutation ${load.callee}() each time the component mounts. A write is not data the route can load.`,
170
+ line,
171
+ ),
172
+ fix: SsrScanner.#mountWriteFix,
173
+ }
174
+ : fileWarning(
175
+ "akan.ssr.client-mount-load",
176
+ "ssr",
177
+ sourceFile.file,
178
+ `Mount-only effect loads server data with ${load.callee}(). The route can fetch this before the first byte instead.`,
179
+ line,
180
+ ),
163
181
  );
182
+ }
164
183
  });
165
184
  return warnings;
166
185
  }
package/uploadRelease.ts DELETED
@@ -1,80 +0,0 @@
1
- import { Logger, RestClient } from "akanjs/common";
2
-
3
- import { Spinner } from "./spinner";
4
-
5
- const spinning = (message: string) => new Spinner(message, { prefix: message, enableSpin: true }).start();
6
-
7
- export const uploadRelease = async (
8
- appName: string,
9
- {
10
- workspaceRoot,
11
- environment,
12
- buildNum,
13
- platformVersion,
14
- os,
15
- local,
16
- }: {
17
- workspaceRoot: string;
18
- environment: string;
19
- buildNum: number;
20
- platformVersion?: string;
21
- os?: "android" | "ios";
22
- local?: boolean;
23
- },
24
- ) => {
25
- const logger = new Logger("uploadRelease");
26
- const basePath = local ? "http://localhost:8282/backend" : "https://cloud.akanjs.com/backend";
27
- const httpClient = new RestClient(basePath);
28
- const readingFilesSpinner = spinning("Reading files...");
29
- try {
30
- const uploads = [
31
- [`builds/${appName}-release.tar.gz`, `${appName}-release.tar.gz`],
32
- [`sources/${appName}-source.tar.gz`, `${appName}-source.tar.gz`],
33
- [`builds/${appName}-appBuild.zip`, `${appName}-appBuild.zip`],
34
- ].map(([relativePath, name]) => ({ file: Bun.file(`${workspaceRoot}/releases/${relativePath}`), name }));
35
- const metas = uploads.map(({ file }) => ({ lastModifiedAt: new Date(file.lastModified), size: file.size }));
36
- readingFilesSpinner.succeed("Reading files... done");
37
-
38
- const preparingFormSpinner = spinning("Preparing form data...");
39
- const formData = new FormData();
40
- for (const { file, name } of uploads) formData.append("files", file, name);
41
- formData.append("metas", JSON.stringify(metas));
42
- formData.append("type", "release");
43
- preparingFormSpinner.succeed("Preparing form data... done");
44
-
45
- try {
46
- const uploadingFilesSpinner = spinning("Uploading files to server...");
47
- const [buildFile, sourceFile, appBuildFile] = await httpClient.post<
48
- [{ id: string }, { id: string }, { id: string }]
49
- >("/file/addFiles", formData);
50
- uploadingFilesSpinner.succeed("Uploading files to server... done");
51
-
52
- const fetchingAppSpinner = spinning(`Fetching dev app information for ${appName}...`);
53
- const major = platformVersion ? parseInt(platformVersion.split(".")[0]) : 1;
54
- const minor = platformVersion ? parseInt(platformVersion.split(".")[1]) : 0;
55
- const patch = platformVersion ? parseInt(platformVersion.split(".")[2]) : 0;
56
-
57
- const devApp = await httpClient.get<{ id: string }>(`/devApp/devAppInName/${appName}`);
58
- fetchingAppSpinner.succeed(`Fetching dev app information for ${appName}... done`);
59
-
60
- const pushingReleaseSpinner = spinning(`Pushing release to ${environment} environment...`);
61
- const release = await httpClient.post<{ id: string }>(
62
- `/release/pushRelease/${devApp.id}/${environment}/${major}/${minor}/${patch}/${sourceFile.id}/${buildFile.id}/${appBuildFile.id}${os ? `/${os}` : ""}`,
63
- );
64
- pushingReleaseSpinner.succeed(`Pushing release to ${environment} environment... done`);
65
- new Spinner(`Successfully pushed release to ${appName}-${environment} server. `, {
66
- prefix: `Successfully pushed release to ${appName}-${environment} server. `,
67
- enableSpin: false,
68
- }).succeed(`Successfully pushed release to ${appName}-${environment} server. `);
69
- return release;
70
- } catch (e) {
71
- const errorMessage = e instanceof Error ? e.message : "Unknown error";
72
- logger.error(`Upload release failed: ${errorMessage}`);
73
- return null;
74
- }
75
- } catch (e) {
76
- const errorMessage = e instanceof Error ? e.message : "Unknown error";
77
- readingFilesSpinner.fail(`Reading files failed: ${errorMessage}`);
78
- return null;
79
- }
80
- };