@akanjs/devkit 3.0.0-alpha.4 → 3.0.0-alpha.41

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.
Files changed (56) hide show
  1. package/README.ko.md +1 -1
  2. package/README.md +1 -1
  3. package/agentsIndex.test.ts +10 -0
  4. package/agentsIndex.ts +47 -1
  5. package/aiEditor.ts +1 -1
  6. package/akanConfig/akanConfig.test.ts +0 -10
  7. package/akanConfig/akanConfig.ts +1 -11
  8. package/akanContext.ts +43 -31
  9. package/applicationBuildRunner.test.ts +1 -1
  10. package/applicationBuildRunner.ts +1 -2
  11. package/biome.base.json +321 -0
  12. package/biomeBase.ts +9 -0
  13. package/executors.test.ts +29 -5
  14. package/executors.ts +11 -4
  15. package/formSetterScanner.test.ts +80 -0
  16. package/formSetterScanner.ts +92 -0
  17. package/frontendBuild/allRoutesBuilder.ts +27 -8
  18. package/frontendBuild/buildRouteClient.test.ts +28 -2
  19. package/frontendBuild/clientBuildTypes.ts +4 -0
  20. package/frontendBuild/clientEntriesBundler.ts +4 -1
  21. package/frontendBuild/cssCandidateCache.test.ts +11 -0
  22. package/frontendBuild/cssCandidateCache.ts +9 -2
  23. package/frontendBuild/cssCompiler.ts +122 -11
  24. package/frontendBuild/cssImportResolver.ts +8 -7
  25. package/frontendBuild/frontendBuild.test.ts +88 -1
  26. package/frontendBuild/precompressArtifacts.ts +35 -7
  27. package/frontendBuild/routeClientBuilder.ts +26 -7
  28. package/frontendBuild/ssrBaseArtifactBuilder.ts +20 -4
  29. package/frontendBuild/vendorSpecifiers.ts +1 -0
  30. package/getCredentials.ts +1 -3
  31. package/incrementalBuilder/devWatchBatch.test.ts +18 -20
  32. package/incrementalBuilder/incrementalBuilder.host.ts +1 -1
  33. package/incrementalBuilder/incrementalBuilder.proc.ts +2 -2
  34. package/integration/devResourceProbe.ts +7 -2
  35. package/integration/devStabilityHarness.ts +1 -5
  36. package/integration/ssrMemoryProbe.ts +542 -0
  37. package/lint/no-bang-comment-in-client.grit +20 -0
  38. package/lint/no-import-client-in-server.grit +48 -0
  39. package/lint/no-import-server-in-client.grit +45 -0
  40. package/lint/no-model-type-in-util-zone.grit +58 -0
  41. package/lint/no-return-in-store-action.grit +35 -0
  42. package/lint/no-unpublished-form-setter.grit +41 -0
  43. package/linter.ts +17 -12
  44. package/package.json +4 -4
  45. package/qualityScanner.test.ts +163 -1
  46. package/qualityScanner.ts +51 -46
  47. package/repoIdentity.ts +42 -0
  48. package/routeSourceValidator.test.ts +41 -0
  49. package/routeSourceValidator.ts +2 -44
  50. package/scanInfo.ts +2 -43
  51. package/spinner.test.ts +81 -0
  52. package/spinner.ts +22 -2
  53. package/ssrScanner.ts +409 -0
  54. package/transforms/externalizeFrameworkPlugin.ts +0 -1
  55. package/workspaceLayout.test.ts +26 -0
  56. package/workspaceLayout.ts +60 -0
