@akanjs/devkit 3.0.0-alpha.7 → 3.0.0-alpha.71

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 (57) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.ko.md +1 -1
  3. package/README.md +1 -1
  4. package/agentsIndex.test.ts +10 -0
  5. package/agentsIndex.ts +47 -1
  6. package/aiEditor.ts +1 -1
  7. package/akanConfig/akanConfig.test.ts +182 -14
  8. package/akanConfig/akanConfig.ts +132 -47
  9. package/akanConfig/types.ts +8 -0
  10. package/akanContext.ts +53 -11
  11. package/applicationBuildRunner.test.ts +1 -1
  12. package/applicationBuildRunner.ts +45 -21
  13. package/artifact/implicitRootLayout.ts +2 -2
  14. package/biome.base.json +340 -0
  15. package/biomeBase.ts +9 -0
  16. package/executors.test.ts +87 -5
  17. package/executors.ts +40 -9
  18. package/formSetterScanner.test.ts +80 -0
  19. package/formSetterScanner.ts +92 -0
  20. package/frontendBuild/buildRouteClient.test.ts +28 -2
  21. package/frontendBuild/clientBuildTypes.ts +4 -0
  22. package/frontendBuild/clientEntriesBundler.ts +4 -1
  23. package/frontendBuild/cssCompiler.ts +122 -11
  24. package/frontendBuild/cssImportResolver.ts +8 -7
  25. package/frontendBuild/fontPruner.test.ts +220 -0
  26. package/frontendBuild/fontPruner.ts +206 -0
  27. package/frontendBuild/frontendBuild.test.ts +88 -1
  28. package/frontendBuild/hmrWatcher.ts +1 -1
  29. package/frontendBuild/index.ts +1 -0
  30. package/frontendBuild/routeClientBuilder.ts +12 -5
  31. package/frontendBuild/ssrBaseArtifactBuilder.ts +21 -4
  32. package/frontendBuild/styleGuard.test.ts +15 -0
  33. package/frontendBuild/styleGuard.ts +17 -0
  34. package/frontendBuild/vendorSpecifiers.ts +1 -0
  35. package/getCredentials.ts +1 -3
  36. package/incrementalBuilder/devWatchBatch.test.ts +18 -20
  37. package/incrementalBuilder/incrementalBuilder.host.ts +1 -1
  38. package/incrementalBuilder/incrementalBuilder.proc.ts +2 -2
  39. package/integration/devStabilityHarness.ts +2 -10
  40. package/lint/no-async-component-in-ui.grit +35 -0
  41. package/lint/no-daisyui-legacy-class.grit +26 -9
  42. package/lint/no-deprecated-log-level.grit +17 -0
  43. package/lint/no-import-client-in-server.grit +48 -0
  44. package/lint/no-import-server-in-client.grit +45 -0
  45. package/lint/no-init-fetch-in-client.grit +47 -0
  46. package/lint/no-model-type-in-util-zone.grit +58 -0
  47. package/lint/no-unpublished-form-setter.grit +41 -0
  48. package/linter.ts +17 -12
  49. package/package.json +5 -5
  50. package/qualityScanner.test.ts +52 -0
  51. package/qualityScanner.ts +46 -18
  52. package/repoIdentity.ts +42 -0
  53. package/scanInfo.ts +29 -23
  54. package/transforms/externalizeFrameworkPlugin.ts +0 -1
  55. package/tsconfig.json +1 -1
  56. package/workspaceLayout.test.ts +56 -4
  57. package/workspaceLayout.ts +49 -4
@@ -67,6 +67,21 @@ describe("StyleGuard daisyui-legacy", () => {
67
67
  test("does not flag bare ambiguous names that collide with Tailwind", () => {
68
68
  expect(scan('<div className="card input badge btn" />')).toHaveLength(0);
69
69
  });
70
+
71
+ test("flags daisyUI colour slots the vocabulary dropped", () => {
72
+ expect(rules('<div className="bg-base-100" />')).toEqual(["daisyui-legacy"]);
73
+ expect(rules('<div className="text-base-content/70" />')).toEqual(["daisyui-legacy"]);
74
+ expect(rules('<div className="border-t-base-300" />')).toEqual(["daisyui-legacy"]);
75
+ expect(rules('<div className="text-primary-content" />')).toEqual(["daisyui-legacy"]);
76
+ expect(rules('<div className="border-error/30 bg-error/5" />')).toEqual(["daisyui-legacy", "daisyui-legacy"]);
77
+ });
78
+
79
+ test("does not flag colour slots that survived into the semantic vocabulary", () => {
80
+ expect(scan('<div className="bg-primary text-primary-foreground" />')).toHaveLength(0);
81
+ expect(scan('<div className="bg-neutral text-info border-warning" />')).toHaveLength(0);
82
+ expect(scan('<div className="bg-destructive/10 text-destructive" />')).toHaveLength(0);
83
+ expect(scan('<div className="content-center justify-content" />')).toHaveLength(0);
84
+ });
70
85
  });
