@akanjs/cli 3.0.0-alpha.4 → 3.0.0-alpha.40
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/.build-stamp +1 -1
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/{abstractCompactor-rk6twzvj.js → abstractCompactor-20e0cwbs.js} +3 -3
- package/{agent.command-jfz8yfwy.js → agent.command-e835fjem.js} +9 -8
- package/{application.command-mxyqtd6p.js → application.command-sjtbk6tr.js} +7 -6
- package/{applicationBuildRunner-cwk52sb7.js → applicationBuildRunner-7gnfz7ww.js} +7 -8
- package/{applicationReleasePackager-yrrhbkv3.js → applicationReleasePackager-93nzdfsy.js} +2 -2
- package/buildBatch.proc.js +6 -5
- package/{capacitorApp-j357ak1g.js → capacitorApp-qv7g3dp2.js} +29 -28
- package/{cloud.command-f87dwe52.js → cloud.command-y0e4eyqz.js} +11 -10
- package/{context.command-z5bf8bn7.js → context.command-vn87jb08.js} +17 -16
- package/{dependencyScanner-f6grrep3.js → dependencyScanner-rbs2407b.js} +1 -1
- package/getDirname-ka11hm7e.js +8 -0
- package/{guideline.command-1m1ctar3.js → guideline.command-s4hn2bsy.js} +7 -6
- package/guidelines/conventions/conventions.generate.json +25 -0
- package/guidelines/conventions/conventions.instruction.md +1173 -0
- package/guidelines/cssRule/cssRule.instruction.md +7 -0
- package/guidelines/framework/framework.instruction.md +1 -0
- package/guidelines/ssrRule/ssrRule.generate.json +31 -0
- package/guidelines/ssrRule/ssrRule.instruction.md +108 -0
- package/guidelines/workspaceOnboarding/workspaceOnboarding.generate.json +24 -0
- package/guidelines/workspaceOnboarding/workspaceOnboarding.instruction.md +972 -0
- package/incrementalBuilder.proc.js +7 -6
- package/{index-1s71fwrq.js → index-0h212yqe.js} +13 -13
- package/index-1577bej2.js +7 -0
- package/{index-e3qskyt2.js → index-33zce8gt.js} +8 -4
- package/{index-csqk9z5j.js → index-3dp7x6rc.js} +3 -3
- package/{index-cn24dcqn.js → index-73rnbwgg.js} +15 -7
- package/{index-tq1y56tp.js → index-8d2sv6pw.js} +39 -16
- package/{index-jtm86byd.js → index-8vkkm7v0.js} +125 -66
- package/{index-r4w8cry5.js → index-9jsnb4vw.js} +4 -4
- package/{index-ybybkec0.js → index-becx1t0k.js} +6 -6
- package/{index-4yp06pn1.js → index-g9e2fhw2.js} +49 -32
- package/{index-cap2k2ch.js → index-hg0qvhmx.js} +152 -241
- package/{index-xwgm94zr.js → index-j37qq1f2.js} +1 -1
- package/{index-n0z3f2xg.js → index-k6fvbcj1.js} +37 -6
- package/{index-6pz1j0zj.js → index-mxvakhsm.js} +6 -2
- package/{index-vhv3xnjz.js → index-mykmf6wd.js} +8 -8
- package/{index-y0k42k04.js → index-mz5jtaq7.js} +1 -1
- package/{index-jk9q6pse.js → index-n706txcv.js} +5 -3
- package/{index-hjzr8ja1.js → index-qc0tnhs2.js} +1 -1
- package/{index-rh16j3c7.js → index-qnv0aqmm.js} +12 -8
- package/{index-3pgtw35d.js → index-sh01r3pm.js} +2 -2
- package/{index-xj8bks3p.js → index-t71zvmt9.js} +9 -11
- package/{index-4zgs8g1w.js → index-tsmjxph2.js} +1 -1
- package/{index-fmgbwsr5.js → index-x33gnap3.js} +4 -4
- package/{index-b978vs2e.js → index-xyrb7pk5.js} +3 -3
- package/index.js +21 -20
- package/{library.command-pwfx8dx2.js → library.command-v5cyaw9t.js} +6 -5
- package/{localRegistry.command-r4zjx5py.js → localRegistry.command-53t758pt.js} +10 -9
- package/{module.command-0t3ewata.js → module.command-pq6w84xg.js} +10 -9
- package/{package.command-7hmkxvpa.js → package.command-qkcs60ts.js} +6 -5
- package/package.json +3 -4
- package/{page.command-st6r4s0q.js → page.command-qj5bfnh9.js} +6 -5
- package/{primitive.command-1qkxpzsn.js → primitive.command-bw0nes6x.js} +11 -10
- package/{quality.command-hy5vt1bh.js → quality.command-g1s2wt8b.js} +532 -87
- package/{recipeScanner-tghkzrrt.js → recipeScanner-zrzev25x.js} +3 -3
- package/{repair.command-nakkq2zp.js → repair.command-mzwtejpk.js} +8 -7
- package/{routeSourceValidator-cncd92fv.js → routeSourceValidator-7fycdqyv.js} +3 -39
- package/{scalar.command-fjq272gs.js → scalar.command-rm6k3c65.js} +9 -8
- package/templates/app/env/env.server.debug.ts.template +1 -5
- package/templates/app/env/env.server.develop.ts.template +1 -5
- package/templates/app/env/env.server.local.ts.template +1 -5
- package/templates/app/env/env.server.main.ts.template +1 -5
- package/templates/app/page/styles.css.template +3 -3
- package/templates/appSample/srvkit/AuthGuard.ts +9 -5
- package/templates/appSample/srvkit/SessionInternalArg.ts +4 -3
- package/templates/crudPages/[__model__Id]/edit/page.tsx +1 -1
- package/templates/crudPages/new/page.tsx +1 -1
- package/templates/crudSinglePage/page.tsx +1 -1
- package/templates/env/_env.server.type.ts +7 -5
- package/templates/lib/db.ts +4 -2
- package/templates/lib/dict.ts +1 -1
- package/templates/lib/sig.ts +1 -1
- package/templates/lib/srv.ts +6 -2
- package/templates/lib/useClient.ts +9 -3
- package/templates/module/__Model__.Util.tsx +1 -1
- package/templates/module/__Model__.Zone.tsx +3 -5
- package/templates/workspaceRoot/AGENTS.md.template +0 -831
- package/templates/workspaceRoot/CLAUDE.md.template +19 -0
- package/templates/workspaceRoot/biome.json.template +4 -217
- package/{typeChecker-kravn7ns.js → typeChecker-g6bh0xfv.js} +1 -1
- package/typecheck.proc.js +1 -1
- package/{workflow.command-jswbgk90.js → workflow.command-g2cgcgxn.js} +16 -15
- package/{workspace.command-h9wwcjpt.js → workspace.command-f7jerqb2.js} +70 -52
- package/templates/libRoot/base/baseLogic.ts +0 -14
- package/templates/libRoot/base/index.ts +0 -8
- package/templates/workspaceRoot/.cursor/rules/akan-page-routing.mdc.template +0 -43
- package/templates/workspaceRoot/.cursor/rules/akan-sync-conventions.mdc.template +0 -33
- package/templates/workspaceRoot/.cursor/rules/application-test-commands.mdc.template +0 -16
- package/templates/workspaceRoot/.cursor/rules/change-scope.mdc.template +0 -12
- package/templates/workspaceRoot/.cursor/rules/client-server-boundaries.mdc.template +0 -17
- package/templates/workspaceRoot/.cursor/rules/coding-comments.mdc.template +0 -25
- package/templates/workspaceRoot/.cursor/rules/coding-style.mdc.template +0 -44
- package/templates/workspaceRoot/.cursor/rules/domain-module-conventions.mdc.template +0 -17
- package/templates/workspaceRoot/.cursor/rules/format-lint.mdc.template +0 -12
- package/templates/workspaceRoot/.cursor/rules/lint-enforced-rules.mdc.template +0 -18
- package/templates/workspaceRoot/.cursor/rules/module-file-playbook.mdc.template +0 -61
- package/templates/workspaceRoot/.cursor/rules/naming-conventions.mdc.template +0 -15
- package/templates/workspaceRoot/.cursor/rules/react-component-style.mdc.template +0 -21
- package/templates/workspaceRoot/.cursor/rules/repo-overview.mdc.template +0 -17
- package/templates/workspaceRoot/.cursor/rules/scalar-modeling.mdc.template +0 -58
- package/templates/workspaceRoot/.cursor/rules/secrets-and-env.mdc.template +0 -13
- package/templates/workspaceRoot/.cursor/rules/service-signal-conventions.mdc.template +0 -83
- package/templates/workspaceRoot/.cursor/rules/srvkit-adapters-and-guards.mdc.template +0 -50
- package/templates/workspaceRoot/.cursor/rules/typescript-imports.mdc.template +0 -16
- /package/{index-r24hmh0q.js → index-yb2wsc7f.js} +0 -0
|
@@ -7,18 +7,454 @@ import {
|
|
|
7
7
|
command,
|
|
8
8
|
runner,
|
|
9
9
|
script
|
|
10
|
-
} from "./index-
|
|
11
|
-
import
|
|
12
|
-
|
|
10
|
+
} from "./index-n706txcv.js";
|
|
11
|
+
import {
|
|
12
|
+
appRootAllowedFiles,
|
|
13
|
+
libFacetRootAllowedFiles
|
|
14
|
+
} from "./index-8vkkm7v0.js";
|
|
15
|
+
import"./index-mxvakhsm.js";
|
|
16
|
+
import"./index-1577bej2.js";
|
|
13
17
|
import"./index-67546d0j.js";
|
|
14
|
-
import"./index-
|
|
18
|
+
import"./index-yb2wsc7f.js";
|
|
15
19
|
|
|
16
20
|
// pkgs/@akanjs/devkit/qualityScanner.ts
|
|
17
21
|
import { createHash } from "crypto";
|
|
18
22
|
import { readdir, readFile, stat } from "fs/promises";
|
|
19
23
|
import path from "path";
|
|
24
|
+
import { RESERVED_ROUTE_CONFIG_EXPORTS } from "akanjs/common";
|
|
20
25
|
import ignore from "ignore";
|
|
26
|
+
import ts3 from "typescript";
|
|
27
|
+
|
|
28
|
+
// pkgs/@akanjs/devkit/formSetterScanner.ts
|
|
21
29
|
import ts from "typescript";
|
|
30
|
+
|
|
31
|
+
class FormSetterScanner {
|
|
32
|
+
static #fieldSetter = /^set[A-Za-z0-9_$]*On[A-Za-z0-9_$]*$/;
|
|
33
|
+
scan(sourceFiles) {
|
|
34
|
+
return sourceFiles.filter((sourceFile) => sourceFile.file.endsWith(".tsx")).flatMap((f) => this.#scanFile(f));
|
|
35
|
+
}
|
|
36
|
+
#scanFile({ file, sourceFile }) {
|
|
37
|
+
const wrapped = [];
|
|
38
|
+
const visit = (node) => {
|
|
39
|
+
if (ts.isJsxAttribute(node)) {
|
|
40
|
+
const setter = FormSetterScanner.#wrappedSetterOf(node);
|
|
41
|
+
if (setter)
|
|
42
|
+
wrapped.push({ setter, line: FormSetterScanner.#lineOf(sourceFile, node) });
|
|
43
|
+
}
|
|
44
|
+
ts.forEachChild(node, visit);
|
|
45
|
+
};
|
|
46
|
+
visit(sourceFile);
|
|
47
|
+
if (!wrapped.length)
|
|
48
|
+
return [];
|
|
49
|
+
const setters = [...new Set(wrapped.map((entry) => entry.setter))].sort();
|
|
50
|
+
return [
|
|
51
|
+
{
|
|
52
|
+
rule: "akan.agent.unpublished-form-setter",
|
|
53
|
+
scope: "agent",
|
|
54
|
+
severity: "warning",
|
|
55
|
+
message: setters.length === 1 ? `${setters[0]} is reached through a wrapper, so that field publishes no agent tool and carries no data-akan-action.` : `${setters.length} field setters are reached through a wrapper, so those fields publish no agent tool and carry no data-akan-action: ${setters.join(", ")}.`,
|
|
56
|
+
file,
|
|
57
|
+
line: wrapped[0]?.line,
|
|
58
|
+
locations: wrapped.map(({ line }) => ({ file, line }))
|
|
59
|
+
}
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
static #wrappedSetterOf(attribute) {
|
|
63
|
+
if (!ts.isIdentifier(attribute.name) || !/^on[A-Z]/.test(attribute.name.text))
|
|
64
|
+
return null;
|
|
65
|
+
const initializer = attribute.initializer;
|
|
66
|
+
if (!initializer || !ts.isJsxExpression(initializer) || !initializer.expression)
|
|
67
|
+
return null;
|
|
68
|
+
const handler = initializer.expression;
|
|
69
|
+
if (!ts.isArrowFunction(handler) && !ts.isFunctionExpression(handler))
|
|
70
|
+
return null;
|
|
71
|
+
if (!handler.parameters.length)
|
|
72
|
+
return null;
|
|
73
|
+
return FormSetterScanner.#setterCallIn(handler.body);
|
|
74
|
+
}
|
|
75
|
+
static #setterCallIn(node) {
|
|
76
|
+
let found = null;
|
|
77
|
+
const visit = (current) => {
|
|
78
|
+
if (found)
|
|
79
|
+
return;
|
|
80
|
+
if (ts.isCallExpression(current) && ts.isPropertyAccessExpression(current.expression)) {
|
|
81
|
+
const { expression, name } = current.expression;
|
|
82
|
+
if (ts.isPropertyAccessExpression(expression) && ts.isIdentifier(expression.expression) && expression.expression.text === "st" && expression.name.text === "do" && FormSetterScanner.#fieldSetter.test(name.text)) {
|
|
83
|
+
found = name.text;
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
ts.forEachChild(current, visit);
|
|
88
|
+
};
|
|
89
|
+
visit(node);
|
|
90
|
+
return found;
|
|
91
|
+
}
|
|
92
|
+
static #lineOf(sourceFile, node) {
|
|
93
|
+
return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// pkgs/@akanjs/devkit/ssrScanner.ts
|
|
98
|
+
import ts2 from "typescript";
|
|
99
|
+
var STATIC_COMPONENT_MIN_MASS = 4;
|
|
100
|
+
var MIXED_COMPONENT_MIN_MASS = 10;
|
|
101
|
+
var MIXED_COMPONENT_MAX_TOUCHES = 2;
|
|
102
|
+
var MODULE_SERVER_VIEW_MIN_CLIENT_MASS = 12;
|
|
103
|
+
|
|
104
|
+
class SsrScanner {
|
|
105
|
+
static #serverSafeCalls = new Set(["usePage", "getSelf", "useServer"]);
|
|
106
|
+
static #clientGlobals = new Set([
|
|
107
|
+
"window",
|
|
108
|
+
"document",
|
|
109
|
+
"navigator",
|
|
110
|
+
"localStorage",
|
|
111
|
+
"sessionStorage",
|
|
112
|
+
"location",
|
|
113
|
+
"history",
|
|
114
|
+
"screen",
|
|
115
|
+
"matchMedia",
|
|
116
|
+
"IntersectionObserver",
|
|
117
|
+
"ResizeObserver",
|
|
118
|
+
"MutationObserver",
|
|
119
|
+
"requestAnimationFrame",
|
|
120
|
+
"WebSocket"
|
|
121
|
+
]);
|
|
122
|
+
static #clientRuntimeImports = new Set(["st", "fetch"]);
|
|
123
|
+
scan(sourceFiles) {
|
|
124
|
+
const componentFiles = sourceFiles.filter((sourceFile) => this.#isBalancedFile(sourceFile.file));
|
|
125
|
+
return {
|
|
126
|
+
warnings: [
|
|
127
|
+
...componentFiles.flatMap((sourceFile) => this.#scanFile(sourceFile)),
|
|
128
|
+
...this.#scanModules(componentFiles)
|
|
129
|
+
],
|
|
130
|
+
balance: this.#measureBalance(componentFiles)
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
#scanFile(sourceFile) {
|
|
134
|
+
if (!this.#hasUseClient(sourceFile.sourceFile))
|
|
135
|
+
return [];
|
|
136
|
+
const vendorNames = this.#getVendorNames(sourceFile.sourceFile);
|
|
137
|
+
const hasVendorImport = this.#hasVendorImport(sourceFile.sourceFile);
|
|
138
|
+
const importsClientRuntime = this.#importsClientRuntime(sourceFile.sourceFile);
|
|
139
|
+
const components = this.#getComponents(sourceFile, vendorNames);
|
|
140
|
+
const warnings = [];
|
|
141
|
+
if (!hasVendorImport && !importsClientRuntime && this.#getTouches(sourceFile.sourceFile, sourceFile.sourceFile).length === 0 && !this.#isConventionClientFile(sourceFile.file)) {
|
|
142
|
+
warnings.push({
|
|
143
|
+
rule: "akan.ssr.unnecessary-use-client",
|
|
144
|
+
scope: "ssr",
|
|
145
|
+
severity: "warning",
|
|
146
|
+
file: sourceFile.file,
|
|
147
|
+
line: 1,
|
|
148
|
+
message: `"use client" is declared but the file uses no client-only capability (hook, event handler, store, or browser API).`
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
for (const component of components) {
|
|
152
|
+
if (component.vendorTags)
|
|
153
|
+
continue;
|
|
154
|
+
if (component.touches.length === 0 && component.mass >= STATIC_COMPONENT_MIN_MASS) {
|
|
155
|
+
warnings.push({
|
|
156
|
+
rule: "akan.ssr.client-static-component",
|
|
157
|
+
scope: "ssr",
|
|
158
|
+
severity: "warning",
|
|
159
|
+
file: sourceFile.file,
|
|
160
|
+
line: component.line,
|
|
161
|
+
message: `Client component "${component.name}" renders ${component.mass} JSX elements with no client-only capability. It is server-renderable markup sitting in the client bundle.`
|
|
162
|
+
});
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (component.touches.length >= 1 && component.touches.length <= MIXED_COMPONENT_MAX_TOUCHES && component.mass >= MIXED_COMPONENT_MIN_MASS) {
|
|
166
|
+
warnings.push({
|
|
167
|
+
rule: "akan.ssr.client-static-markup",
|
|
168
|
+
scope: "ssr",
|
|
169
|
+
severity: "warning",
|
|
170
|
+
file: sourceFile.file,
|
|
171
|
+
line: component.line,
|
|
172
|
+
message: `Client component "${component.name}" renders ${component.mass} JSX elements around only ${component.touches.length} client-only touch (${[...new Set(component.touches)].join(", ")}). Most of this subtree does not need the client bundle.`
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
warnings.push(...this.#getMountLoadWarnings(sourceFile));
|
|
177
|
+
warnings.push(...this.#getTemplateStateWarnings(sourceFile));
|
|
178
|
+
return warnings;
|
|
179
|
+
}
|
|
180
|
+
#scanModules(sourceFiles) {
|
|
181
|
+
const modules = new Map;
|
|
182
|
+
for (const sourceFile of sourceFiles) {
|
|
183
|
+
const moduleDir = this.#getModuleDir(sourceFile.file);
|
|
184
|
+
if (!moduleDir)
|
|
185
|
+
continue;
|
|
186
|
+
const entry = modules.get(moduleDir) ?? { clientMass: 0, serverFiles: 0, line: sourceFile.file };
|
|
187
|
+
if (this.#hasUseClient(sourceFile.sourceFile))
|
|
188
|
+
entry.clientMass += this.#getMass(sourceFile.sourceFile);
|
|
189
|
+
else if (/\.(Unit|View)\.tsx$/.test(sourceFile.file))
|
|
190
|
+
entry.serverFiles += 1;
|
|
191
|
+
modules.set(moduleDir, entry);
|
|
192
|
+
}
|
|
193
|
+
return [...modules].filter(([, entry]) => entry.serverFiles === 0 && entry.clientMass >= MODULE_SERVER_VIEW_MIN_CLIENT_MASS).map(([moduleDir, entry]) => ({
|
|
194
|
+
rule: "akan.ssr.module-missing-server-view",
|
|
195
|
+
scope: "ssr",
|
|
196
|
+
severity: "warning",
|
|
197
|
+
file: entry.line,
|
|
198
|
+
message: `Module "${moduleDir}" renders ${entry.clientMass} JSX elements from client files only; it declares no Unit or View server component.`
|
|
199
|
+
}));
|
|
200
|
+
}
|
|
201
|
+
#getMountLoadWarnings(sourceFile) {
|
|
202
|
+
const warnings = [];
|
|
203
|
+
const visit = (node) => {
|
|
204
|
+
if (this.#isMountEffect(sourceFile.sourceFile, node)) {
|
|
205
|
+
for (const load of this.#getLoadCalls(sourceFile.sourceFile, node)) {
|
|
206
|
+
warnings.push({
|
|
207
|
+
rule: "akan.ssr.client-mount-load",
|
|
208
|
+
scope: "ssr",
|
|
209
|
+
severity: "warning",
|
|
210
|
+
file: sourceFile.file,
|
|
211
|
+
line: this.#getLine(sourceFile.sourceFile, load.node),
|
|
212
|
+
message: `Mount-only effect loads server data with ${load.callee}(). The route can fetch this before the first byte instead.`
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
ts2.forEachChild(node, visit);
|
|
217
|
+
};
|
|
218
|
+
ts2.forEachChild(sourceFile.sourceFile, visit);
|
|
219
|
+
return warnings;
|
|
220
|
+
}
|
|
221
|
+
#isMountEffect(sourceFile, node) {
|
|
222
|
+
if (!ts2.isCallExpression(node))
|
|
223
|
+
return false;
|
|
224
|
+
const callee = node.expression.getText(sourceFile);
|
|
225
|
+
if (callee !== "useEffect" && callee !== "useLayoutEffect")
|
|
226
|
+
return false;
|
|
227
|
+
const deps = node.arguments[1];
|
|
228
|
+
return !!deps && ts2.isArrayLiteralExpression(deps) && deps.elements.length === 0;
|
|
229
|
+
}
|
|
230
|
+
#getLoadCalls(sourceFile, node) {
|
|
231
|
+
const calls = [];
|
|
232
|
+
const visit = (child) => {
|
|
233
|
+
if (ts2.isCallExpression(child)) {
|
|
234
|
+
const callee = child.expression.getText(sourceFile);
|
|
235
|
+
if (/^fetch\.[a-z]/.test(callee) || /^st\.do\.(init|get|view|load|list|count|insight)[A-Z]/.test(callee))
|
|
236
|
+
calls.push({ callee, node: child });
|
|
237
|
+
}
|
|
238
|
+
ts2.forEachChild(child, visit);
|
|
239
|
+
};
|
|
240
|
+
ts2.forEachChild(node, visit);
|
|
241
|
+
return calls;
|
|
242
|
+
}
|
|
243
|
+
#getTemplateStateWarnings(sourceFile) {
|
|
244
|
+
if (!sourceFile.file.endsWith(".Template.tsx"))
|
|
245
|
+
return [];
|
|
246
|
+
const warnings = [];
|
|
247
|
+
const visit = (node) => {
|
|
248
|
+
if (ts2.isCallExpression(node) && node.expression.getText(sourceFile.sourceFile) === "useState") {
|
|
249
|
+
warnings.push({
|
|
250
|
+
rule: "akan.ssr.template-client-state",
|
|
251
|
+
scope: "ssr",
|
|
252
|
+
severity: "warning",
|
|
253
|
+
file: sourceFile.file,
|
|
254
|
+
line: this.#getLine(sourceFile.sourceFile, node),
|
|
255
|
+
message: "Template holds form state in useState. Templates are store-driven and carry no local state."
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
ts2.forEachChild(node, visit);
|
|
259
|
+
};
|
|
260
|
+
ts2.forEachChild(sourceFile.sourceFile, visit);
|
|
261
|
+
return warnings;
|
|
262
|
+
}
|
|
263
|
+
#measureBalance(sourceFiles) {
|
|
264
|
+
const scopes = new Map;
|
|
265
|
+
for (const sourceFile of sourceFiles) {
|
|
266
|
+
const segments = sourceFile.file.split("/");
|
|
267
|
+
const scope = `${segments[0]}/${segments[1]}`;
|
|
268
|
+
const entry = scopes.get(scope) ?? { serverMass: 0, clientMass: 0 };
|
|
269
|
+
const mass = this.#getMass(sourceFile.sourceFile);
|
|
270
|
+
if (this.#hasUseClient(sourceFile.sourceFile))
|
|
271
|
+
entry.clientMass += mass;
|
|
272
|
+
else
|
|
273
|
+
entry.serverMass += mass;
|
|
274
|
+
scopes.set(scope, entry);
|
|
275
|
+
}
|
|
276
|
+
const entries = [...scopes].map(([scope, mass]) => ({ scope, ...mass, serverShare: getShare(mass.serverMass, mass.clientMass) })).sort((a, b) => a.scope.localeCompare(b.scope));
|
|
277
|
+
if (entries.length < 2)
|
|
278
|
+
return entries;
|
|
279
|
+
const serverMass = entries.reduce((sum, entry) => sum + entry.serverMass, 0);
|
|
280
|
+
const clientMass = entries.reduce((sum, entry) => sum + entry.clientMass, 0);
|
|
281
|
+
return [...entries, { scope: "workspace", serverMass, clientMass, serverShare: getShare(serverMass, clientMass) }];
|
|
282
|
+
}
|
|
283
|
+
#getComponents(sourceFile, vendorNames) {
|
|
284
|
+
const components = [];
|
|
285
|
+
for (const statement of sourceFile.sourceFile.statements) {
|
|
286
|
+
for (const { name, node } of this.#getComponentNodes(statement)) {
|
|
287
|
+
if (!/^[A-Z]/.test(name))
|
|
288
|
+
continue;
|
|
289
|
+
components.push({
|
|
290
|
+
name,
|
|
291
|
+
line: this.#getLine(sourceFile.sourceFile, node),
|
|
292
|
+
mass: this.#getMass(node),
|
|
293
|
+
touches: this.#getTouches(sourceFile.sourceFile, node),
|
|
294
|
+
vendorTags: [...this.#getTagNames(sourceFile.sourceFile, node)].some((tag) => vendorNames.has(tag))
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return components;
|
|
299
|
+
}
|
|
300
|
+
#getComponentNodes(statement) {
|
|
301
|
+
if (ts2.isFunctionDeclaration(statement) && statement.body)
|
|
302
|
+
return [{ name: statement.name?.text ?? "default", node: statement.body }];
|
|
303
|
+
if (!ts2.isVariableStatement(statement))
|
|
304
|
+
return [];
|
|
305
|
+
return statement.declarationList.declarations.flatMap((declaration) => {
|
|
306
|
+
if (!ts2.isIdentifier(declaration.name) || !declaration.initializer)
|
|
307
|
+
return [];
|
|
308
|
+
if (!ts2.isArrowFunction(declaration.initializer) && !ts2.isFunctionExpression(declaration.initializer))
|
|
309
|
+
return [];
|
|
310
|
+
return [{ name: declaration.name.text, node: declaration.initializer }];
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
#getTouches(sourceFile, node) {
|
|
314
|
+
const touches = [];
|
|
315
|
+
const visit = (child) => {
|
|
316
|
+
if (ts2.isCallExpression(child)) {
|
|
317
|
+
const callee = child.expression.getText(sourceFile);
|
|
318
|
+
const bareName = callee.split(".").pop() ?? callee;
|
|
319
|
+
if (callee === "createContext" || callee === "lazy")
|
|
320
|
+
touches.push(callee);
|
|
321
|
+
else if (/^use[A-Z]/.test(bareName) && !SsrScanner.#serverSafeCalls.has(bareName))
|
|
322
|
+
touches.push(bareName);
|
|
323
|
+
}
|
|
324
|
+
if (ts2.isJsxAttribute(child) && /^on[A-Z]/.test(child.name.getText(sourceFile)))
|
|
325
|
+
touches.push(child.name.getText(sourceFile));
|
|
326
|
+
if (ts2.isPropertyAccessExpression(child)) {
|
|
327
|
+
const root = getAccessRoot(child);
|
|
328
|
+
if (root === "st")
|
|
329
|
+
touches.push("st");
|
|
330
|
+
else if (SsrScanner.#clientGlobals.has(root))
|
|
331
|
+
touches.push(root);
|
|
332
|
+
}
|
|
333
|
+
ts2.forEachChild(child, visit);
|
|
334
|
+
};
|
|
335
|
+
ts2.forEachChild(node, visit);
|
|
336
|
+
return touches;
|
|
337
|
+
}
|
|
338
|
+
#getTagNames(sourceFile, node) {
|
|
339
|
+
const tags = new Set;
|
|
340
|
+
const visit = (child) => {
|
|
341
|
+
if (ts2.isJsxOpeningElement(child) || ts2.isJsxSelfClosingElement(child))
|
|
342
|
+
tags.add(child.tagName.getText(sourceFile).split(".")[0]);
|
|
343
|
+
ts2.forEachChild(child, visit);
|
|
344
|
+
};
|
|
345
|
+
ts2.forEachChild(node, visit);
|
|
346
|
+
return tags;
|
|
347
|
+
}
|
|
348
|
+
#getMass(node) {
|
|
349
|
+
let mass = 0;
|
|
350
|
+
const visit = (child) => {
|
|
351
|
+
if (ts2.isJsxOpeningElement(child) || ts2.isJsxSelfClosingElement(child))
|
|
352
|
+
mass += 1;
|
|
353
|
+
ts2.forEachChild(child, visit);
|
|
354
|
+
};
|
|
355
|
+
ts2.forEachChild(node, visit);
|
|
356
|
+
return mass;
|
|
357
|
+
}
|
|
358
|
+
#hasUseClient(sourceFile) {
|
|
359
|
+
const first = sourceFile.statements[0];
|
|
360
|
+
if (!first || !ts2.isExpressionStatement(first) || !ts2.isStringLiteral(first.expression))
|
|
361
|
+
return false;
|
|
362
|
+
return first.expression.text === "use client";
|
|
363
|
+
}
|
|
364
|
+
#hasVendorImport(sourceFile) {
|
|
365
|
+
return sourceFile.statements.some((statement) => ts2.isImportDeclaration(statement) && isVendorSpecifier(getSpecifier(statement)));
|
|
366
|
+
}
|
|
367
|
+
#getVendorNames(sourceFile) {
|
|
368
|
+
const names = new Set;
|
|
369
|
+
for (const statement of sourceFile.statements) {
|
|
370
|
+
if (!ts2.isImportDeclaration(statement) || !isVendorSpecifier(getSpecifier(statement)))
|
|
371
|
+
continue;
|
|
372
|
+
const clause = statement.importClause;
|
|
373
|
+
if (clause?.name)
|
|
374
|
+
names.add(clause.name.text);
|
|
375
|
+
if (clause?.namedBindings && ts2.isNamespaceImport(clause.namedBindings))
|
|
376
|
+
names.add(clause.namedBindings.name.text);
|
|
377
|
+
if (clause?.namedBindings && ts2.isNamedImports(clause.namedBindings))
|
|
378
|
+
for (const element of clause.namedBindings.elements)
|
|
379
|
+
names.add(element.name.text);
|
|
380
|
+
}
|
|
381
|
+
return names;
|
|
382
|
+
}
|
|
383
|
+
#importsClientRuntime(sourceFile) {
|
|
384
|
+
for (const statement of sourceFile.statements) {
|
|
385
|
+
if (!ts2.isImportDeclaration(statement))
|
|
386
|
+
continue;
|
|
387
|
+
const bindings = statement.importClause?.namedBindings;
|
|
388
|
+
if (!bindings || !ts2.isNamedImports(bindings))
|
|
389
|
+
continue;
|
|
390
|
+
if (bindings.elements.some((element) => SsrScanner.#clientRuntimeImports.has(element.name.text)))
|
|
391
|
+
return true;
|
|
392
|
+
}
|
|
393
|
+
return false;
|
|
394
|
+
}
|
|
395
|
+
#isConventionClientFile(file) {
|
|
396
|
+
if (file.endsWith("/index_.tsx"))
|
|
397
|
+
return true;
|
|
398
|
+
return /\.(Zone|Template|Util)\.tsx$/.test(file) && this.#getModuleDir(file) !== null;
|
|
399
|
+
}
|
|
400
|
+
#isBalancedFile(file) {
|
|
401
|
+
if (!file.endsWith(".tsx") || file.endsWith(".test.tsx") || file.endsWith(".spec.tsx"))
|
|
402
|
+
return false;
|
|
403
|
+
const segments = file.split("/");
|
|
404
|
+
if (segments[0] !== "apps" && segments[0] !== "libs")
|
|
405
|
+
return false;
|
|
406
|
+
return segments[2] === "ui" || segments[2] === "lib";
|
|
407
|
+
}
|
|
408
|
+
#getModuleDir(file) {
|
|
409
|
+
const segments = file.split("/");
|
|
410
|
+
const libIndex = segments.indexOf("lib");
|
|
411
|
+
if (libIndex < 1 || segments.length <= libIndex + 2)
|
|
412
|
+
return null;
|
|
413
|
+
const moduleName = segments[libIndex + 1];
|
|
414
|
+
if (moduleName.startsWith("_"))
|
|
415
|
+
return null;
|
|
416
|
+
return segments.slice(0, libIndex + 2).join("/");
|
|
417
|
+
}
|
|
418
|
+
#getLine(sourceFile, node) {
|
|
419
|
+
return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
var SSR_SERVER_SHARE_TARGET = 0.5;
|
|
423
|
+
function formatSsrBalance(balance) {
|
|
424
|
+
if (balance.length === 0)
|
|
425
|
+
return ["No component files found."];
|
|
426
|
+
return balance.map((entry) => {
|
|
427
|
+
const total = entry.serverMass + entry.clientMass;
|
|
428
|
+
const share = `${Math.round(entry.serverShare * 100)}% server`;
|
|
429
|
+
const counts = `${entry.serverMass} of ${total} JSX elements, ${entry.clientMass} client`;
|
|
430
|
+
const flag = entry.serverShare < SSR_SERVER_SHARE_TARGET ? ` <- below the ${Math.round(SSR_SERVER_SHARE_TARGET * 100)}% target` : "";
|
|
431
|
+
return ` ${entry.scope}: ${share} (${counts})${flag}`;
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
function getShare(serverMass, clientMass) {
|
|
435
|
+
const total = serverMass + clientMass;
|
|
436
|
+
return total === 0 ? 1 : serverMass / total;
|
|
437
|
+
}
|
|
438
|
+
function getSpecifier(statement) {
|
|
439
|
+
return ts2.isStringLiteral(statement.moduleSpecifier) ? statement.moduleSpecifier.text : "";
|
|
440
|
+
}
|
|
441
|
+
function isVendorSpecifier(specifier) {
|
|
442
|
+
if (specifier === "" || specifier.startsWith(".") || specifier.startsWith("/"))
|
|
443
|
+
return false;
|
|
444
|
+
if (specifier === "react" || specifier === "react-dom" || specifier.startsWith("react/"))
|
|
445
|
+
return false;
|
|
446
|
+
if (specifier.startsWith("node:"))
|
|
447
|
+
return false;
|
|
448
|
+
return !/^(akanjs|@akanjs|@libs|@apps|@contract)(\/|$)/.test(specifier);
|
|
449
|
+
}
|
|
450
|
+
function getAccessRoot(node) {
|
|
451
|
+
let current = node;
|
|
452
|
+
while (ts2.isPropertyAccessExpression(current))
|
|
453
|
+
current = current.expression;
|
|
454
|
+
return ts2.isIdentifier(current) ? current.text : "";
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// pkgs/@akanjs/devkit/qualityScanner.ts
|
|
22
458
|
var MAX_FILE_LINES = 2000;
|
|
23
459
|
var PLACEHOLDER_EXPORT_NAMES = new Set([
|
|
24
460
|
"aa",
|
|
@@ -39,30 +475,10 @@ var SUGGESTED_RULES = [
|
|
|
39
475
|
"Keep apps/*/lib and libs/*/lib root files limited to generated support facets such as cnst.ts, db.ts, dict.ts, sig.ts, srv.ts, st.ts, useClient.ts, and useServer.ts.",
|
|
40
476
|
"Use domain module folders consistently: lib/<model> for database modules, lib/_<service> for service modules, and lib/__scalar/<scalar> for scalar modules.",
|
|
41
477
|
"Keep module UI filenames predictable: database modules use <Model>.Template/Unit/Util/View/Zone.tsx, service modules use <Service>.Util/Zone.tsx, and scalar modules use <Scalar>.Template/Unit.tsx.",
|
|
478
|
+
"Hand a form control its store setter by reference so the field publishes an agent tool; put normalization in the control's transform prop and a multi-write in a _postSet<Field> hook on the store.",
|
|
42
479
|
"Move shared app utilities to apps/*/common instead of creating apps/*/base.",
|
|
43
480
|
"Avoid large mixed-purpose class files; class export files should import helpers from neighboring utility files instead of declaring them inline."
|
|
44
481
|
];
|
|
45
|
-
var APP_ROOT_FILES = new Set([
|
|
46
|
-
"akan.app.json",
|
|
47
|
-
"akan.config.ts",
|
|
48
|
-
"capacitor.config.ts",
|
|
49
|
-
"client.ts",
|
|
50
|
-
"main.ts",
|
|
51
|
-
"package.json",
|
|
52
|
-
"server.ts",
|
|
53
|
-
"tsconfig.json"
|
|
54
|
-
]);
|
|
55
|
-
var LIB_ROOT_FILES = new Set([
|
|
56
|
-
"cnst.ts",
|
|
57
|
-
"db.ts",
|
|
58
|
-
"dict.ts",
|
|
59
|
-
"option.ts",
|
|
60
|
-
"sig.ts",
|
|
61
|
-
"srv.ts",
|
|
62
|
-
"st.ts",
|
|
63
|
-
"useClient.ts",
|
|
64
|
-
"useServer.ts"
|
|
65
|
-
]);
|
|
66
482
|
var CONVENTION_SUFFIXES = [
|
|
67
483
|
".constant.ts",
|
|
68
484
|
".dictionary.ts",
|
|
@@ -71,20 +487,6 @@ var CONVENTION_SUFFIXES = [
|
|
|
71
487
|
".signal.ts",
|
|
72
488
|
".store.ts"
|
|
73
489
|
];
|
|
74
|
-
var PAGE_RESERVED_EXPORTS = new Set([
|
|
75
|
-
"pageConfig",
|
|
76
|
-
"head",
|
|
77
|
-
"metadata",
|
|
78
|
-
"generateHead",
|
|
79
|
-
"generateMetadata",
|
|
80
|
-
"fonts",
|
|
81
|
-
"manifest",
|
|
82
|
-
"theme",
|
|
83
|
-
"reconnect",
|
|
84
|
-
"wsConnect",
|
|
85
|
-
"layoutStyle",
|
|
86
|
-
"gaTrackingId"
|
|
87
|
-
]);
|
|
88
490
|
var RULE_FIXES = {
|
|
89
491
|
"akan.global.duplicate-exported-function-name": "Rename one of the exports, or if they are the same thing, extract it into one shared module and import it in both places.",
|
|
90
492
|
"akan.global.duplicate-exported-function-body": "Extract the shared implementation into a single exported helper and import it, instead of copying the body.",
|
|
@@ -97,11 +499,18 @@ var RULE_FIXES = {
|
|
|
97
499
|
"akan.file.window-augmentation": "Move the Window augmentation into an approved browser integration file and keep it isolated.",
|
|
98
500
|
"akan.file.prototype-mutation": "Avoid prototype mutation, or isolate it in an approved low-level integration file.",
|
|
99
501
|
"akan.file.class-export-global-declaration": "Move the helper to a sibling file and import it into the class module.",
|
|
502
|
+
"akan.agent.unpublished-form-setter": "Pass the setter by reference where the wrapper only forwards (onChange={st.do.setTitleOnTask}); move normalization into the control's own transform prop; move a multi-write into a _postSet<Field> hook on the store, keeping the generated setter on the control.",
|
|
100
503
|
"akan.file.component-internal-declaration": "Move the type or helper to a type/util file in ui/, webkit/, or common/ by purpose. If it is the component's props, declare it as `interface <Component>Props`.",
|
|
101
504
|
"akan.file.component-export": "Move the value or type to a util/constant/type file in ui/, webkit/, or common/ and import it. Adding `export` is not a valid fix \u2014 only PascalCase components and their `<Component>Props` interface belong here.",
|
|
102
505
|
"akan.layout.app-root-file": "Move the file into a conventional app folder (common, env, lib, page, private, public, script, srvkit, ui, or webkit).",
|
|
103
506
|
"akan.layout.lib-root-file": "Move the file into a domain module folder under lib/; keep lib root limited to generated support facets.",
|
|
104
|
-
"akan.layout.module-ui-file": "Rename the file to an allowed module UI name, or move it to ui/ if it is not a module component."
|
|
507
|
+
"akan.layout.module-ui-file": "Rename the file to an allowed module UI name, or move it to ui/ if it is not a module component.",
|
|
508
|
+
"akan.ssr.unnecessary-use-client": '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.',
|
|
509
|
+
"akan.ssr.client-static-component": "Move the component to a server file \u2014 a <Model>.Unit.tsx / <Model>.View.tsx for a module, or a ui/ file with no directive \u2014 and reference it from the client file.",
|
|
510
|
+
"akan.ssr.client-static-markup": "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.",
|
|
511
|
+
"akan.ssr.client-mount-load": "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.",
|
|
512
|
+
"akan.ssr.module-missing-server-view": "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.",
|
|
513
|
+
"akan.ssr.template-client-state": "Bind the field to the store instead: `value={xForm.field}` with `onChange={st.do.setFieldOnX}`."
|
|
105
514
|
};
|
|
106
515
|
function getRuleFix(rule) {
|
|
107
516
|
if (rule.startsWith("akan.convention"))
|
|
@@ -114,18 +523,22 @@ class AkanQualityScanner {
|
|
|
114
523
|
const targetFiles = await this.#collectTargetFiles(workspaceRoot);
|
|
115
524
|
const sourceFiles = await Promise.all(targetFiles.filter((file) => !AbstractDoc.isAbstractPath(file)).map((file) => this.#readSourceFile(workspaceRoot, file)));
|
|
116
525
|
const abstractFiles = await Promise.all(targetFiles.filter((file) => AbstractDoc.isAbstractPath(file)).map((file) => this.#readTextFile(workspaceRoot, file)));
|
|
526
|
+
const ssr = new SsrScanner().scan(sourceFiles);
|
|
117
527
|
const warnings = [
|
|
118
528
|
...this.#scanGlobalQuality(sourceFiles),
|
|
119
529
|
...sourceFiles.flatMap((sourceFile) => this.#scanSingleFileQuality(sourceFile)),
|
|
120
530
|
...sourceFiles.flatMap((sourceFile) => this.#scanComponentQuality(sourceFile)),
|
|
121
531
|
...sourceFiles.flatMap((sourceFile) => this.#scanConventionQuality(sourceFile)),
|
|
122
532
|
...sourceFiles.flatMap((sourceFile) => this.#scanLayoutQuality(sourceFile)),
|
|
123
|
-
...abstractFiles.flatMap((abstractFile) => this.#scanAbstractQuality(abstractFile))
|
|
533
|
+
...abstractFiles.flatMap((abstractFile) => this.#scanAbstractQuality(abstractFile)),
|
|
534
|
+
...new FormSetterScanner().scan(sourceFiles),
|
|
535
|
+
...ssr.warnings
|
|
124
536
|
];
|
|
125
537
|
return {
|
|
126
538
|
workspaceRoot,
|
|
127
539
|
scannedFiles: sourceFiles.length + abstractFiles.length,
|
|
128
540
|
warnings: warnings.map((warning) => ({ ...warning, fix: warning.fix ?? getRuleFix(warning.rule) })).sort(compareWarnings),
|
|
541
|
+
ssrBalance: ssr.balance,
|
|
129
542
|
suggestedRules: SUGGESTED_RULES
|
|
130
543
|
};
|
|
131
544
|
}
|
|
@@ -171,7 +584,7 @@ class AkanQualityScanner {
|
|
|
171
584
|
file,
|
|
172
585
|
absolutePath,
|
|
173
586
|
content,
|
|
174
|
-
sourceFile:
|
|
587
|
+
sourceFile: ts3.createSourceFile(file, content, ts3.ScriptTarget.Latest, true, getScriptKind(file))
|
|
175
588
|
};
|
|
176
589
|
}
|
|
177
590
|
async#readTextFile(workspaceRoot, file) {
|
|
@@ -332,7 +745,7 @@ class AkanQualityScanner {
|
|
|
332
745
|
#scanLayoutQuality(sourceFile) {
|
|
333
746
|
const segments = sourceFile.file.split("/");
|
|
334
747
|
const warnings = [];
|
|
335
|
-
if (segments[0] === "apps" && segments.length === 3 && !
|
|
748
|
+
if (segments[0] === "apps" && segments.length === 3 && !appRootAllowedFiles.has(segments[2])) {
|
|
336
749
|
warnings.push({
|
|
337
750
|
rule: "akan.layout.app-root-file",
|
|
338
751
|
scope: "layout",
|
|
@@ -342,7 +755,7 @@ class AkanQualityScanner {
|
|
|
342
755
|
});
|
|
343
756
|
}
|
|
344
757
|
const libRootFile = getLibRootFile(sourceFile.file);
|
|
345
|
-
if (libRootFile && !
|
|
758
|
+
if (libRootFile && !libFacetRootAllowedFiles.has(libRootFile)) {
|
|
346
759
|
warnings.push({
|
|
347
760
|
rule: "akan.layout.lib-root-file",
|
|
348
761
|
scope: "layout",
|
|
@@ -368,6 +781,10 @@ function formatQualityScanResult(result) {
|
|
|
368
781
|
"",
|
|
369
782
|
...formatQualityWarnings(result.warnings),
|
|
370
783
|
"",
|
|
784
|
+
"SSR balance (component files, JSX elements rendered per side):",
|
|
785
|
+
"",
|
|
786
|
+
...formatSsrBalance(result.ssrBalance),
|
|
787
|
+
"",
|
|
371
788
|
"Suggested quality rules:",
|
|
372
789
|
"",
|
|
373
790
|
...result.suggestedRules.map((rule) => ` - ${rule}`)
|
|
@@ -375,6 +792,24 @@ function formatQualityScanResult(result) {
|
|
|
375
792
|
return sections.join(`
|
|
376
793
|
`);
|
|
377
794
|
}
|
|
795
|
+
function formatSsrScanResult(result) {
|
|
796
|
+
const sections = [
|
|
797
|
+
"Akan SSR Balance Scan",
|
|
798
|
+
`workspace: ${result.workspaceRoot}`,
|
|
799
|
+
`scanned files: ${result.scannedFiles}`,
|
|
800
|
+
`ssr warnings: ${result.warnings.length}`,
|
|
801
|
+
"",
|
|
802
|
+
"Server render share (component files, JSX elements rendered per side):",
|
|
803
|
+
"",
|
|
804
|
+
...formatSsrBalance(result.ssrBalance),
|
|
805
|
+
"",
|
|
806
|
+
"Warnings:",
|
|
807
|
+
"",
|
|
808
|
+
...formatQualityWarnings(result.warnings)
|
|
809
|
+
];
|
|
810
|
+
return sections.join(`
|
|
811
|
+
`);
|
|
812
|
+
}
|
|
378
813
|
function formatQualityWarnings(warnings) {
|
|
379
814
|
if (warnings.length === 0)
|
|
380
815
|
return ["No warnings found."];
|
|
@@ -396,7 +831,7 @@ function getExportedFunctionLikes(sourceFile) {
|
|
|
396
831
|
const declarations = [];
|
|
397
832
|
const nameExempt = isPageRouteFile(sourceFile.file) || isUiComponentFile(sourceFile.file);
|
|
398
833
|
for (const statement of sourceFile.sourceFile.statements) {
|
|
399
|
-
if (
|
|
834
|
+
if (ts3.isFunctionDeclaration(statement) && statement.name && isExported(statement)) {
|
|
400
835
|
declarations.push({
|
|
401
836
|
name: statement.name.text,
|
|
402
837
|
kind: "function",
|
|
@@ -406,7 +841,7 @@ function getExportedFunctionLikes(sourceFile) {
|
|
|
406
841
|
duplicateNameExempt: nameExempt || isConventionDuplicateNameExempt(sourceFile.file, false)
|
|
407
842
|
});
|
|
408
843
|
}
|
|
409
|
-
if (
|
|
844
|
+
if (ts3.isClassDeclaration(statement) && statement.name && isExported(statement)) {
|
|
410
845
|
declarations.push({
|
|
411
846
|
name: statement.name.text,
|
|
412
847
|
kind: "class",
|
|
@@ -416,9 +851,9 @@ function getExportedFunctionLikes(sourceFile) {
|
|
|
416
851
|
duplicateNameExempt: nameExempt || isConventionDuplicateNameExempt(sourceFile.file, isEnumClassStatement(sourceFile.sourceFile, statement))
|
|
417
852
|
});
|
|
418
853
|
}
|
|
419
|
-
if (
|
|
854
|
+
if (ts3.isVariableStatement(statement) && isExported(statement)) {
|
|
420
855
|
for (const declaration of statement.declarationList.declarations) {
|
|
421
|
-
if (!
|
|
856
|
+
if (!ts3.isIdentifier(declaration.name) || !isFunctionLikeInitializer(declaration.initializer))
|
|
422
857
|
continue;
|
|
423
858
|
declarations.push({
|
|
424
859
|
name: declaration.name.text,
|
|
@@ -457,14 +892,14 @@ function isInLibModule(file) {
|
|
|
457
892
|
return (segments[0] === "apps" || segments[0] === "libs") && segments.includes("lib");
|
|
458
893
|
}
|
|
459
894
|
function isEnumClassStatement(sourceFile, statement) {
|
|
460
|
-
if (!
|
|
895
|
+
if (!ts3.isClassDeclaration(statement))
|
|
461
896
|
return false;
|
|
462
|
-
const heritageClause = statement.heritageClauses?.find((clause) => clause.token ===
|
|
897
|
+
const heritageClause = statement.heritageClauses?.find((clause) => clause.token === ts3.SyntaxKind.ExtendsKeyword);
|
|
463
898
|
const expression = heritageClause?.types[0]?.expression;
|
|
464
899
|
return !!expression && expression.getText(sourceFile).startsWith("enumOf(");
|
|
465
900
|
}
|
|
466
901
|
function getExportedClassNames(sourceFile) {
|
|
467
|
-
return sourceFile.statements.filter((statement) =>
|
|
902
|
+
return sourceFile.statements.filter((statement) => ts3.isClassDeclaration(statement) && !!statement.name).filter((statement) => isExported(statement)).map((statement) => statement.name.text);
|
|
468
903
|
}
|
|
469
904
|
function isComponentDeclarationFile(file) {
|
|
470
905
|
if (!file.endsWith(".tsx"))
|
|
@@ -480,7 +915,7 @@ function isComponentDeclarationFile(file) {
|
|
|
480
915
|
function getComponentFileDeclarations(sourceFile) {
|
|
481
916
|
const reExportedNames = new Set;
|
|
482
917
|
for (const statement of sourceFile.statements) {
|
|
483
|
-
if (
|
|
918
|
+
if (ts3.isExportDeclaration(statement) && statement.exportClause && ts3.isNamedExports(statement.exportClause)) {
|
|
484
919
|
for (const element of statement.exportClause.elements)
|
|
485
920
|
reExportedNames.add((element.propertyName ?? element.name).text);
|
|
486
921
|
}
|
|
@@ -490,19 +925,19 @@ function getComponentFileDeclarations(sourceFile) {
|
|
|
490
925
|
const isDefaultExport = isDefaultExportStatement(statement);
|
|
491
926
|
const inlineExported = isExported(statement);
|
|
492
927
|
const add = (name, kind, line) => declarations.push({ name, kind, line, exported: inlineExported || reExportedNames.has(name), isDefaultExport });
|
|
493
|
-
if (
|
|
928
|
+
if (ts3.isInterfaceDeclaration(statement))
|
|
494
929
|
add(statement.name.text, "interface", getLine(sourceFile, statement));
|
|
495
|
-
else if (
|
|
930
|
+
else if (ts3.isTypeAliasDeclaration(statement))
|
|
496
931
|
add(statement.name.text, "type", getLine(sourceFile, statement));
|
|
497
|
-
else if (
|
|
932
|
+
else if (ts3.isEnumDeclaration(statement))
|
|
498
933
|
add(statement.name.text, "enum", getLine(sourceFile, statement));
|
|
499
|
-
else if (
|
|
934
|
+
else if (ts3.isFunctionDeclaration(statement) && statement.name)
|
|
500
935
|
add(statement.name.text, "function", getLine(sourceFile, statement));
|
|
501
|
-
else if (
|
|
936
|
+
else if (ts3.isClassDeclaration(statement) && statement.name)
|
|
502
937
|
add(statement.name.text, "class", getLine(sourceFile, statement));
|
|
503
|
-
else if (
|
|
938
|
+
else if (ts3.isVariableStatement(statement)) {
|
|
504
939
|
for (const declaration of statement.declarationList.declarations) {
|
|
505
|
-
if (!
|
|
940
|
+
if (!ts3.isIdentifier(declaration.name))
|
|
506
941
|
continue;
|
|
507
942
|
const kind = isFunctionLikeInitializer(declaration.initializer) ? "function" : "variable";
|
|
508
943
|
add(declaration.name.text, kind, getLine(sourceFile, declaration));
|
|
@@ -514,15 +949,15 @@ function getComponentFileDeclarations(sourceFile) {
|
|
|
514
949
|
function getCompoundComponentNames(sourceFile) {
|
|
515
950
|
const names = new Set;
|
|
516
951
|
for (const statement of sourceFile.statements) {
|
|
517
|
-
if (!
|
|
952
|
+
if (!ts3.isExpressionStatement(statement))
|
|
518
953
|
continue;
|
|
519
954
|
const { expression } = statement;
|
|
520
|
-
if (!
|
|
955
|
+
if (!ts3.isBinaryExpression(expression) || expression.operatorToken.kind !== ts3.SyntaxKind.EqualsToken)
|
|
521
956
|
continue;
|
|
522
|
-
if (!
|
|
957
|
+
if (!ts3.isPropertyAccessExpression(expression.left) || !isPascalCaseName(expression.left.name.text))
|
|
523
958
|
continue;
|
|
524
959
|
names.add(expression.left.name.text);
|
|
525
|
-
if (
|
|
960
|
+
if (ts3.isIdentifier(expression.right) && isPascalCaseName(expression.right.text))
|
|
526
961
|
names.add(expression.right.text);
|
|
527
962
|
}
|
|
528
963
|
return names;
|
|
@@ -536,15 +971,15 @@ function isRestrictedInternalKind(kind) {
|
|
|
536
971
|
function isAllowedComponentExport(declaration, isPage) {
|
|
537
972
|
if (isComponentValueKind(declaration.kind) && isPascalCaseName(declaration.name))
|
|
538
973
|
return true;
|
|
539
|
-
return isPage &&
|
|
974
|
+
return isPage && RESERVED_ROUTE_CONFIG_EXPORTS.has(declaration.name);
|
|
540
975
|
}
|
|
541
976
|
function isPascalCaseName(name) {
|
|
542
977
|
return /^[A-Z]/.test(name) && !/^[A-Z0-9_]+$/.test(name);
|
|
543
978
|
}
|
|
544
979
|
function isDefaultExportStatement(statement) {
|
|
545
|
-
if (
|
|
980
|
+
if (ts3.isExportAssignment(statement))
|
|
546
981
|
return !statement.isExportEquals;
|
|
547
|
-
return !!(
|
|
982
|
+
return !!(ts3.getCombinedModifierFlags(statement) & ts3.ModifierFlags.Default);
|
|
548
983
|
}
|
|
549
984
|
function getPlaceholderExportWarnings(sourceFile) {
|
|
550
985
|
if (!sourceFile.file.endsWith("/index.ts") && !sourceFile.file.endsWith("/index.tsx"))
|
|
@@ -574,7 +1009,7 @@ function getDictionaryTextWarnings(sourceFile) {
|
|
|
574
1009
|
function getGlobalMutationWarnings(sourceFile) {
|
|
575
1010
|
const warnings = [];
|
|
576
1011
|
for (const statement of sourceFile.sourceFile.statements) {
|
|
577
|
-
if (
|
|
1012
|
+
if (ts3.isModuleDeclaration(statement) && statement.name.getText(sourceFile.sourceFile) === "global") {
|
|
578
1013
|
warnings.push({
|
|
579
1014
|
rule: "akan.file.global-declaration",
|
|
580
1015
|
scope: "file",
|
|
@@ -584,7 +1019,7 @@ function getGlobalMutationWarnings(sourceFile) {
|
|
|
584
1019
|
message: "Global declarations require an explicit low-level integration allowlist."
|
|
585
1020
|
});
|
|
586
1021
|
}
|
|
587
|
-
if (
|
|
1022
|
+
if (ts3.isInterfaceDeclaration(statement) && statement.name.text === "Window") {
|
|
588
1023
|
warnings.push({
|
|
589
1024
|
rule: "akan.file.window-augmentation",
|
|
590
1025
|
scope: "file",
|
|
@@ -594,7 +1029,7 @@ function getGlobalMutationWarnings(sourceFile) {
|
|
|
594
1029
|
message: "Window augmentation should be isolated to approved browser integration files."
|
|
595
1030
|
});
|
|
596
1031
|
}
|
|
597
|
-
if (
|
|
1032
|
+
if (ts3.isExpressionStatement(statement) && statement.expression.getText(sourceFile.sourceFile).includes(".prototype.")) {
|
|
598
1033
|
warnings.push({
|
|
599
1034
|
rule: "akan.file.prototype-mutation",
|
|
600
1035
|
scope: "file",
|
|
@@ -612,23 +1047,23 @@ function getTopLevelDeclarations(sourceFile) {
|
|
|
612
1047
|
}
|
|
613
1048
|
function getTopLevelDeclaration(sourceFile, statement) {
|
|
614
1049
|
const line = getLine(sourceFile, statement);
|
|
615
|
-
if (
|
|
1050
|
+
if (ts3.isClassDeclaration(statement) && statement.name) {
|
|
616
1051
|
return [{ name: statement.name.text, kind: "class", line, exported: isExported(statement), node: statement }];
|
|
617
1052
|
}
|
|
618
|
-
if (
|
|
1053
|
+
if (ts3.isFunctionDeclaration(statement) && statement.name) {
|
|
619
1054
|
return [{ name: statement.name.text, kind: "function", line, exported: isExported(statement), node: statement }];
|
|
620
1055
|
}
|
|
621
|
-
if (
|
|
1056
|
+
if (ts3.isInterfaceDeclaration(statement)) {
|
|
622
1057
|
return [{ name: statement.name.text, kind: "interface", line, exported: isExported(statement), node: statement }];
|
|
623
1058
|
}
|
|
624
|
-
if (
|
|
1059
|
+
if (ts3.isTypeAliasDeclaration(statement)) {
|
|
625
1060
|
return [{ name: statement.name.text, kind: "type", line, exported: isExported(statement), node: statement }];
|
|
626
1061
|
}
|
|
627
|
-
if (
|
|
1062
|
+
if (ts3.isEnumDeclaration(statement)) {
|
|
628
1063
|
return [{ name: statement.name.text, kind: "enum", line, exported: isExported(statement), node: statement }];
|
|
629
1064
|
}
|
|
630
|
-
if (
|
|
631
|
-
return statement.declarationList.declarations.filter((declaration) =>
|
|
1065
|
+
if (ts3.isVariableStatement(statement)) {
|
|
1066
|
+
return statement.declarationList.declarations.filter((declaration) => ts3.isIdentifier(declaration.name)).map((declaration) => ({
|
|
632
1067
|
name: declaration.name.text,
|
|
633
1068
|
kind: "variable",
|
|
634
1069
|
line: getLine(sourceFile, declaration),
|
|
@@ -636,7 +1071,7 @@ function getTopLevelDeclaration(sourceFile, statement) {
|
|
|
636
1071
|
node: statement
|
|
637
1072
|
}));
|
|
638
1073
|
}
|
|
639
|
-
if (
|
|
1074
|
+
if (ts3.isExportDeclaration(statement)) {
|
|
640
1075
|
return [{ name: "export declaration", kind: "export", line, exported: true, node: statement }];
|
|
641
1076
|
}
|
|
642
1077
|
return [];
|
|
@@ -661,9 +1096,9 @@ function isAllowedConstantDeclaration(modelName, declaration) {
|
|
|
661
1096
|
return false;
|
|
662
1097
|
if ([`${modelName}Input`, `${modelName}Object`, modelName, `Light${modelName}`, `${modelName}Insight`].includes(declaration.name))
|
|
663
1098
|
return true;
|
|
664
|
-
if (!
|
|
1099
|
+
if (!ts3.isClassDeclaration(declaration.node))
|
|
665
1100
|
return false;
|
|
666
|
-
const heritageClause = declaration.node.heritageClauses?.find((clause) => clause.token ===
|
|
1101
|
+
const heritageClause = declaration.node.heritageClauses?.find((clause) => clause.token === ts3.SyntaxKind.ExtendsKeyword);
|
|
667
1102
|
const expression = heritageClause?.types[0]?.expression;
|
|
668
1103
|
return !!expression && expression.getText().startsWith("enumOf(");
|
|
669
1104
|
}
|
|
@@ -729,13 +1164,13 @@ function getModuleInfo(file) {
|
|
|
729
1164
|
return { moduleName, fileName, kind: "database" };
|
|
730
1165
|
}
|
|
731
1166
|
function isExportedConst(declaration) {
|
|
732
|
-
return declaration.exported &&
|
|
1167
|
+
return declaration.exported && ts3.isVariableStatement(declaration.node) && (declaration.node.declarationList.flags & ts3.NodeFlags.Const) !== 0;
|
|
733
1168
|
}
|
|
734
1169
|
function isExported(node) {
|
|
735
|
-
return !!
|
|
1170
|
+
return !!ts3.getCombinedModifierFlags(node) && !!(ts3.getCombinedModifierFlags(node) & ts3.ModifierFlags.Export);
|
|
736
1171
|
}
|
|
737
1172
|
function isFunctionLikeInitializer(node) {
|
|
738
|
-
return !!node && (
|
|
1173
|
+
return !!node && (ts3.isArrowFunction(node) || ts3.isFunctionExpression(node));
|
|
739
1174
|
}
|
|
740
1175
|
function getBodyFingerprint(sourceFile, node) {
|
|
741
1176
|
if (!node)
|
|
@@ -757,7 +1192,7 @@ async function isDirectory(absolutePath) {
|
|
|
757
1192
|
}
|
|
758
1193
|
}
|
|
759
1194
|
function getScriptKind(file) {
|
|
760
|
-
return file.endsWith(".tsx") ?
|
|
1195
|
+
return file.endsWith(".tsx") ? ts3.ScriptKind.TSX : ts3.ScriptKind.TS;
|
|
761
1196
|
}
|
|
762
1197
|
function getLine(sourceFile, node) {
|
|
763
1198
|
return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
|
|
@@ -810,6 +1245,13 @@ class QualityScript extends script("quality", [QualityRunner]) {
|
|
|
810
1245
|
spinner.succeed(`Quality scan completed (${result.scannedFiles} files, ${result.warnings.length} warnings)`);
|
|
811
1246
|
Logger.rawLog(format === "json" ? JSON.stringify(result, null, 2) : formatQualityScanResult(result));
|
|
812
1247
|
}
|
|
1248
|
+
async ssr(workspace, format = "text") {
|
|
1249
|
+
const spinner = workspace.spinning("Measuring Akan server/client render balance...");
|
|
1250
|
+
const scanned = await this.qualityRunner.scan(workspace);
|
|
1251
|
+
const result = { ...scanned, warnings: scanned.warnings.filter((warning) => warning.scope === "ssr") };
|
|
1252
|
+
spinner.succeed(`SSR scan completed (${result.scannedFiles} files, ${result.warnings.length} warnings)`);
|
|
1253
|
+
Logger.rawLog(format === "json" ? JSON.stringify(result, null, 2) : formatSsrScanResult(result));
|
|
1254
|
+
}
|
|
813
1255
|
}
|
|
814
1256
|
|
|
815
1257
|
// pkgs/@akanjs/cli/quality/quality.command.ts
|
|
@@ -817,15 +1259,18 @@ class QualityCommand extends command("quality", [QualityScript], ({ public: targ
|
|
|
817
1259
|
quality: target({ desc: "Scan apps and libs for Akan code quality warnings" }).arg("action", String, {
|
|
818
1260
|
desc: "quality action",
|
|
819
1261
|
default: "scan",
|
|
820
|
-
enum: ["scan"]
|
|
1262
|
+
enum: ["scan", "ssr"]
|
|
821
1263
|
}).option("format", String, {
|
|
822
1264
|
desc: "output format",
|
|
823
1265
|
default: "text",
|
|
824
1266
|
enum: ["text", "json"]
|
|
825
1267
|
}).with(Workspace).exec(async function(action, format, workspace) {
|
|
826
|
-
if (action
|
|
827
|
-
|
|
828
|
-
|
|
1268
|
+
if (action === "scan")
|
|
1269
|
+
await this.qualityScript.scan(workspace, format);
|
|
1270
|
+
else if (action === "ssr")
|
|
1271
|
+
await this.qualityScript.ssr(workspace, format);
|
|
1272
|
+
else
|
|
1273
|
+
throw new Error(`Unknown quality action: ${action}. Use "scan" or "ssr".`);
|
|
829
1274
|
})
|
|
830
1275
|
})) {
|
|
831
1276
|
}
|