@akanjs/devkit 3.0.0-alpha.4 → 3.0.0-alpha.6
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/akanContext.ts +3 -31
- package/applicationBuildRunner.ts +1 -1
- package/frontendBuild/allRoutesBuilder.ts +27 -8
- package/frontendBuild/cssCandidateCache.test.ts +11 -0
- package/frontendBuild/cssCandidateCache.ts +9 -2
- 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/package.json +2 -2
- package/qualityScanner.test.ts +154 -1
- package/qualityScanner.ts +44 -27
- 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 +61 -0
package/ssrScanner.ts
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import type { QualityWarning, SourceFileInfo } from "./qualityScanner";
|
|
3
|
+
|
|
4
|
+
/** Server/client render split for one app or lib, measured in JSX elements rather than files. */
|
|
5
|
+
export interface SsrBalanceEntry {
|
|
6
|
+
scope: string;
|
|
7
|
+
serverMass: number;
|
|
8
|
+
clientMass: number;
|
|
9
|
+
serverShare: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface SsrScanResult {
|
|
13
|
+
warnings: QualityWarning[];
|
|
14
|
+
balance: SsrBalanceEntry[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface ComponentInfo {
|
|
18
|
+
name: string;
|
|
19
|
+
line: number;
|
|
20
|
+
mass: number;
|
|
21
|
+
touches: string[];
|
|
22
|
+
vendorTags: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// A component with no client-only touch at all never needed the client bundle, so even a small subtree is
|
|
26
|
+
// worth moving. A mostly-static component keeps its interaction and hands the static part to the server, so
|
|
27
|
+
// it only pays off once the static subtree is large enough to matter.
|
|
28
|
+
const STATIC_COMPONENT_MIN_MASS = 4;
|
|
29
|
+
const MIXED_COMPONENT_MIN_MASS = 10;
|
|
30
|
+
const MIXED_COMPONENT_MAX_TOUCHES = 2;
|
|
31
|
+
const MODULE_SERVER_VIEW_MIN_CLIENT_MASS = 12;
|
|
32
|
+
|
|
33
|
+
export class SsrScanner {
|
|
34
|
+
// `usePage` and `getSelf` read request-scoped server context and are legal in server components, so they
|
|
35
|
+
// must not count as evidence that a file needs "use client".
|
|
36
|
+
static #serverSafeCalls = new Set(["usePage", "getSelf", "useServer"]);
|
|
37
|
+
static #clientGlobals = new Set([
|
|
38
|
+
"window",
|
|
39
|
+
"document",
|
|
40
|
+
"navigator",
|
|
41
|
+
"localStorage",
|
|
42
|
+
"sessionStorage",
|
|
43
|
+
"location",
|
|
44
|
+
"history",
|
|
45
|
+
"screen",
|
|
46
|
+
"matchMedia",
|
|
47
|
+
"IntersectionObserver",
|
|
48
|
+
"ResizeObserver",
|
|
49
|
+
"MutationObserver",
|
|
50
|
+
"requestAnimationFrame",
|
|
51
|
+
"WebSocket",
|
|
52
|
+
]);
|
|
53
|
+
// Runtime singletons that only exist in the client bundle; importing either is what forces the directive.
|
|
54
|
+
static #clientRuntimeImports = new Set(["st", "fetch"]);
|
|
55
|
+
|
|
56
|
+
scan(sourceFiles: SourceFileInfo[]): SsrScanResult {
|
|
57
|
+
const componentFiles = sourceFiles.filter((sourceFile) => this.#isBalancedFile(sourceFile.file));
|
|
58
|
+
return {
|
|
59
|
+
warnings: [
|
|
60
|
+
...componentFiles.flatMap((sourceFile) => this.#scanFile(sourceFile)),
|
|
61
|
+
...this.#scanModules(componentFiles),
|
|
62
|
+
],
|
|
63
|
+
balance: this.#measureBalance(componentFiles),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
#scanFile(sourceFile: SourceFileInfo): QualityWarning[] {
|
|
68
|
+
if (!this.#hasUseClient(sourceFile.sourceFile)) return [];
|
|
69
|
+
const vendorNames = this.#getVendorNames(sourceFile.sourceFile);
|
|
70
|
+
const hasVendorImport = this.#hasVendorImport(sourceFile.sourceFile);
|
|
71
|
+
const importsClientRuntime = this.#importsClientRuntime(sourceFile.sourceFile);
|
|
72
|
+
const components = this.#getComponents(sourceFile, vendorNames);
|
|
73
|
+
const warnings: QualityWarning[] = [];
|
|
74
|
+
|
|
75
|
+
if (
|
|
76
|
+
!hasVendorImport &&
|
|
77
|
+
!importsClientRuntime &&
|
|
78
|
+
this.#getTouches(sourceFile.sourceFile, sourceFile.sourceFile).length === 0 &&
|
|
79
|
+
!this.#isConventionClientFile(sourceFile.file)
|
|
80
|
+
) {
|
|
81
|
+
warnings.push({
|
|
82
|
+
rule: "akan.ssr.unnecessary-use-client",
|
|
83
|
+
scope: "ssr",
|
|
84
|
+
severity: "warning",
|
|
85
|
+
file: sourceFile.file,
|
|
86
|
+
line: 1,
|
|
87
|
+
message: `"use client" is declared but the file uses no client-only capability (hook, event handler, store, or browser API).`,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
for (const component of components) {
|
|
92
|
+
if (component.vendorTags) continue;
|
|
93
|
+
if (component.touches.length === 0 && component.mass >= STATIC_COMPONENT_MIN_MASS) {
|
|
94
|
+
warnings.push({
|
|
95
|
+
rule: "akan.ssr.client-static-component",
|
|
96
|
+
scope: "ssr",
|
|
97
|
+
severity: "warning",
|
|
98
|
+
file: sourceFile.file,
|
|
99
|
+
line: component.line,
|
|
100
|
+
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.`,
|
|
101
|
+
});
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (
|
|
105
|
+
component.touches.length >= 1 &&
|
|
106
|
+
component.touches.length <= MIXED_COMPONENT_MAX_TOUCHES &&
|
|
107
|
+
component.mass >= MIXED_COMPONENT_MIN_MASS
|
|
108
|
+
) {
|
|
109
|
+
warnings.push({
|
|
110
|
+
rule: "akan.ssr.client-static-markup",
|
|
111
|
+
scope: "ssr",
|
|
112
|
+
severity: "warning",
|
|
113
|
+
file: sourceFile.file,
|
|
114
|
+
line: component.line,
|
|
115
|
+
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.`,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
warnings.push(...this.#getMountLoadWarnings(sourceFile));
|
|
121
|
+
warnings.push(...this.#getTemplateStateWarnings(sourceFile));
|
|
122
|
+
return warnings;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// A database module whose rendering happens entirely in Template/Zone/Util has no server-rendered surface at
|
|
126
|
+
// all, so every consumer pays for hydration even when it only needs to display the model.
|
|
127
|
+
#scanModules(sourceFiles: SourceFileInfo[]): QualityWarning[] {
|
|
128
|
+
const modules = new Map<string, { clientMass: number; serverFiles: number; line: string }>();
|
|
129
|
+
for (const sourceFile of sourceFiles) {
|
|
130
|
+
const moduleDir = this.#getModuleDir(sourceFile.file);
|
|
131
|
+
if (!moduleDir) continue;
|
|
132
|
+
const entry = modules.get(moduleDir) ?? { clientMass: 0, serverFiles: 0, line: sourceFile.file };
|
|
133
|
+
if (this.#hasUseClient(sourceFile.sourceFile)) entry.clientMass += this.#getMass(sourceFile.sourceFile);
|
|
134
|
+
else if (/\.(Unit|View)\.tsx$/.test(sourceFile.file)) entry.serverFiles += 1;
|
|
135
|
+
modules.set(moduleDir, entry);
|
|
136
|
+
}
|
|
137
|
+
return [...modules]
|
|
138
|
+
.filter(([, entry]) => entry.serverFiles === 0 && entry.clientMass >= MODULE_SERVER_VIEW_MIN_CLIENT_MASS)
|
|
139
|
+
.map(([moduleDir, entry]) => ({
|
|
140
|
+
rule: "akan.ssr.module-missing-server-view",
|
|
141
|
+
scope: "ssr" as const,
|
|
142
|
+
severity: "warning" as const,
|
|
143
|
+
file: entry.line,
|
|
144
|
+
message: `Module "${moduleDir}" renders ${entry.clientMass} JSX elements from client files only; it declares no Unit or View server component.`,
|
|
145
|
+
}));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// A load fired from a mount-only effect is data the route already could have fetched: the client renders an
|
|
149
|
+
// empty shell, hydrates, then fetches. A reactive effect (non-empty deps) responds to client state instead
|
|
150
|
+
// and has no server-side equivalent, so only the empty-dependency form is a finding.
|
|
151
|
+
#getMountLoadWarnings(sourceFile: SourceFileInfo): QualityWarning[] {
|
|
152
|
+
const warnings: QualityWarning[] = [];
|
|
153
|
+
const visit = (node: ts.Node) => {
|
|
154
|
+
if (this.#isMountEffect(sourceFile.sourceFile, node)) {
|
|
155
|
+
for (const load of this.#getLoadCalls(sourceFile.sourceFile, node)) {
|
|
156
|
+
warnings.push({
|
|
157
|
+
rule: "akan.ssr.client-mount-load",
|
|
158
|
+
scope: "ssr",
|
|
159
|
+
severity: "warning",
|
|
160
|
+
file: sourceFile.file,
|
|
161
|
+
line: this.#getLine(sourceFile.sourceFile, load.node),
|
|
162
|
+
message: `Mount-only effect loads server data with ${load.callee}(). The route can fetch this before the first byte instead.`,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
ts.forEachChild(node, visit);
|
|
167
|
+
};
|
|
168
|
+
ts.forEachChild(sourceFile.sourceFile, visit);
|
|
169
|
+
return warnings;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
#isMountEffect(sourceFile: ts.SourceFile, node: ts.Node) {
|
|
173
|
+
if (!ts.isCallExpression(node)) return false;
|
|
174
|
+
const callee = node.expression.getText(sourceFile);
|
|
175
|
+
if (callee !== "useEffect" && callee !== "useLayoutEffect") return false;
|
|
176
|
+
const deps = node.arguments[1];
|
|
177
|
+
return !!deps && ts.isArrayLiteralExpression(deps) && deps.elements.length === 0;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
#getLoadCalls(sourceFile: ts.SourceFile, node: ts.Node) {
|
|
181
|
+
const calls: Array<{ callee: string; node: ts.Node }> = [];
|
|
182
|
+
const visit = (child: ts.Node) => {
|
|
183
|
+
if (ts.isCallExpression(child)) {
|
|
184
|
+
const callee = child.expression.getText(sourceFile);
|
|
185
|
+
if (/^fetch\.[a-z]/.test(callee) || /^st\.do\.(init|get|view|load|list|count|insight)[A-Z]/.test(callee))
|
|
186
|
+
calls.push({ callee, node: child });
|
|
187
|
+
}
|
|
188
|
+
ts.forEachChild(child, visit);
|
|
189
|
+
};
|
|
190
|
+
ts.forEachChild(node, visit);
|
|
191
|
+
return calls;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
#getTemplateStateWarnings(sourceFile: SourceFileInfo): QualityWarning[] {
|
|
195
|
+
if (!sourceFile.file.endsWith(".Template.tsx")) return [];
|
|
196
|
+
const warnings: QualityWarning[] = [];
|
|
197
|
+
const visit = (node: ts.Node) => {
|
|
198
|
+
if (ts.isCallExpression(node) && node.expression.getText(sourceFile.sourceFile) === "useState") {
|
|
199
|
+
warnings.push({
|
|
200
|
+
rule: "akan.ssr.template-client-state",
|
|
201
|
+
scope: "ssr",
|
|
202
|
+
severity: "warning",
|
|
203
|
+
file: sourceFile.file,
|
|
204
|
+
line: this.#getLine(sourceFile.sourceFile, node),
|
|
205
|
+
message: "Template holds form state in useState. Templates are store-driven and carry no local state.",
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
ts.forEachChild(node, visit);
|
|
209
|
+
};
|
|
210
|
+
ts.forEachChild(sourceFile.sourceFile, visit);
|
|
211
|
+
return warnings;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
#measureBalance(sourceFiles: SourceFileInfo[]): SsrBalanceEntry[] {
|
|
215
|
+
const scopes = new Map<string, { serverMass: number; clientMass: number }>();
|
|
216
|
+
for (const sourceFile of sourceFiles) {
|
|
217
|
+
const segments = sourceFile.file.split("/");
|
|
218
|
+
const scope = `${segments[0]}/${segments[1]}`;
|
|
219
|
+
const entry = scopes.get(scope) ?? { serverMass: 0, clientMass: 0 };
|
|
220
|
+
const mass = this.#getMass(sourceFile.sourceFile);
|
|
221
|
+
if (this.#hasUseClient(sourceFile.sourceFile)) entry.clientMass += mass;
|
|
222
|
+
else entry.serverMass += mass;
|
|
223
|
+
scopes.set(scope, entry);
|
|
224
|
+
}
|
|
225
|
+
const entries = [...scopes]
|
|
226
|
+
.map(([scope, mass]) => ({ scope, ...mass, serverShare: getShare(mass.serverMass, mass.clientMass) }))
|
|
227
|
+
.sort((a, b) => a.scope.localeCompare(b.scope));
|
|
228
|
+
if (entries.length < 2) return entries;
|
|
229
|
+
const serverMass = entries.reduce((sum, entry) => sum + entry.serverMass, 0);
|
|
230
|
+
const clientMass = entries.reduce((sum, entry) => sum + entry.clientMass, 0);
|
|
231
|
+
return [...entries, { scope: "workspace", serverMass, clientMass, serverShare: getShare(serverMass, clientMass) }];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
#getComponents(sourceFile: SourceFileInfo, vendorNames: Set<string>): ComponentInfo[] {
|
|
235
|
+
const components: ComponentInfo[] = [];
|
|
236
|
+
for (const statement of sourceFile.sourceFile.statements) {
|
|
237
|
+
for (const { name, node } of this.#getComponentNodes(statement)) {
|
|
238
|
+
if (!/^[A-Z]/.test(name)) continue;
|
|
239
|
+
components.push({
|
|
240
|
+
name,
|
|
241
|
+
line: this.#getLine(sourceFile.sourceFile, node),
|
|
242
|
+
mass: this.#getMass(node),
|
|
243
|
+
touches: this.#getTouches(sourceFile.sourceFile, node),
|
|
244
|
+
vendorTags: [...this.#getTagNames(sourceFile.sourceFile, node)].some((tag) => vendorNames.has(tag)),
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return components;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
#getComponentNodes(statement: ts.Statement): Array<{ name: string; node: ts.Node }> {
|
|
252
|
+
if (ts.isFunctionDeclaration(statement) && statement.body)
|
|
253
|
+
return [{ name: statement.name?.text ?? "default", node: statement.body }];
|
|
254
|
+
if (!ts.isVariableStatement(statement)) return [];
|
|
255
|
+
return statement.declarationList.declarations.flatMap((declaration) => {
|
|
256
|
+
if (!ts.isIdentifier(declaration.name) || !declaration.initializer) return [];
|
|
257
|
+
if (!ts.isArrowFunction(declaration.initializer) && !ts.isFunctionExpression(declaration.initializer)) return [];
|
|
258
|
+
return [{ name: declaration.name.text, node: declaration.initializer }];
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
#getTouches(sourceFile: ts.SourceFile, node: ts.Node): string[] {
|
|
263
|
+
const touches: string[] = [];
|
|
264
|
+
const visit = (child: ts.Node) => {
|
|
265
|
+
if (ts.isCallExpression(child)) {
|
|
266
|
+
const callee = child.expression.getText(sourceFile);
|
|
267
|
+
const bareName = callee.split(".").pop() ?? callee;
|
|
268
|
+
if (callee === "createContext" || callee === "lazy") touches.push(callee);
|
|
269
|
+
else if (/^use[A-Z]/.test(bareName) && !SsrScanner.#serverSafeCalls.has(bareName)) touches.push(bareName);
|
|
270
|
+
}
|
|
271
|
+
if (ts.isJsxAttribute(child) && /^on[A-Z]/.test(child.name.getText(sourceFile)))
|
|
272
|
+
touches.push(child.name.getText(sourceFile));
|
|
273
|
+
if (ts.isPropertyAccessExpression(child)) {
|
|
274
|
+
const root = getAccessRoot(child);
|
|
275
|
+
if (root === "st") touches.push("st");
|
|
276
|
+
else if (SsrScanner.#clientGlobals.has(root)) touches.push(root);
|
|
277
|
+
}
|
|
278
|
+
ts.forEachChild(child, visit);
|
|
279
|
+
};
|
|
280
|
+
ts.forEachChild(node, visit);
|
|
281
|
+
return touches;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
#getTagNames(sourceFile: ts.SourceFile, node: ts.Node): Set<string> {
|
|
285
|
+
const tags = new Set<string>();
|
|
286
|
+
const visit = (child: ts.Node) => {
|
|
287
|
+
if (ts.isJsxOpeningElement(child) || ts.isJsxSelfClosingElement(child))
|
|
288
|
+
tags.add(child.tagName.getText(sourceFile).split(".")[0]);
|
|
289
|
+
ts.forEachChild(child, visit);
|
|
290
|
+
};
|
|
291
|
+
ts.forEachChild(node, visit);
|
|
292
|
+
return tags;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
#getMass(node: ts.Node) {
|
|
296
|
+
let mass = 0;
|
|
297
|
+
const visit = (child: ts.Node) => {
|
|
298
|
+
if (ts.isJsxOpeningElement(child) || ts.isJsxSelfClosingElement(child)) mass += 1;
|
|
299
|
+
ts.forEachChild(child, visit);
|
|
300
|
+
};
|
|
301
|
+
ts.forEachChild(node, visit);
|
|
302
|
+
return mass;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
#hasUseClient(sourceFile: ts.SourceFile) {
|
|
306
|
+
const first = sourceFile.statements[0];
|
|
307
|
+
if (!first || !ts.isExpressionStatement(first) || !ts.isStringLiteral(first.expression)) return false;
|
|
308
|
+
return first.expression.text === "use client";
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// A bare specifier is a third-party package: it may be client-only, which is a legitimate reason for the
|
|
312
|
+
// directive that no amount of AST reading can rule out.
|
|
313
|
+
#hasVendorImport(sourceFile: ts.SourceFile) {
|
|
314
|
+
return sourceFile.statements.some(
|
|
315
|
+
(statement) => ts.isImportDeclaration(statement) && isVendorSpecifier(getSpecifier(statement)),
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
#getVendorNames(sourceFile: ts.SourceFile) {
|
|
320
|
+
const names = new Set<string>();
|
|
321
|
+
for (const statement of sourceFile.statements) {
|
|
322
|
+
if (!ts.isImportDeclaration(statement) || !isVendorSpecifier(getSpecifier(statement))) continue;
|
|
323
|
+
const clause = statement.importClause;
|
|
324
|
+
if (clause?.name) names.add(clause.name.text);
|
|
325
|
+
if (clause?.namedBindings && ts.isNamespaceImport(clause.namedBindings))
|
|
326
|
+
names.add(clause.namedBindings.name.text);
|
|
327
|
+
if (clause?.namedBindings && ts.isNamedImports(clause.namedBindings))
|
|
328
|
+
for (const element of clause.namedBindings.elements) names.add(element.name.text);
|
|
329
|
+
}
|
|
330
|
+
return names;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
#importsClientRuntime(sourceFile: ts.SourceFile) {
|
|
334
|
+
for (const statement of sourceFile.statements) {
|
|
335
|
+
if (!ts.isImportDeclaration(statement)) continue;
|
|
336
|
+
const bindings = statement.importClause?.namedBindings;
|
|
337
|
+
if (!bindings || !ts.isNamedImports(bindings)) continue;
|
|
338
|
+
if (bindings.elements.some((element) => SsrScanner.#clientRuntimeImports.has(element.name.text))) return true;
|
|
339
|
+
}
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Zone/Template/Util carry the directive mechanically by file role, and `index_.tsx` is the declared
|
|
344
|
+
// "use client" + lazy() boundary. In neither case is the directive a stray — for module UI it means markup
|
|
345
|
+
// belongs in a Unit or View instead, which the component rules already cover.
|
|
346
|
+
#isConventionClientFile(file: string) {
|
|
347
|
+
if (file.endsWith("/index_.tsx")) return true;
|
|
348
|
+
return /\.(Zone|Template|Util)\.tsx$/.test(file) && this.#getModuleDir(file) !== null;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
#isBalancedFile(file: string) {
|
|
352
|
+
if (!file.endsWith(".tsx") || file.endsWith(".test.tsx") || file.endsWith(".spec.tsx")) return false;
|
|
353
|
+
const segments = file.split("/");
|
|
354
|
+
if (segments[0] !== "apps" && segments[0] !== "libs") return false;
|
|
355
|
+
return segments[2] === "ui" || segments[2] === "lib";
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
#getModuleDir(file: string) {
|
|
359
|
+
const segments = file.split("/");
|
|
360
|
+
const libIndex = segments.indexOf("lib");
|
|
361
|
+
if (libIndex < 1 || segments.length <= libIndex + 2) return null;
|
|
362
|
+
const moduleName = segments[libIndex + 1];
|
|
363
|
+
if (moduleName.startsWith("_")) return null;
|
|
364
|
+
return segments.slice(0, libIndex + 2).join("/");
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
#getLine(sourceFile: ts.SourceFile, node: ts.Node) {
|
|
368
|
+
return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Share of component rendering an app or lib should keep on the server before the split needs a reason. */
|
|
373
|
+
export const SSR_SERVER_SHARE_TARGET = 0.5;
|
|
374
|
+
|
|
375
|
+
export function formatSsrBalance(balance: SsrBalanceEntry[]) {
|
|
376
|
+
if (balance.length === 0) return ["No component files found."];
|
|
377
|
+
return balance.map((entry) => {
|
|
378
|
+
const total = entry.serverMass + entry.clientMass;
|
|
379
|
+
const share = `${Math.round(entry.serverShare * 100)}% server`;
|
|
380
|
+
const counts = `${entry.serverMass} of ${total} JSX elements, ${entry.clientMass} client`;
|
|
381
|
+
const flag =
|
|
382
|
+
entry.serverShare < SSR_SERVER_SHARE_TARGET
|
|
383
|
+
? ` <- below the ${Math.round(SSR_SERVER_SHARE_TARGET * 100)}% target`
|
|
384
|
+
: "";
|
|
385
|
+
return ` ${entry.scope}: ${share} (${counts})${flag}`;
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function getShare(serverMass: number, clientMass: number) {
|
|
390
|
+
const total = serverMass + clientMass;
|
|
391
|
+
return total === 0 ? 1 : serverMass / total;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function getSpecifier(statement: ts.ImportDeclaration) {
|
|
395
|
+
return ts.isStringLiteral(statement.moduleSpecifier) ? statement.moduleSpecifier.text : "";
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function isVendorSpecifier(specifier: string) {
|
|
399
|
+
if (specifier === "" || specifier.startsWith(".") || specifier.startsWith("/")) return false;
|
|
400
|
+
if (specifier === "react" || specifier === "react-dom" || specifier.startsWith("react/")) return false;
|
|
401
|
+
if (specifier.startsWith("node:")) return false;
|
|
402
|
+
return !/^(akanjs|@akanjs|@libs|@apps|@contract)(\/|$)/.test(specifier);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function getAccessRoot(node: ts.PropertyAccessExpression) {
|
|
406
|
+
let current: ts.Expression = node;
|
|
407
|
+
while (ts.isPropertyAccessExpression(current)) current = current.expression;
|
|
408
|
+
return ts.isIdentifier(current) ? current.text : "";
|
|
409
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { appRootAllowedDirs, appRootAllowedFiles, isScannedAppRootEntry } from "./workspaceLayout";
|
|
3
|
+
|
|
4
|
+
describe("app root layout allowlist", () => {
|
|
5
|
+
test("admits the scoped agent guides sync writes into every app", () => {
|
|
6
|
+
expect(appRootAllowedFiles.has("AGENTS.md")).toBe(true);
|
|
7
|
+
expect(appRootAllowedFiles.has("CLAUDE.md")).toBe(true);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("admits every documented app root folder", () => {
|
|
11
|
+
for (const dirname of ["mobile", "plugin", "secrets", "srvkit", "webkit"]) {
|
|
12
|
+
expect(appRootAllowedDirs.has(dirname)).toBe(true);
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("rejects an app root entry no facet owns", () => {
|
|
17
|
+
expect(appRootAllowedFiles.has("helper.ts")).toBe(false);
|
|
18
|
+
expect(appRootAllowedDirs.has("base")).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("skips dotfile artifacts the sync glob never sees, but keeps .akan", () => {
|
|
22
|
+
expect(isScannedAppRootEntry(".DS_Store")).toBe(false);
|
|
23
|
+
expect(isScannedAppRootEntry(".akan")).toBe(true);
|
|
24
|
+
expect(isScannedAppRootEntry("lib")).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App/lib 루트 레이아웃 허용 목록 — 단일 소스.
|
|
3
|
+
*
|
|
4
|
+
* 같은 규칙을 scanInfo(`akan sync`, hard error) · akanContext(`akan doctor`, diagnostic) ·
|
|
5
|
+
* qualityScanner(`akan quality scan`, warning) 세 곳이 각자 복사해 두면서 실제로 어긋났다
|
|
6
|
+
* (스코프 AGENTS.md/CLAUDE.md 는 sync 만 허용, `plugin` 은 문서에만, `secrets` 는 doctor 만 거부).
|
|
7
|
+
* 규칙을 추가할 때는 이 파일만 고치고, 루트 AGENTS.md 와 `.cursor/rules/akan-scan-conventions.mdc`
|
|
8
|
+
* 의 목록도 같이 갱신한다.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const appRootAllowedFiles = new Set([
|
|
12
|
+
// 스코프 에이전트 가이드 — scan(write) 이 유지하는 색인 + 마커 밖 hand-written 내용 (agentsIndex.ts)
|
|
13
|
+
"AGENTS.md",
|
|
14
|
+
"CLAUDE.md",
|
|
15
|
+
"akan.app.json",
|
|
16
|
+
"akan.config.ts",
|
|
17
|
+
"capacitor.config.ts",
|
|
18
|
+
"client.ts",
|
|
19
|
+
"main.ts",
|
|
20
|
+
"package.json",
|
|
21
|
+
"server.ts",
|
|
22
|
+
"tsconfig.json",
|
|
23
|
+
"tsconfig.tsbuildinfo",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
export const appRootAllowedDirs = new Set([
|
|
27
|
+
".akan",
|
|
28
|
+
"android",
|
|
29
|
+
"common",
|
|
30
|
+
"env",
|
|
31
|
+
"ios",
|
|
32
|
+
"lib",
|
|
33
|
+
"mobile",
|
|
34
|
+
"page",
|
|
35
|
+
"plugin",
|
|
36
|
+
"private",
|
|
37
|
+
"public",
|
|
38
|
+
"script",
|
|
39
|
+
"secrets",
|
|
40
|
+
"srvkit",
|
|
41
|
+
"ui",
|
|
42
|
+
"webkit",
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
export const libFacetRootAllowedFiles = new Set([
|
|
46
|
+
"cnst.ts",
|
|
47
|
+
"db.ts",
|
|
48
|
+
"dict.ts",
|
|
49
|
+
"option.ts",
|
|
50
|
+
"sig.ts",
|
|
51
|
+
"srv.ts",
|
|
52
|
+
"st.ts",
|
|
53
|
+
"useClient.ts",
|
|
54
|
+
"useServer.ts",
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* scanSync 는 앱 루트를 `Bun.Glob("*")` 로 읽어 dotfile 을 아예 보지 못한다. 디렉터리를 직접 읽는
|
|
59
|
+
* doctor 가 그 차이만큼 `.DS_Store` 같은 툴 산출물을 에러로 올리므로 같은 기준으로 걸러낸다.
|
|
60
|
+
*/
|
|
61
|
+
export const isScannedAppRootEntry = (name: string) => !name.startsWith(".") || appRootAllowedDirs.has(name);
|