@akanjs/devkit 3.0.0-alpha.0 → 3.0.0-alpha.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.
@@ -0,0 +1,35 @@
1
+ engine biome(1.0)
2
+ language js(typescript, jsx)
3
+
4
+ // Every prototype method of a `store(...)` class is registered as an action
5
+ // (StoreRegistry.register) and reaches callers only as `st.do.<action>`, which re-wraps it
6
+ // (StoreInstance.#extendAccessors) and is typed void by `VoidActions` in
7
+ // pkgs/akanjs/store/types.ts. A returned value is therefore unreachable from every call
8
+ // site — it reads like a contract while being dead code. Write the result into state with
9
+ // `this.set({ ... })` instead.
10
+ //
11
+ // Deliberately not flagged, because none of these are actions: a bare `return;` guard, a
12
+ // `return` belonging to a nested callback, a getter, a `static` helper, and a class-property
13
+ // arrow (an instance field, so `register` never sees it).
14
+ `return $value;` as $return where {
15
+ not $value <: r"",
16
+ $return <: within JsClassDeclaration() as $storeClass where {
17
+ $storeClass <: contains JsExtendsClause() as $extends where {
18
+ $extends <: r"extends\s+store\([\s\S]*"
19
+ }
20
+ },
21
+ $return <: within JsMethodClassMember() as $action where {
22
+ not $action <: r"static[\s\S]*"
23
+ },
24
+ not $return <: within JsArrowFunctionExpression(),
25
+ not $return <: within JsFunctionExpression(),
26
+ not $return <: within JsFunctionDeclaration(),
27
+ not $return <: within JsMethodObjectMember(),
28
+ not $return <: within JsGetterObjectMember(),
29
+ not $return <: within JsSetterObjectMember(),
30
+ register_diagnostic(
31
+ span = $return,
32
+ message = "Store actions dispatch as void — st.do.<action>() never exposes this value. Write the result into state with this.set({ ... }) and drop the returned value; a bare 'return;' guard clause is fine.",
33
+ severity = "error"
34
+ )
35
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "3.0.0-alpha.0",
3
+ "version": "3.0.0-alpha.10",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -23,6 +23,7 @@
23
23
  "default": "./index.ts"
24
24
  },
25
25
  "./package.json": "./package.json",
26
+ "./biome.base.json": "./biome.base.json",
26
27
  "./akanApp": "./akanApp/index.ts",
27
28
  "./akanConfig": "./akanConfig/index.ts",
28
29
  "./artifact": "./artifact/index.ts",
@@ -44,7 +45,7 @@
44
45
  "@langchain/openai": "^1.4.6",
45
46
  "@tailwindcss/node": "^4.3.0",
46
47
  "@trapezedev/project": "^7.1.4",
47
- "akanjs": "3.0.0-alpha.0",
48
+ "akanjs": "3.0.0-alpha.10",
48
49
  "chalk": "^5.6.2",
49
50
  "commander": "^14.0.3",
50
51
  "dayjs": "^1.11.20",
@@ -3,7 +3,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { AbstractDoc } from "./abstractDoc";
6
- import { AkanQualityScanner } from "./qualityScanner";
6
+ import { AkanQualityScanner, type QualityScanResult } from "./qualityScanner";
7
7
 
8
8
  const tempRoots: string[] = [];
9
9
 
@@ -44,3 +44,156 @@ describe("AkanQualityScanner abstract rule", () => {
44
44
  expect(warnings[0]?.fix).toContain("akan compact");
45
45
  });
46
46
  });