@@ -0,0 +1,48 @@
1
+ engine biome(1.0)
2
+ language js(typescript, jsx)
3
+
4
+ // Server files (*.document.ts, *.dictionary.ts, *.service.ts, *.signal.ts, srvkit/) and shared files
5
+ // (common/, *.constant.ts) run in Bun with no DOM and no bundler, and every CLI command, worker, and
6
+ // migration that loads a service loads whatever the service imports. Reaching into the client graph
7
+ // pulls React, the store, and the browser globals they touch into that process, and it runs the
8
+ // dependency backwards: the client entrypoint is built on top of cnst and sig, not the other way round.
9
+ //
10
+ // `import type` is erased before bundling and stays legal. A mixed value-and-type import is not exempt.
11
+ or {
12
+ JsModuleSource() as $source where {
13
+ $source <: within JsImport() as $import,
14
+ not $import <: r"import\s+type[\s\S]*",
15
+ $source <: or {
16
+ r"\".*\.store\"",
17
+ r"\".*\.(?:Template|Unit|Util|View|Zone)\""
18
+ },
19
+ register_diagnostic(
20
+ span = $source,
21
+ message = "Client module. A server or shared file must not import a *.store or a module component (*.Template, *.Unit, *.Util, *.View, *.Zone) — server code reaches the model through cnst and db, never through client state or JSX.",
22
+ severity = "error"
23
+ )
24
+ },
25
+ JsModuleSource() as $source where {
26
+ $source <: within JsImport() as $import,
27
+ not $import <: r"import\s+type[\s\S]*",
28
+ $source <: or {
29
+ r"\"(?:.*/)?(?:ui|webkit)(?:/.*)?\"",
30
+ r"\".*/client(?:/.*)?\""
31
+ },
32
+ register_diagnostic(
33
+ span = $source,
34
+ message = "Client entrypoint. A server or shared file must not import ui/, webkit/, or a package client entrypoint such as '@libs/<lib>/client' or 'akanjs/client' — import the server entrypoint or a common/ helper instead.",
35
+ severity = "error"
36
+ )
37
+ },
38
+ JsModuleSource() as $source where {
39
+ $source <: within JsImport() as $import,
40
+ not $import <: r"import\s+type[\s\S]*",
41
+ $source <: r"\"(?:.*/)?(?:st|store|useClient)\"",
42
+ register_diagnostic(
43
+ span = $source,
44
+ message = "Client barrel. A server or shared file must not import st, store, or useClient — server code holds no client state, and Err comes from dict on the server.",
45
+ severity = "error"
46
+ )
47
+ }
48
+ }
@@ -0,0 +1,45 @@
1
+ engine biome(1.0)
2
+ language js(typescript, jsx)
3
+
4
+ // Client files (ui/, webkit/, page/, *.store.ts, every .tsx) and shared files (common/, *.constant.ts)
5
+ // are compiled into the browser bundle, so a single value import of a server module drags its whole
6
+ // graph along — the database driver, node:crypto, a secret resolved from process.env. The boundary has
7
+ // to hold at the import statement, because by the call site the module is already bundled.
8
+ //
9
+ // `import type` is erased before bundling and stays legal, so a shared file may still name a server-side
10
+ // type. A mixed value-and-type import is not exempt: it emits a real edge.
11
+ or {
12
+ JsModuleSource() as $source where {
13
+ $source <: within JsImport() as $import,
14
+ not $import <: r"import\s+type[\s\S]*",
15
+ $source <: r"\".*\.(?:document|dictionary|service|signal)\"",
16
+ register_diagnostic(
17
+ span = $source,
18
+ message = "Server module. A client or shared file must not import a *.document, *.dictionary, *.service, or *.signal file — take the model from the package client entrypoint ('@libs/<lib>/client') or from its *.constant instead. Write 'import type' if only the type is needed.",
19
+ severity = "error"
20
+ )
21
+ },
22
+ JsModuleSource() as $source where {
23
+ $source <: within JsImport() as $import,
24
+ not $import <: r"import\s+type[\s\S]*",
25
+ $source <: or {
26
+ r"\"(?:.*/)?srvkit(?:/.*)?\"",
27
+ r"\".*/server(?:/.*)?\""
28
+ },
29
+ register_diagnostic(
30
+ span = $source,
31
+ message = "Server entrypoint. A client or shared file must not import srvkit/ or a package server entrypoint such as '@apps/<app>/server' or 'akanjs/server' — import the matching client entrypoint instead.",
32
+ severity = "error"
33
+ )
34
+ },
35
+ JsModuleSource() as $source where {
36
+ $source <: within JsImport() as $import,
37
+ not $import <: r"import\s+type[\s\S]*",
38
+ $source <: r"\"(?:.*/)?(?:db|srv|sig|dict|option|useServer)\"",
39
+ register_diagnostic(
40
+ span = $source,
41
+ message = "Server barrel. A client or shared file must not import db, srv, sig, dict, option, or useServer — read models from cnst, state from st, and Err from the package client entrypoint.",
42
+ severity = "error"
43
+ )
44
+ }
45
+ }
@@ -0,0 +1,58 @@
1
+ engine biome(1.0)
2
+ language js(typescript, jsx)
3
+
4
+ // `Util` and `Zone` are always client components, so a *prop* typed as a `cnst` model is a hydrated
5
+ // class instance the server has to hand across the boundary — the methods are stripped on the way and
6
+ // what arrives is a plain object wearing the model's type.
7
+ //
8
+ // Only prop positions are reported: the component's `*Props` interface / type alias, or the inline
9
+ // object type on its parameter. A `cnst` type anywhere else in the file never crosses the boundary and
10
+ // stays legal — a local annotation, a callback parameter the framework already types with the model
11
+ // (`renderItem`, `renderList`), a module-scope helper, a non-`Props` local shape, and the props of a
12
+ // component nested inside another one. A function-typed prop is client-internal too: a closure cannot
13
+ // cross the RSC boundary at all, so whoever passes it already holds the value it takes.
14
+ //
15
+ // The exempt generics are the framework props types whose model parameter never lands on a prop as
16
+ // data: `ClientInit` / `ClientView` / `ClientEdit` (mapped to plain `GetStateObject`) and `ModelsProps`,
17
+ // whose only use of the model is `onClickItem?: (model: M) => unknown` — a callback, so the caller
18
+ // already holds the value. `ModelProps<"x", cnst.LightX>` is *not* exempt: it spreads the model onto
19
+ // the props themselves.
20
+ TsReferenceType() as $ref where {
21
+ $ref <: r"cnst\.[A-Za-z_$][A-Za-z0-9_$]*",
22
+ not $ref <: within TsIndexedAccessType() as $indexed where {
23
+ $indexed <: r"cnst\.[A-Za-z_$][A-Za-z0-9_$]*\[.value.\][\s\S]*"
24
+ },
25
+ not $ref <: within TsReferenceType() as $wrapper where {
26
+ $wrapper <: r"(?:(?:Client|Server)(?:Init|View|Edit)|ModelsProps)<[\s\S]*"
27
+ },
28
+ not $ref <: within TsFunctionType(),
29
+ or {
30
+ $ref <: within TsInterfaceDeclaration() as $propsInterface where {
31
+ $propsInterface <: r"interface\s+[A-Za-z0-9_$]*Props[\s\S]*"
32
+ },
33
+ $ref <: within TsTypeAliasDeclaration() as $propsAlias where {
34
+ $propsAlias <: r"type\s+[A-Za-z0-9_$]*Props[\s\S]*"
35
+ },
36
+ and {
37
+ $ref <: within JsFormalParameter(),
38
+ // A parameter is a sibling of the body, so this only drops the ones belonging to a function
39
+ // declared *inside* a component — a nested component or a local callback.
40
+ not $ref <: within JsFunctionBody(),
41
+ // Same for a callback handed straight to a call, which an expression-bodied component leaves
42
+ // outside every function body: `rows.map((row: cnst.LightTicket) => …)`.
43
+ not $ref <: within JsCallArguments(),
44
+ or {
45
+ $ref <: within JsVariableDeclarator() as $component where {
46
+ $component <: r"[A-Z][A-Za-z0-9_$]*[\s\S]*"
47
+ },
48
+ $ref <: within JsFunctionDeclaration() as $componentFn where {
49
+ $componentFn <: r"(?:async\s+)?function\s+[A-Z][A-Za-z0-9_$]*[\s\S]*"
50
+ }
51
+ }
52
+ }
53
+ },
54
+ register_diagnostic(
55
+ span = $ref,
56
+ message = "Util and Zone are client components, so a cnst model type on a prop is a class instance crossing the server boundary. Take an id instead (bannerId: string), or hand server data across as ClientInit / ClientView. An enum union such as cnst.AdminRole['value'] is allowed, and so is a cnst type that stays inside the file — a local annotation or a callback parameter."
57
+ )
58
+ }
@@ -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
+ }
@@ -0,0 +1,41 @@
1
+ engine biome(1.0)
2
+ language js(typescript, jsx)
3
+
4
+ // A form control publishes its own setter by reading the action off the function it was handed
5
+ // (`actionTagOf` in pkgs/akanjs/store/actionTag.ts). A setter passed by reference names the field it
6
+ // writes, so the control emits `data-akan-action` and `useFieldTool` publishes the field to the in-page
7
+ // agent. An arrow that only forwards its argument is a fresh anonymous closure carrying neither, so the
8
+ // field silently becomes unreachable to the agent, to an E2E selector, and to the accessibility tree —
9
+ // while looking identical to a reader. Generated field setters take exactly one value
10
+ // (`makeFormSetter` in pkgs/akanjs/store/action.ts), so the wrapper never changes what runs.
11
+ //
12
+ // Only a pure forwarding body is reported: the argument reaches the setter unchanged, as the sole
13
+ // statement. Each other shape has its own home, so none of them is reported here:
14
+ //
15
+ // normalize `(v) => set(formatPhone(v))` -> the control's own `transform` prop, which every text and
16
+ // number `Field.*` already takes. Keep `onChange` a reference.
17
+ // composite `(v) => { set(v); other(v); }` -> a `_postSet<Field>` method on the store, with the generated
18
+ // setter left on the control. A generated action cannot be
19
+ // overridden: mapped types make them properties (TS2425).
20
+ // nested `(v) => writeOnX('a.3.b', v)` -> unannotatable by design. An agent reaches an embedded row
21
+ // through `fill<Model>Form`, which waves composites through.
22
+ //
23
+ // A typed parameter (`(v: string) => st.do.setVOnX(v)`) is not matched: the parameter and the argument
24
+ // bind to different text, so the metavariable cannot unify them. Under-reporting, not a false positive.
25
+ //
26
+ JsxAttribute() as $attr where {
27
+ $attr <: contains JsArrowFunctionExpression() as $arrow,
28
+ $arrow <: `($p) => $body`,
29
+ // One bare parameter. A metavariable matches a whole parameter list leniently, so `(a, b)` binds the
30
+ // text "a, b" and is excluded here rather than by the snippet.
31
+ $p <: r"[A-Za-z_$][A-Za-z0-9_$]*",
32
+ $body <: contains `st.do.$action($p)` as $call,
33
+ $action <: r"set[A-Za-z0-9_$]*On[A-Za-z0-9_$]*",
34
+ // The body is that call and nothing else — `r"..."` is a full match, so an extra statement fails it.
35
+ $body <: r"\{?\s*(?:void\s+|await\s+)?st\.do\.[A-Za-z0-9_$]+\([A-Za-z_$][A-Za-z0-9_$]*\)\s*;?\s*\}?",
36
+ register_diagnostic(
37
+ span = $arrow,
38
+ message = "This arrow only forwards its argument, and an anonymous closure names no action — the control publishes no agent tool and emits no data-akan-action for the field. Pass the setter by reference: onChange={st.do.setTypeOnTicket}. A wrapper that does more is not reported: normalization belongs in the control's transform prop, a multi-write belongs in a _postSet<Field> hook on the store, and a nested path through writeOnX is reached by the agent through fill<Model>Form.",
39
+ severity = "error"
40
+ )
41
+ }
package/linter.ts CHANGED
@@ -58,19 +58,31 @@ interface LintResponse {
58
58
  warnings: LintMessage[];
59
59
  }