71
86
 
72
87
  // The fixtures below must contain a literal `${`. Writing it inside a plain string trips biome's
@@ -90,6 +90,13 @@ const DAISYUI_LEGACY_RE = new RegExp(
90
90
  "g",
91
91
  );
92
92
 
93
+ // daisyUI 색 슬롯. 어휘 폐쇄가 이 이름들을 지웠으므로 CSS 가 생성되지 않는다 — 컴포넌트 클래스와 같은
94
+ // 무증상 실패다. 시맨틱 어휘에 살아남은 슬롯(primary·secondary·accent·neutral·info·success·warning)은
95
+ // 정상이므로 제외하고, 사라진 것(base-*, *-content, error)만 잡는다.
96
+ const DAISYUI_TOKEN =
97
+ "(?:base-(?:100|200|300|content)|(?:primary|secondary|accent|neutral|info|success|warning|error)-content|error)";
98
+ const DAISYUI_TOKEN_RE = new RegExp(`${LEAD}(?:${PREFIX})-${DAISYUI_TOKEN}${TAIL}`, "g");
99
+
93
100
  const ALL_RULES: StyleGuardRule[] = [
94
101
  "raw-palette",
95
102
  "arbitrary-color",
@@ -247,6 +254,16 @@ export class StyleGuard {
247
254
  }),
248
255
  );
249
256
  }