47
+
48
+ const staticMarkup = (elementNum: number) =>
49
+ Array.from({ length: elementNum }, (_, idx) => ` <p className="text-sm">row ${idx}</p>`).join("\n");
50
+
51
+ const rulesOf = (result: QualityScanResult, rule: string) => result.warnings.filter((warning) => warning.rule === rule);
52
+
53
+ describe("AkanQualityScanner ssr rules", () => {
54
+ test("flags a client file that uses no client-only capability", async () => {
55
+ const root = await makeWorkspace({
56
+ "apps/demo/ui/Plain.tsx": `"use client";\nexport const Plain = () => <div>plain</div>;\n`,
57
+ "apps/demo/ui/Interactive.tsx": `"use client";\nexport const Interactive = () => <button onClick={() => null}>go</button>;\n`,
58
+ "apps/demo/ui/Hooked.tsx": `"use client";\nimport { useState } from "react";\nexport const Hooked = () => {\n const [open] = useState(false);\n return <div>{open ? "y" : "n"}</div>;\n};\n`,
59
+ });
60
+
61
+ const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.ssr.unnecessary-use-client");
62
+
63
+ expect(warnings).toHaveLength(1);
64
+ expect(warnings[0]?.file).toBe("apps/demo/ui/Plain.tsx");
65
+ });
66
+
67
+ test("keeps the directive on a third-party wrapper and on an index_ boundary", async () => {
68
+ const root = await makeWorkspace({
69
+ "apps/demo/ui/Chart.tsx": `"use client";\nimport { Bar } from "react-chartjs-2";\nexport const Chart = () => <Bar data={{}} />;\n`,
70
+ "apps/demo/ui/Lazy/index_.tsx": `"use client";\nexport { Inner } from "./Inner";\n`,
71
+ });
72
+
73
+ expect(rulesOf(await new AkanQualityScanner().scan(root), "akan.ssr.unnecessary-use-client")).toHaveLength(0);
74
+ });
75
+
76
+ test("flags a static component and a mostly-static component inside a client file", async () => {
77
+ const root = await makeWorkspace({
78
+ "apps/demo/ui/Panels.tsx": [
79
+ `"use client";`,
80
+ `import { useState } from "react";`,
81
+ `export const StaticPanel = () => (`,
82
+ ` <section>`,
83
+ staticMarkup(5),
84
+ ` </section>`,
85
+ `);`,
86
+ `export const MixedPanel = () => {`,
87
+ ` const [open, setOpen] = useState(false);`,
88
+ ` return (`,
89
+ ` <section>`,
90
+ staticMarkup(12),
91
+ ` <span>{open ? "open" : "shut"}</span>`,
92
+ ` </section>`,
93
+ ` );`,
94
+ `};`,
95
+ "",
96
+ ].join("\n"),
97
+ });
98
+
99
+ const result = await new AkanQualityScanner().scan(root);
100
+ const staticWarnings = rulesOf(result, "akan.ssr.client-static-component");
101
+ const mixedWarnings = rulesOf(result, "akan.ssr.client-static-markup");
102
+
103
+ expect(staticWarnings).toHaveLength(1);
104
+ expect(staticWarnings[0]?.message).toContain("StaticPanel");
105
+ expect(staticWarnings[0]?.fix).toContain("server file");
106
+ expect(mixedWarnings).toHaveLength(1);
107
+ expect(mixedWarnings[0]?.message).toContain("MixedPanel");
108
+ });
109
+
110
+ test("flags a mount-only load but not a reactive one", async () => {
111
+ const root = await makeWorkspace({
112
+ "apps/demo/lib/post/Post.Zone.tsx": [
113
+ `"use client";`,
114
+ `import { useEffect } from "react";`,
115
+ `export const List = ({ tag }: { tag: string }) => {`,
116
+ ` useEffect(() => {`,
117
+ ` void st.do.initPostInPublic();`,
118
+ ` }, []);`,
119
+ ` useEffect(() => {`,
120
+ ` void st.do.getPostListInTag(tag);`,
121
+ ` }, [tag]);`,
122
+ ` return <div>{tag}</div>;`,
123
+ `};`,
124
+ "",
125
+ ].join("\n"),
126
+ });
127
+
128
+ const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.ssr.client-mount-load");
129
+
130
+ expect(warnings).toHaveLength(1);
131
+ expect(warnings[0]?.message).toContain("st.do.initPostInPublic");
132
+ expect(warnings[0]?.fix).toContain("init/view");
133
+ });
134
+
135
+ test("flags useState in a Template", async () => {
136
+ const root = await makeWorkspace({
137
+ "apps/demo/lib/post/Post.Template.tsx": `"use client";\nimport { useState } from "react";\nexport const General = () => {\n const [draft, setDraft] = useState("");\n return <input value={draft} onChange={(e) => setDraft(e.target.value)} />;\n};\n`,
138
+ });
139
+
140
+ const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.ssr.template-client-state");
141
+
142
+ expect(warnings).toHaveLength(1);
143
+ expect(warnings[0]?.fix).toContain("st.do.setFieldOnX");
144
+ });
145
+
146
+ test("flags a module that renders only from client files", async () => {
147
+ const root = await makeWorkspace({
148
+ "apps/demo/lib/post/Post.Zone.tsx": [
149
+ `"use client";`,
150
+ `import { useState } from "react";`,
151
+ `export const Card = () => {`,
152
+ ` const [open] = useState(false);`,
153
+ ` return (`,
154
+ ` <section>`,
155
+ staticMarkup(14),
156
+ ` <span>{open ? "open" : "shut"}</span>`,
157
+ ` </section>`,
158
+ ` );`,
159
+ `};`,
160
+ "",
161
+ ].join("\n"),
162
+ "libs/shared/lib/user/User.Zone.tsx": `"use client";\nimport { st } from "@libs/shared/client";\nexport const Self = () => <User.View.General user={st.use.self()} />;\n`,
163
+ "libs/shared/lib/user/User.View.tsx": `export const General = ({ name }: { name: string }) => <div>{name}</div>;\n`,
164
+ });
165
+
166
+ const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.ssr.module-missing-server-view");
167
+
168
+ expect(warnings).toHaveLength(1);
169
+ expect(warnings[0]?.message).toContain("apps/demo/lib/post");
170
+ });
171
+
172
+ test("measures the server render share per scope and for the workspace", async () => {
173
+ const root = await makeWorkspace({
174
+ "apps/demo/ui/Server.tsx": `export const Server = () => (\n <section>\n <p>a</p>\n <p>b</p>\n </section>\n);\n`,
175
+ "libs/shared/ui/Client.tsx": `"use client";\nexport const Client = () => <button onClick={() => null}>go</button>;\n`,
176
+ });
177
+
178
+ const { ssrBalance } = await new AkanQualityScanner().scan(root);
179
+
180
+ expect(ssrBalance.map((entry) => entry.scope)).toEqual(["apps/demo", "libs/shared", "workspace"]);
181
+ expect(ssrBalance[0]).toMatchObject({ serverMass: 3, clientMass: 0, serverShare: 1 });
182
+ expect(ssrBalance[1]).toMatchObject({ serverMass: 0, clientMass: 1 });
183
+ expect(ssrBalance[2]).toMatchObject({ scope: "workspace", serverMass: 3, clientMass: 1 });
184
+ });
185
+ });
186
+
187
+ describe("AkanQualityScanner layout rules", () => {
188
+ test("flags an unknown app root file but not a facet entrypoint", async () => {
189
+ const root = await makeWorkspace({
190
+ "apps/demo/client.ts": "export const client = 1;\n",
191
+ "apps/demo/helper.ts": "export const helper = 1;\n",
192
+ });
193
+
194
+ const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.layout.app-root-file");
195
+
196
+ expect(warnings).toHaveLength(1);
197
+ expect(warnings[0]?.file).toBe("apps/demo/helper.ts");
198
+ });
199
+ });
package/qualityScanner.ts CHANGED
@@ -1,12 +1,15 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { readdir, readFile, stat } from "node:fs/promises";
3
3
  import path from "node:path";