60
60
 
61
+ /** Biome reads `biome.json` first and `biome.jsonc` second; only the latter may carry comments. */
62
+ const BIOME_CONFIG_FILES = ["biome.json", "biome.jsonc"] as const;
63
+
61
64
  export class Linter {
62
65
  lintRoot: string;
66
+ configPath: string;
63
67
  #biomeBin: string;
64
68
 
65
69
  constructor(cwdPath: string) {
66
70
  this.lintRoot = this.#findBiomeRootPath(cwdPath);
71
+ this.configPath = Linter.#configPathIn(this.lintRoot) ?? path.join(this.lintRoot, "biome.json");
67
72
  const localBiomeBin = path.join(this.lintRoot, "node_modules/.bin/biome");
68
73
  this.#biomeBin = existsSync(localBiomeBin) ? localBiomeBin : "biome";
69
74
  }
70
75
 
76
+ static #configPathIn(dir: string): string | null {
77
+ for (const fileName of BIOME_CONFIG_FILES) {
78
+ const configPath = path.join(dir, fileName);
79
+ if (existsSync(configPath)) return configPath;
80
+ }
81
+ return null;
82
+ }
83
+
71
84
  #findBiomeRootPath(dir: string): string {
72
- const configPath = path.join(dir, "biome.json");
73
- if (existsSync(configPath)) return dir;
85
+ if (Linter.#configPathIn(dir)) return dir;
74
86
  const parentDir = path.dirname(dir);
75
87
  if (parentDir === dir) throw new Error(`biome.json not found from ${dir}`);
76
88
  return this.#findBiomeRootPath(parentDir);
@@ -193,7 +205,7 @@ export class Linter {
193
205
  "--max-diagnostics=none",
194
206
  "--no-errors-on-unmatched",
195
207
  "--config-path",
196
- path.join(this.lintRoot, "biome.json"),
208
+ this.configPath,
197
209
  this.#toBiomePath(filePath),
198
210
  ]);
