@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.
- package/agentsIndex.ts +26 -0
- package/akanContext.ts +43 -31
- package/applicationBuildRunner.ts +1 -1
- package/biome.base.json +301 -0
- package/biomeBase.ts +9 -0
- package/capacitorApp.test.ts +8 -0
- package/capacitorApp.ts +112 -18
- package/executors.test.ts +3 -1
- package/executors.ts +2 -1
- package/frontendBuild/allRoutesBuilder.ts +27 -8
- package/frontendBuild/cssCandidateCache.test.ts +38 -0
- package/frontendBuild/cssCandidateCache.ts +15 -3
- package/frontendBuild/precompressArtifacts.ts +35 -7
- package/frontendBuild/routeClientBuilder.ts +14 -2
- package/integration/devResourceProbe.ts +7 -2
- package/integration/ssrMemoryProbe.ts +542 -0
- package/lint/no-bang-comment-in-client.grit +20 -0
- package/lint/no-import-client-in-server.grit +48 -0
- package/lint/no-import-server-in-client.grit +45 -0
- package/lint/no-return-in-store-action.grit +35 -0
- package/package.json +3 -2
- package/qualityScanner.test.ts +154 -1
- package/qualityScanner.ts +46 -46
- package/repoIdentity.ts +42 -0
- package/routeSourceValidator.test.ts +41 -0
- package/routeSourceValidator.ts +2 -44
- package/scanInfo.ts +2 -43
- package/spinner.test.ts +81 -0
- package/spinner.ts +22 -2
- package/ssrScanner.ts +409 -0
- package/workspaceLayout.test.ts +26 -0
- package/workspaceLayout.ts +60 -0
|
@@ -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.
|
|
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.
|
|
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",
|
package/qualityScanner.test.ts
CHANGED
|
@@ -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 && !
|
|
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 && !
|
|
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 &&
|
|
655
|
+
return isPage && RESERVED_ROUTE_CONFIG_EXPORTS.has(declaration.name);
|
|
656
656
|
}
|
|
657
657
|
|
|
658
658
|
function isPascalCaseName(name: string) {
|
package/repoIdentity.ts
ADDED
|
@@ -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
|
+
});
|
package/routeSourceValidator.ts
CHANGED
|
@@ -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
|
-
|
|
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) {
|