4
+ import { RESERVED_ROUTE_CONFIG_EXPORTS } from "akanjs/common";
4
5
  import ignore from "ignore";
5
6
  import ts from "typescript";
6
7
  import { AbstractDoc } from "./abstractDoc";
8
+ import { formatSsrBalance, type SsrBalanceEntry, SsrScanner } from "./ssrScanner";
9
+ import { appRootAllowedFiles, libFacetRootAllowedFiles } from "./workspaceLayout";
7
10
 
8
11
  type QualitySeverity = "warning";
9
- type QualityScope = "global" | "file" | "convention" | "layout";
12
+ type QualityScope = "global" | "file" | "convention" | "layout" | "ssr";
10
13
 
11
14
  export interface QualityWarning {
12
15
  rule: string;
@@ -23,10 +26,11 @@ export interface QualityScanResult {
23
26
  workspaceRoot: string;
24
27
  scannedFiles: number;
25
28
  warnings: QualityWarning[];
29
+ ssrBalance: SsrBalanceEntry[];
26
30
  suggestedRules: string[];
27
31
  }
28
32
 
29
- interface SourceFileInfo {
33
+ export interface SourceFileInfo {
30
34
  file: string;
31
35
  absolutePath: string;
32
36
  content: string;
@@ -88,29 +92,6 @@ const SUGGESTED_RULES = [
88
92
  "Avoid large mixed-purpose class files; class export files should import helpers from neighboring utility files instead of declaring them inline.",
89
93
  ];
90
94
 
91
- const APP_ROOT_FILES = new Set([
92
- "akan.app.json",
93
- "akan.config.ts",
94
- "capacitor.config.ts",
95
- "client.ts",
96
- "main.ts",
97
- "package.json",
98
- "server.ts",
99
- "tsconfig.json",
100
- ]);
101
-
102
- const LIB_ROOT_FILES = new Set([
103
- "cnst.ts",
104
- "db.ts",
105
- "dict.ts",
106
- "option.ts",
107
- "sig.ts",
108
- "srv.ts",
109
- "st.ts",
110
- "useClient.ts",
111
- "useServer.ts",
112
- ]);
113
-
114
95
  const CONVENTION_SUFFIXES = [
115
96
  ".constant.ts",
116
97
  ".dictionary.ts",
@@ -120,24 +101,6 @@ const CONVENTION_SUFFIXES = [
120
101
  ".store.ts",
121
102
  ] as const;
122
103
 
123
- // Non-PascalCase exports the framework recognizes on page/layout route modules (see PageModule/LayoutModule
124
- // in pkgs/akanjs/client/csrTypes.ts). PascalCase route exports (Loading, NotFound, Error) pass the component
125
- // check, and the `default` export is handled separately.
126
- const PAGE_RESERVED_EXPORTS = new Set([
127
- "pageConfig",
128
- "head",
129
- "metadata",
130
- "generateHead",
131
- "generateMetadata",
132
- "fonts",
133
- "manifest",
134
- "theme",
135
- "reconnect",
136
- "wsConnect",
137
- "layoutStyle",
138
- "gaTrackingId",
139
- ]);
140
-
141
104
  // How to remediate each rule, keyed by rule id. Surfaced as a `fix:` line per warning (text + JSON output)
142
105
  // so the scan result tells the reader what to do, not just what is wrong.
143
106
  const RULE_FIXES: Record<string, string> = {
@@ -169,6 +132,18 @@ const RULE_FIXES: Record<string, string> = {
169
132
  "Move the file into a domain module folder under lib/; keep lib root limited to generated support facets.",
170
133
  "akan.layout.module-ui-file":
171
134
  "Rename the file to an allowed module UI name, or move it to ui/ if it is not a module component.",
135
+ "akan.ssr.unnecessary-use-client":
136
+ 'Delete the "use client" directive so the file renders on the server. If it exists only to wrap one client child, drop the wrapper and use the child directly.',
137
+ "akan.ssr.client-static-component":
138
+ "Move the component to a server file — a <Model>.Unit.tsx / <Model>.View.tsx for a module, or a ui/ file with no directive — and reference it from the client file.",
139
+ "akan.ssr.client-static-markup":
140
+ "Keep the interactive element in the client component and hoist the static subtree into a server component, then accept it as `children` or render it through a Unit/View reference.",
141
+ "akan.ssr.client-mount-load":
142
+ "Load the data in the route with `fetch.initX(...)` / `fetch.viewX(...)` and pass the init/view object down as a prop; the client store hydrates from it and the effect goes away.",
143
+ "akan.ssr.module-missing-server-view":
144
+ "Add a <Model>.Unit.tsx for list/card rendering and a <Model>.View.tsx for the detail surface, then have the Zone delegate to them.",
145
+ "akan.ssr.template-client-state":
146
+ "Bind the field to the store instead: `value={xForm.field}` with `onChange={st.do.setFieldOnX}`.",
172
147
  };
173
148
 
174
149
  function getRuleFix(rule: string): string | undefined {
@@ -190,6 +165,7 @@ export class AkanQualityScanner {
190
165
  .filter((file) => AbstractDoc.isAbstractPath(file))
191
166
  .map((file) => this.#readTextFile(workspaceRoot, file)),
192
167
  );
168
+ const ssr = new SsrScanner().scan(sourceFiles);
193
169
  const warnings = [
194
170
  ...this.#scanGlobalQuality(sourceFiles),
195
171
  ...sourceFiles.flatMap((sourceFile) => this.#scanSingleFileQuality(sourceFile)),
@@ -197,6 +173,7 @@ export class AkanQualityScanner {
197
173
  ...sourceFiles.flatMap((sourceFile) => this.#scanConventionQuality(sourceFile)),
198
174
  ...sourceFiles.flatMap((sourceFile) => this.#scanLayoutQuality(sourceFile)),
199
175
  ...abstractFiles.flatMap((abstractFile) => this.#scanAbstractQuality(abstractFile)),
176
+ ...ssr.warnings,
200
177
  ];
201
178
 
202
179
  return {
@@ -205,6 +182,7 @@ export class AkanQualityScanner {
205
182
  warnings: warnings
206
183
  .map((warning) => ({ ...warning, fix: warning.fix ?? getRuleFix(warning.rule) }))
207
184
  .sort(compareWarnings),
185
+ ssrBalance: ssr.balance,
208
186
  suggestedRules: SUGGESTED_RULES,
209
187
  };
210
188
  }
@@ -432,7 +410,7 @@ export class AkanQualityScanner {
432
410
  #scanLayoutQuality(sourceFile: SourceFileInfo): QualityWarning[] {
433
411
  const segments = sourceFile.file.split("/");
434
412
  const warnings: QualityWarning[] = [];
435
- if (segments[0] === "apps" && segments.length === 3 && !APP_ROOT_FILES.has(segments[2])) {
413
+ if (segments[0] === "apps" && segments.length === 3 && !appRootAllowedFiles.has(segments[2])) {
436
414
  warnings.push({
437
415
  rule: "akan.layout.app-root-file",
438
416
  scope: "layout",
@@ -443,7 +421,7 @@ export class AkanQualityScanner {
443
421
  }
444
422
 
445
423
  const libRootFile = getLibRootFile(sourceFile.file);
446
- if (libRootFile && !LIB_ROOT_FILES.has(libRootFile)) {
424
+ if (libRootFile && !libFacetRootAllowedFiles.has(libRootFile)) {
447
425
  warnings.push({
448
426
  rule: "akan.layout.lib-root-file",
449
427
  scope: "layout",
@@ -470,6 +448,10 @@ export function formatQualityScanResult(result: QualityScanResult) {
470
448
  "",
471
449
  ...formatQualityWarnings(result.warnings),
472
450
  "",
451
+ "SSR balance (component files, JSX elements rendered per side):",
452
+ "",
453
+ ...formatSsrBalance(result.ssrBalance),
454
+ "",
473
455
  "Suggested quality rules:",
474
456
  "",
475
457
  ...result.suggestedRules.map((rule) => ` - ${rule}`),
@@ -477,6 +459,24 @@ export function formatQualityScanResult(result: QualityScanResult) {
477
459
  return sections.join("\n");
478
460
  }
479
461
 
462
+ export function formatSsrScanResult(result: QualityScanResult) {
463
+ const sections = [
464
+ "Akan SSR Balance Scan",
465
+ `workspace: ${result.workspaceRoot}`,
466
+ `scanned files: ${result.scannedFiles}`,
467
+ `ssr warnings: ${result.warnings.length}`,
468
+ "",
469
+ "Server render share (component files, JSX elements rendered per side):",
470
+ "",
471
+ ...formatSsrBalance(result.ssrBalance),
472
+ "",
473
+ "Warnings:",
474
+ "",
475
+ ...formatQualityWarnings(result.warnings),
476
+ ];
477
+ return sections.join("\n");
478
+ }
479
+
480
480
  export function formatQualityWarnings(warnings: QualityWarning[]) {
481
481
  if (warnings.length === 0) return ["No warnings found."];
482
482
  return warnings.flatMap((warning) => {
@@ -652,7 +652,7 @@ function isRestrictedInternalKind(kind: ComponentFileDeclaration["kind"]) {
652
652
 
653
653
  function isAllowedComponentExport(declaration: ComponentFileDeclaration, isPage: boolean) {
654
654
  if (isComponentValueKind(declaration.kind) && isPascalCaseName(declaration.name)) return true;
655
- return isPage && PAGE_RESERVED_EXPORTS.has(declaration.name);
655
+ return isPage && RESERVED_ROUTE_CONFIG_EXPORTS.has(declaration.name);
656
656
  }
657
657
 
658
658
  function isPascalCaseName(name: string) {
@@ -0,0 +1,42 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import path from "node:path";
3
+
4
+ // Resolved once per root: every CLI command builds a WorkspaceExecutor, and this would otherwise fork git on each.
5
+ const resolved = new Map<string, string>();
6
+
7
+ const readRemoteName = (workspaceRoot: string): string | null => {
8
+ try {
9
+ const url = execFileSync("git", ["config", "--get", "remote.origin.url"], {
10
+ cwd: workspaceRoot,
11
+ encoding: "utf-8",
12
+ stdio: ["ignore", "pipe", "ignore"],
13
+ }).trim();
14
+ // Both remote spellings end in the repository: git@host:owner/name.git and https://host/owner/name.git.
15
+ return (
16
+ url
17
+ .replace(/\.git$/, "")
18
+ .split(/[/:]/)
19
+ .pop() || null
20
+ );
21
+ } catch {
22
+ // No git, no origin, or no git binary — a fresh `akan workspace` before its first commit lands here.
23
+ return null;
24
+ }
25
+ };
26
+
27
+ /**
28
+ * The repository's own name.
29
+ *
30
+ * Deriving it from the working directory made every generated file that names the repo — the AGENTS.md title, its
31
+ * `- Repo:` line — depend on what each person happened to call the folder they cloned into, so one commit rendered
32
+ * a different guide per developer and the diff never settled. The origin remote is the one identity every clone
33
+ * shares. `AKAN_PUBLIC_REPO_NAME` is deliberately not consulted: that is a deployment namespace (queue prefixes,
34
+ * cache keys, secret paths) which a monorepo hosting several products legitimately points somewhere else.
35
+ */
36
+ export const resolveRepoName = (workspaceRoot: string): string => {
37
+ const cached = resolved.get(workspaceRoot);
38
+ if (cached) return cached;
39
+ const repoName = readRemoteName(workspaceRoot) ?? path.basename(workspaceRoot);
40
+ resolved.set(workspaceRoot, repoName);
41
+ return repoName;
42
+ };
@@ -0,0 +1,41 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { RouteSourceValidator } from "./routeSourceValidator";
4
+
5
+ const validate = (source: string, kind: "page" | "layout", rootLayout = false) =>
6
+ RouteSourceValidator.validateRouteSourceExports(source, `page/_${kind}.tsx`, kind, { rootLayout });
7
+
8
+ describe("RouteSourceValidator", () => {
9
+ test("accepts every root-layout config export the route tree honors", () => {
10
+ const source = [
11
+ "export default function Layout() { return null; }",
12
+ "export const fonts = [];",
13
+ "export const manifest = {};",
14
+ 'export const theme = "dark";',
15
+ "export const reconnect = true;",
16
+ "export const wsConnect = true;",
17
+ "export const layoutStyle = {};",
18
+ 'export const gaTrackingId = "G-1";',
19
+ ].join("\n");
20
+
21
+ expect(() => validate(source, "layout", true)).not.toThrow();
22
+ });
23
+
24
+ test("rejects root-layout-only exports on a nested layout and on a page", () => {
25
+ const source = ["export default function Layout() { return null; }", "export const wsConnect = true;"].join("\n");
26
+
27
+ expect(() => validate(source, "layout")).toThrow('unsupported export "wsConnect"');
28
+ expect(() => validate(source, "page")).toThrow('unsupported export "wsConnect"');
29
+ });
30
+
31
+ test("reads devOnly off pageConfig without evaluating the module", () => {
32
+ const source = [
33
+ "export default function Page() { return null; }",
34
+ "export const pageConfig = { devOnly: true };",
35
+ ].join("\n");
36
+
37
+ expect(RouteSourceValidator.validateRouteSourceExports(source, "page/_index.tsx", "page")).toEqual({
38
+ devOnly: true,
39
+ });
40
+ });
41
+ });
@@ -1,3 +1,4 @@
1
+ import { getRouteExports } from "akanjs/common";
1
2
  import ts from "typescript";
2
3
 
3
4
  /** What the build needs out of a route module without evaluating it. */
@@ -16,44 +17,6 @@ export interface RouteSourceInfo {
16
17
  * validate a route source stay lean.
17
18
  */
18
19
  export class RouteSourceValidator {
19
- static readonly #pageExports = new Set([
20
- "default",
21
- "pageConfig",
22
- "head",
23
- "metadata",
24
- "generateHead",
25
- "generateMetadata",
26
- "Loading",
27
- ]);
28
- static readonly #rootLayoutExports = new Set([
29
- "default",
30
- "pageConfig",
31
- "head",
32
- "metadata",
33
- "generateHead",
34
- "generateMetadata",
35
- "fonts",
36
- "manifest",
37
- "theme",
38
- "reconnect",
39
- "layoutStyle",
40
- "gaTrackingId",
41
- "Loading",
42
- "NotFound",
43
- "Error",
44
- ]);
45
- static readonly #layoutExports = new Set([
46
- "default",
47
- "pageConfig",
48
- "head",
49
- "metadata",
50
- "generateHead",
51
- "generateMetadata",
52
- "Loading",
53
- "NotFound",
54
- "Error",
55
- ]);
56
-
57
20
  static validateRouteSourceExports(
58
21
  source: string,
59
22
  filePath: string,
@@ -61,12 +24,7 @@ export class RouteSourceValidator {
61
24
  options: { rootLayout?: boolean } = {},
62
25
  ): RouteSourceInfo {
63
26
  const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
64
- const allowed =
65
- kind === "page"
66
- ? RouteSourceValidator.#pageExports
67
- : options.rootLayout
68
- ? RouteSourceValidator.#rootLayoutExports
69
- : RouteSourceValidator.#layoutExports;
27
+ const allowed = getRouteExports(kind, { rootLayout: options.rootLayout });
70
28
  const exported = new Set<string>();
71
29
  const assertExport = (name: string) => {
72
30
  if (!allowed.has(name)) {
package/scanInfo.ts CHANGED
@@ -11,6 +11,7 @@ import type {
11
11
  } from "./akanConfig";
12
12
 
13
13
  import { AppExecutor, LibExecutor, PkgExecutor, WorkspaceExecutor } from "./executors";
14
+ import { appRootAllowedDirs, appRootAllowedFiles, libFacetRootAllowedFiles } from "./workspaceLayout";
14
15
 
15
16
  const scalarFileTypes = ["constant", "dictionary", "document", "template", "unit", "util", "view", "zone"] as const;
16
17
  type ScalarFileType = (typeof scalarFileTypes)[number];
@@ -43,49 +44,7 @@ type DatabaseFileType = (typeof databaseFileTypes)[number];
43
44
 
44
45
  type ModuleKind = "database" | "service" | "scalar";
45
46
 
46
- const appRootAllowedFiles = new Set([
47
- // 스코프 에이전트 가이드 — scan(write) 이 유지하는 색인 + 마커 밖 hand-written 내용 (agentsIndex.ts)
48
- "AGENTS.md",
49
- "CLAUDE.md",
50
- "akan.app.json",
51
- "akan.config.ts",
52
- "capacitor.config.ts",
53
- "client.ts",
54
- "main.ts",
55
- "package.json",
56
- "server.ts",
57
- "tsconfig.json",
58
- "tsconfig.tsbuildinfo",
59
- ]);
60
47
  const generatedRootCapacitorConfigFiles = ["capacitor.config.js", "capacitor.config.json"] as const;
61
- const appRootAllowedDirs = new Set([
62
- ".akan",
63
- "android",
64
- "env",
65
- "ios",
66
- "lib",
67
- "mobile",
68
- "page",
69
- "private",
70
- "public",
71
- "script",
72
- "ui",
73
- "srvkit",
74
- "webkit",
75
- "common",
76
- "secrets",
77
- ]);
78
- const libRootAllowedFiles = new Set([
79
- "cnst.ts",
80
- "db.ts",
81
- "dict.ts",
82
- "option.ts",
83
- "sig.ts",
84
- "srv.ts",
85
- "st.ts",
86
- "useClient.ts",
87
- "useServer.ts",
88
- ]);
89
48
  const internalLibDirs = new Set(["__lib", "__scalar"]);
90
49
  const moduleNonUiFileTypes = {
91
50
  database: new Set(["constant", "dictionary", "document", "service", "signal", "store"]),
@@ -107,7 +66,7 @@ const createDependencyScanner = async (exec: AppExecutor | LibExecutor | PkgExec
107
66
 
108
67
  const isAllowedTestFile = (filename: string) => testFilePattern.test(filename);
109
68
  const isAllowedLibRootFile = (filename: string) =>
110
- libRootAllowedFiles.has(filename) || rootSignalTestFilePattern.test(filename);
69
+ libFacetRootAllowedFiles.has(filename) || rootSignalTestFilePattern.test(filename);
111
70
  const getScanPath = (exec: AppExecutor | LibExecutor, relativePath: string) =>
112
71
  path.posix.join(`${exec.type}s`, exec.name, relativePath.split(path.sep).join("/"));
113
72
  async function clearGeneratedRootCapacitorConfigs(exec: AppExecutor | LibExecutor) {