199
211
  const report = this.#parseBiomeReport(stdout || stderr);
@@ -390,14 +402,7 @@ export class Linter {
390
402
 
391
403
  const source = readFileSync(resolvedFilePath, "utf8");
392
404
  const { stdout } = await this.#runBiome(
393
- [
394
- "check",
395
- "--write",
396
- "--config-path",
397
- path.join(this.lintRoot, "biome.json"),
398
- "--stdin-file-path",
399
- this.#toBiomePath(resolvedFilePath),
400
- ],
405
+ ["check", "--write", "--config-path", this.configPath, "--stdin-file-path", this.#toBiomePath(resolvedFilePath)],
401
406
  source,
402
407
  );
403
408
  const lintResult = await this.lintFile(resolvedFilePath);
@@ -412,7 +417,7 @@ export class Linter {
412
417
  async getConfigForFile(filePath: string): Promise<unknown> {
413
418
  const resolvedFilePath = this.#resolveFilePath(filePath);
414
419
  if (!existsSync(resolvedFilePath)) throw new Error(`File not found: ${filePath}`);
415
- return JSON.parse(readFileSync(path.join(this.lintRoot, "biome.json"), "utf8")) as unknown;
420
+ return JSON.parse(readFileSync(this.configPath, "utf8")) as unknown;
416
421
  }
417
422
 
418
423
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "3.0.0-alpha.4",
3
+ "version": "3.0.0-alpha.41",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -14,7 +14,7 @@
14
14
  "directory": "pkgs/@akanjs/devkit"
15
15
  },
16
16
  "engines": {
17
- "bun": ">=1.3.13"
17
+ "bun": ">=1.4.0"
18
18
  },
19
19
  "exports": {
20
20
  ".": {
@@ -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.4",
48
+ "akanjs": "3.0.0-alpha.41",
48
49
  "chalk": "^5.6.2",
49
50
  "commander": "^14.0.3",
50
51
  "dayjs": "^1.11.20",
@@ -52,7 +53,6 @@
52
53
  "fonteditor-core": "^2.6.3",
53
54
  "ignore": "^7.0.5",
54
55
  "ink": "^6.8.0",
55
- "js-yaml": "^4.1.1",
56
56
  "ora": "^9.4.0",
57
57
  "ssh2": "^1.17.0",
58
58
  "subset-font": "^2.5.0",
@@ -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,165 @@ 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
+ const signalOf = (entries: string) =>
188
+ [
189
+ `import { endpoint, slice } from "akanjs/signal";`,
190
+ `export class PostSlice extends slice(srv.post, { guards: {}, mcp: { get: true } }, (init) => ({`,
191
+ entries,
192
+ `})) {}`,
193
+ "",
194
+ ].join("\n");
195
+
196
+ describe("AkanQualityScanner layout rules", () => {
197
+ test("flags an unknown app root file but not a facet entrypoint", async () => {
198
+ const root = await makeWorkspace({
199
+ "apps/demo/client.ts": "export const client = 1;\n",
200
+ "apps/demo/helper.ts": "export const helper = 1;\n",
201
+ });
202
+
203
+ const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.layout.app-root-file");
204
+
205
+ expect(warnings).toHaveLength(1);
206
+ expect(warnings[0]?.file).toBe("apps/demo/helper.ts");
207
+ });
208
+ });