257
+ for (const m of scan.matchAll(DAISYUI_TOKEN_RE)) {
258
+ out.push(
259
+ this.#violation(file, m.index ?? 0, {
260
+ rule: "daisyui-legacy",
261
+ severity: "error",
262
+ suggestion:
263
+ "daisyUI 색 슬롯은 어휘에서 제거됐습니다 — base-100/200/300→background/muted/border, base-content→foreground, *-content→*-foreground, error→destructive.",
264
+ }),
265
+ );
266
+ }
250
267
  for (const m of scan.matchAll(INTERPOLATED_ARBITRARY_RE)) {
251
268
  out.push(
252
269
  this.#violation(file, m.index ?? 0, {
@@ -11,6 +11,7 @@ export const VENDOR_SPECIFIERS = [
11
11
  "akanjs/base",
12
12
  "akanjs/common",
13
13
  "akanjs/constant",
14
+ "akanjs/fetch",
14
15
  ] as const;
15
16
 
16
17
  export type VendorSpecifier = (typeof VENDOR_SPECIFIERS)[number];
package/getCredentials.ts CHANGED
@@ -1,5 +1,3 @@
1
- import yaml from "js-yaml";
2
-
3
1
  import type { AppExecutor } from "./executors";
4
2
  import { FileSys } from "./fileSys";
5
3
 
@@ -12,7 +10,7 @@ interface Secret {
12
10
 
13
11
  export const getCredentials = async (app: AppExecutor, environment: string): Promise<AppSecret> => {
14
12
  const content = await FileSys.readText(`${app.workspace.workspaceRoot}/infra/app/values/${app.name}-secret.yaml`);
15
- const secret = yaml.load(content) as Secret;
13
+ const secret = Bun.YAML.parse(content) as Secret;
16
14
  const appSecret = secret[environment];
17
15
  if (!appSecret) throw new Error(`No secret found for ${app.name} in ${environment}`);
18
16
  return appSecret;
@@ -22,27 +22,25 @@ describe("prepareDevWatchBatch", () => {
22
22
  expect(prepared.event.devPlan?.files).toEqual(prepared.files);
23
23
  });
24
24
 
25
- test.each([
26
- "common",
27
- "srvkit",
28
- "ui",
29
- "webkit",
30
- ])("keeps %s facet add/delete generated index in the same generation", (facet) => {
31
- const root = "/repo";
32
- const changedFile = `${root}/libs/shared/${facet}/tmpExample.ts`;
33
- const generatedIndex = `${root}/libs/shared/${facet}/index.ts`;
34
- const prepared = prepareDevWatchBatch({
35
- generation: 20,
36
- batch: { files: [changedFile], kinds: new Set(["code"]) },
37
- indexSync: { changedFiles: [generatedIndex], errors: [] },
38
- changePlanner: new DevChangePlanner({ workspaceRoot: root }),
39
- });
25
+ test.each(["common", "srvkit", "ui", "webkit"])(
26
+ "keeps %s facet add/delete generated index in the same generation",
27
+ (facet) => {
28
+ const root = "/repo";
29
+ const changedFile = `${root}/libs/shared/${facet}/tmpExample.ts`;
30
+ const generatedIndex = `${root}/libs/shared/${facet}/index.ts`;
31
+ const prepared = prepareDevWatchBatch({
32
+ generation: 20,
33
+ batch: { files: [changedFile], kinds: new Set(["code"]) },
34
+ indexSync: { changedFiles: [generatedIndex], errors: [] },
35
+ changePlanner: new DevChangePlanner({ workspaceRoot: root }),
36
+ });
40
37
 
41
- expect(new Set(prepared.files)).toEqual(new Set([changedFile, generatedIndex]));
42
- expect(prepared.event.devPlan?.generatedFiles).toEqual([generatedIndex]);
43
- expect(prepared.event.devPlan?.roles).toContain("barrel");
44
- expect(prepared.event.devPlan?.actions).toContain("sync-generated");
45
- });
38
+ expect(new Set(prepared.files)).toEqual(new Set([changedFile, generatedIndex]));
39
+ expect(prepared.event.devPlan?.generatedFiles).toEqual([generatedIndex]);
40
+ expect(prepared.event.devPlan?.roles).toContain("barrel");
41
+ expect(prepared.event.devPlan?.actions).toContain("sync-generated");
42
+ },
43
+ );
46
44
 
47
45
  test("marks failed generated index sync as an error generation", () => {
48
46
  const root = "/repo";
@@ -200,7 +200,7 @@ export class IncrementalBuilderHost {
200
200
  // one at a time, into the dev error page a recycle is supposed to be invisible to. `ready` the
201
201
  // field is deliberately untouched: `onExit` reads it to tell a planned exit from a boot failure.
202
202
  this.#status = "recycling";
203
- this.logger.info(`recycling builder pid=${proc.pid} (${reason})`);
203
+ this.logger.debug(`recycling builder pid=${proc.pid} (${reason})`);
204
204
  this.#recycleTimer = setTimeout(() => {
205
205
  this.#recycleTimer = null;
206
206
  if (this.#proc !== proc) return;
@@ -185,7 +185,7 @@ class IncrementalBuilder {
185
185
  if (this.#shuttingDown) return;
186
186
  this.#shuttingDown = true;
187
187
  const started = Date.now();
188
- this.#logger.info(`shutdown requested (${reason}); draining ${this.#inFlight} work item(s)`);
188
+ this.#logger.debug(`shutdown requested (${reason}); draining ${this.#inFlight} work item(s)`);
189
189
  if (this.#cssRebuildTimer) {
190
190
  // Only reachable if a css batch landed between the idle report and this request: the fresh
191
191
  // boot build recompiles css from scratch anyway, so dropping the debounce loses nothing.
@@ -200,7 +200,7 @@ class IncrementalBuilder {
200
200
  // flushed — a `css-updated` relayed milliseconds before this line would be dropped with no error
201
201
  // anywhere, leaving the backend serving the previous bundle. See `BuilderChannel`.
202
202
  const flushed = await BuilderChannel.drain();
203
- this.#logger.info(
203
+ this.#logger.debug(
204
204
  `drained in ${Date.now() - started}ms${flushed ? ` after flushing ${flushed} ipc write(s)` : ""}; exiting for recycle`,
205
205
  );
206
206
  process.exit(0);
@@ -118,20 +118,12 @@ export default config;
118
118
  ),
119
119
  this.writeFile(
120
120
  "env/env.client.ts",
121
- `import { getEnv } from "akanjs/base";
122
-
123
- export const env = {
124
- ...getEnv(),
125
- } as const;
121
+ `export const env = {} as const;
126
122
  `,
127
123
  ),
128
124
  this.writeFile(
129
125
  "env/env.server.ts",
130
- `import { getEnv } from "akanjs/base";
131
-
132
- export const env = {
133
- ...getEnv(),
134
- } as const;
126
+ `export const env = {} as const;
135
127
  `,
136
128
  ),
137
129
  this.writeFile(
@@ -0,0 +1,35 @@
1
+ engine biome(1.0)
2
+ language js(typescript, jsx)
3
+
4
+ // React has no async client component: an async body is a server-only shape, so a `ui/` component that
5
+ // awaits stops rendering the moment any client parent imports it — and `ui/` is written to be reachable
6
+ // from both sides. It also moves the load below the route, where the framework can no longer start it
7
+ // before the first byte. Awaiting belongs to `page/**`, which passes the resolved data down as props.
8
+ //
9
+ // Component-ness is a PascalCase regex on the binding, the same test `no-model-type-in-util-zone.grit`
10
+ // uses. The declarator regex anchors `async` to the initializer position (`[^=]*` cannot cross an `=`),
11
+ // so an async handler declared inside a synchronous component is not read as the component's own body;
12
+ // `not within JsFunctionBody()` drops every nested declaration for the same reason. Out of scope by
13
+ // construction, since neither is a named binding at statement level: an inline `onClick={async () => …}`
14
+ // and an async member of an object literal.
15
+ JsIdentifierBinding() as $name where {
16
+ $name <: r"[A-Z][A-Za-z0-9_$]*",
17
+ not $name <: within JsFunctionBody(),
18
+ not $name <: within JsFormalParameter(),
19
+ or {
20
+ $name <: within JsVariableDeclarator() as $decl where {
21
+ $decl <: r"[A-Z][A-Za-z0-9_$]*\s*(?::[^=]*)?=\s*async[\s\S]*"
22
+ },
23
+ $name <: within JsFunctionDeclaration() as $fn where {
24
+ $fn <: r"(?:export\s+)?(?:default\s+)?async\s+function[\s\S]*"
25
+ },
26
+ $name <: within JsFunctionExportDefaultDeclaration() as $default where {
27
+ $default <: r"(?:export\s+)?(?:default\s+)?async\s+function[\s\S]*"
28
+ }
29
+ },
30
+ register_diagnostic(
31
+ span = $name,
32
+ message = "An async component belongs in page/. React has no async client component, so a ui/ component that awaits breaks as soon as a client parent renders it, and the route can no longer start the load before the first byte. Await in the page — or hand an unawaited fetch.* to a Zone as an init / view prop — and take the resolved data as a prop.",
33
+ severity = "error"
34
+ )
35
+ }
@@ -2,19 +2,36 @@ engine biome(1.0)
2
2
  language js(typescript, jsx)
3
3
 
4
4
  // daisyUI was removed from the UI system; its component classes (`btn-primary`, `card-body`,
5
- // `mockup-code`, ...) no longer have any CSS behind them and silently render unstyled. Grit
6
- // port of styleGuard's `daisyui-legacy` rule (frontendBuild/styleGuard.ts) keep in sync.
5
+ // `mockup-code`, ...) and its colour slots (`bg-base-100`, `text-base-content`, `bg-error`)
6
+ // no longer have any CSS behind them and silently render unstyled. Grit port of styleGuard's
7
+ // `daisyui-legacy` rule (frontendBuild/styleGuard.ts) — keep in sync.
7
8
  // Only high-signal compound names are matched; bare ambiguous words that collide with
8
- // Tailwind or app classes (`card`, `input`, `badge`, `btn`) are deliberately not flagged.
9
+ // Tailwind or app classes (`card`, `input`, `badge`, `btn`, `divider`) are deliberately not
10
+ // flagged, and colour slots that survived into the semantic vocabulary (`primary`, `info`,
11
+ // `success`, `warning`, `neutral`) are not either — only the slots the vocabulary dropped.
12
+ // Biome keeps only the first diagnostic a single plugin registers on a node, so a string
13
+ // carrying both halves reports the component one and surfaces the colour one on the next run.
9
14
  or {
10
15
  JsxString() as $s,
11
16
  JsStringLiteralExpression() as $s,
12
17
  JsTemplateChunkElement() as $s
13
18
  } where {
14
- $s <: r"[\s\S]*(?:^|[\s\"'`{(\[:!])(?:btn-(?:primary|secondary|accent|neutral|info|success|warning|error|ghost|link|outline|square|circle|wide|block|xs|sm|md|lg)|badge-(?:primary|secondary|accent|neutral|info|success|warning|error|ghost|outline)|alert-(?:info|success|warning|error)|input-(?:bordered|primary|secondary|accent|ghost|error)|select-(?:bordered|primary|ghost)|textarea-(?:bordered|primary|ghost)|checkbox-(?:primary|secondary|accent)|toggle-(?:primary|secondary|accent)|loading-(?:spinner|dots|ring|ball|bars|infinity)|card-(?:body|title|actions)|modal-(?:box|action|backdrop)|collapse-(?:title|content|arrow|plus)|dropdown-(?:content|end|start|hover)|stat-(?:title|value|desc)|tabs-(?:boxed|lifted|bordered)|tab-active|menu-(?:title|dropdown)|steps-(?:horizontal|vertical)|join-item|mockup-(?:code|phone|browser|window)|drawer-(?:side|content|toggle))(?:[\s\"'`})\]:/!,]|$)[\s\S]*",
15
- register_diagnostic(
16
- span = $s,
17
- message = "daisyUI legacy class - daisyUI was removed, so this renders unstyled. Use akanjs/ui components and recipes (Button, buttonRecipe, badgeRecipe, ...) with semantic tokens instead.",
18
- severity = "error"
19
- )
19
+ any {
20
+ and {
21
+ $s <: r"[\s\S]*(?:^|[\s\"'`{(\[:!])(?:btn-(?:primary|secondary|accent|neutral|info|success|warning|error|ghost|link|outline|square|circle|wide|block|xs|sm|md|lg)|badge-(?:primary|secondary|accent|neutral|info|success|warning|error|ghost|outline)|alert-(?:info|success|warning|error)|input-(?:bordered|primary|secondary|accent|ghost|error)|select-(?:bordered|primary|ghost)|textarea-(?:bordered|primary|ghost)|checkbox-(?:primary|secondary|accent)|toggle-(?:primary|secondary|accent)|loading-(?:spinner|dots|ring|ball|bars|infinity)|card-(?:body|title|actions)|modal-(?:box|action|backdrop)|collapse-(?:title|content|arrow|plus)|dropdown-(?:content|end|start|hover)|stat-(?:title|value|desc)|tabs-(?:boxed|lifted|bordered)|tab-active|menu-(?:title|dropdown)|steps-(?:horizontal|vertical)|join-item|mockup-(?:code|phone|browser|window)|drawer-(?:side|content|toggle))(?:[\s\"'`})\]:/!,]|$)[\s\S]*",
22
+ register_diagnostic(
23
+ span = $s,
24
+ message = "daisyUI legacy class - daisyUI was removed, so this renders unstyled. Use akanjs/ui components and recipes (Button, buttonRecipe, badgeRecipe, ...) with semantic tokens instead.",
25
+ severity = "error"
26
+ )
27
+ },
28
+ and {
29
+ $s <: r"[\s\S]*(?:^|[\s\"'`{(\[:!])(?:bg|text|border(?:-[tblrxy])?(?:-[se])?|ring(?:-offset)?|fill|stroke|shadow|from|to|via|divide|outline|decoration|placeholder|caret|accent)-(?:base-(?:100|200|300|content)|(?:primary|secondary|accent|neutral|info|success|warning|error)-content|error)(?:[\s\"'`})\]:/!,]|$)[\s\S]*",
30
+ register_diagnostic(
31
+ span = $s,
32
+ message = "daisyUI colour slot - the closed colour vocabulary has no CSS for it, so this renders unstyled. base-100/200/300 -> background/muted/border, base-content -> foreground, <colour>-content -> <colour>-foreground, error -> destructive.",
33
+ severity = "error"
34
+ )
35
+ }
36
+ }
20
37
  }
@@ -0,0 +1,17 @@
1
+ engine biome(1.0)
2
+ language js(typescript, jsx)
3
+
4
+ // The level ladder no longer has a `log` tier: `logger.log()` and `Logger.log()` are kept for compatibility
5
+ // and emit at `info`, so a call reads like a distinct level and is not one. The receiver regex admits the
6
+ // instance field (`this.logger`, `this.#logger`), a local (`logger`) and the static class (`Logger`), and
7
+ // nothing else — `console.log` is biome's own `noConsole` rule.
8
+ //
9
+ // No autofix: Biome does not apply plugin rewrites, and the rename is a one-word edit at each site.
10
+ `$recv.log($arg)` as $call where {
11
+ $recv <: r"(?:this\.)?#?[lL]ogger",
12
+ register_diagnostic(
13
+ span = $call,
14
+ message = "`.log()` is deprecated and emits at `info`. Call `.info()` instead.",
15
+ severity = "error"
16
+ )
17
+ }
@@ -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,47 @@
1
+ engine biome(1.0)
2
+ language js(typescript, jsx)
3
+
4
+ // `fetch.init<Model><Suffix>` is not a request, it is a hydration snapshot. `#registerSlice`
5
+ // (pkgs/akanjs/fetch/client/fetchClient.ts) composes it out of the slice's list and insight queries and
6
+ // returns a `ServerInit` whose only consumer is the `init` prop of `Load.Units` / `Load.View`, which
7
+ // writes it into the store before React ever renders. `fetch.get<Model>Init<Suffix>` is the same call
8
+ // returning the payload alone. Run from a route they resolve before the first byte and the markup ships
9
+ // populated; run after hydration they are two extra round-trips for a shell the browser already painted
10
+ // empty, landing in a local variable no store reads — `Load.*` seeds state, so a value held in a client
11
+ // closure reaches nothing.
12
+ //
13
+ // The client is not missing the load, only this spelling of it: every slice also generates
14
+ // `st.do.init<Model><Suffix>` (pkgs/akanjs/store/action.ts), which runs the same two queries and commits
15
+ // them to state.
16
+ //
17
+ // Both names are matched by shape, since a lint rule cannot know which slices exist. The generated one is
18
+ // `init` + `Capitalize<refName>` + `Capitalize<suffix>`, so it always carries two capital-led segments —
19
+ // requiring the second one is what keeps a hand-written `initPayment` / `initSession` endpoint out, while
20
+ // `initializeSomething` was never at risk (`init` is followed by a lowercase letter). What is left over is a
21
+ // custom endpoint that happens to spell the generated shape exactly, `initPaymentSession` or
22
+ // `get<X>Init<Y>`; suppress that one with `// biome-ignore lint/plugin: <reason>`.
23
+ //
24
+ // `view`/`edit` hydrate the same way but are not matched here: `edit<X>` is a plausible custom endpoint
25
+ // name, whereas the two shapes above are not.
26
+ //
27
+ // The gate is the file. `JsDirective` anchors `"use client"` to the real directive — docs pages carry the
28
+ // same text as an ordinary string literal and inside sample code, and neither is one. `*.store.ts` is
29
+ // added by name because a store is client-only by role and carries no directive.
30
+ JsCallExpression() as $call where {
31
+ $call <: `fetch.$endpoint($...)`,
32
+ $endpoint <: or {
33
+ r"init[A-Z][A-Za-z0-9_$]*[A-Z][A-Za-z0-9_$]*",
34
+ r"get[A-Z][A-Za-z0-9_$]*Init[A-Z][A-Za-z0-9_$]*"
35
+ },
36
+ or {
37
+ $filename <: r".*\.store\.ts",
38
+ $call <: within JsModule() as $module where {
39
+ $module <: contains JsDirective() as $directive where { $directive <: r"[\s\S]*use client[\s\S]*" }
40
+ }
41
+ },
42
+ register_diagnostic(
43
+ span = $call,
44
+ message = "This is a server-side hydration call. It composes the slice's list and insight queries into a snapshot whose only consumer is the init prop of Load.Units / Load.View, so on the client it costs two extra round-trips for a shell the browser already painted, and the result lands in a value nothing reads. Load it in the route — await fetch.initXInY(...) in page/** and pass the result down as an init prop, or hand the unawaited promise to a Zone and let Load.* resolve it behind a skeleton. To reload from the client, call the generated store action st.do.initXInY() instead, which writes the same data into state.",
45
+ severity = "error"
46
+ )
47
+ }
@@ -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,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.7",
3
+ "version": "3.0.0-alpha.71",
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.7",
48
+ "akanjs": "3.0.0-alpha.71",
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",
@@ -76,4 +76,4 @@
76
76
  "bun": {
77
77
  "platform": "bun"
78
78
  }
79
- }
79
+ }