@akanjs/devkit 3.0.0-alpha.1 → 3.0.0-alpha.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,35 @@
1
+ engine biome(1.0)
2
+ language js(typescript, jsx)
3
+
4
+ // Every prototype method of a `store(...)` class is registered as an action
5
+ // (StoreRegistry.register) and reaches callers only as `st.do.<action>`, which re-wraps it
6
+ // (StoreInstance.#extendAccessors) and is typed void by `VoidActions` in
7
+ // pkgs/akanjs/store/types.ts. A returned value is therefore unreachable from every call
8
+ // site — it reads like a contract while being dead code. Write the result into state with
9
+ // `this.set({ ... })` instead.
10
+ //
11
+ // Deliberately not flagged, because none of these are actions: a bare `return;` guard, a
12
+ // `return` belonging to a nested callback, a getter, a `static` helper, and a class-property
13
+ // arrow (an instance field, so `register` never sees it).
14
+ `return $value;` as $return where {
15
+ not $value <: r"",
16
+ $return <: within JsClassDeclaration() as $storeClass where {
17
+ $storeClass <: contains JsExtendsClause() as $extends where {
18
+ $extends <: r"extends\s+store\([\s\S]*"
19
+ }
20
+ },
21
+ $return <: within JsMethodClassMember() as $action where {
22
+ not $action <: r"static[\s\S]*"
23
+ },
24
+ not $return <: within JsArrowFunctionExpression(),
25
+ not $return <: within JsFunctionExpression(),
26
+ not $return <: within JsFunctionDeclaration(),
27
+ not $return <: within JsMethodObjectMember(),
28
+ not $return <: within JsGetterObjectMember(),
29
+ not $return <: within JsSetterObjectMember(),
30
+ register_diagnostic(
31
+ span = $return,
32
+ message = "Store actions dispatch as void — st.do.<action>() never exposes this value. Write the result into state with this.set({ ... }) and drop the returned value; a bare 'return;' guard clause is fine.",
33
+ severity = "error"
34
+ )
35
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "3.0.0-alpha.1",
3
+ "version": "3.0.0-alpha.10",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -23,6 +23,7 @@
23
23
  "default": "./index.ts"
24
24
  },
25
25
  "./package.json": "./package.json",
26
+ "./biome.base.json": "./biome.base.json",
26
27
  "./akanApp": "./akanApp/index.ts",
27
28
  "./akanConfig": "./akanConfig/index.ts",
28
29
  "./artifact": "./artifact/index.ts",
@@ -44,7 +45,7 @@
44
45
  "@langchain/openai": "^1.4.6",
45
46
  "@tailwindcss/node": "^4.3.0",
46
47
  "@trapezedev/project": "^7.1.4",
47
- "akanjs": "3.0.0-alpha.1",
48
+ "akanjs": "3.0.0-alpha.10",
48
49
  "chalk": "^5.6.2",
49
50
  "commander": "^14.0.3",
50
51
  "dayjs": "^1.11.20",
@@ -183,3 +183,17 @@ describe("AkanQualityScanner ssr rules", () => {
183
183
  expect(ssrBalance[2]).toMatchObject({ scope: "workspace", serverMass: 3, clientMass: 1 });
184
184
  });
185
185
  });
186
+
187
+ describe("AkanQualityScanner layout rules", () => {
188
+ test("flags an unknown app root file but not a facet entrypoint", async () => {
189
+ const root = await makeWorkspace({
190
+ "apps/demo/client.ts": "export const client = 1;\n",
191
+ "apps/demo/helper.ts": "export const helper = 1;\n",
192
+ });
193
+
194
+ const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.layout.app-root-file");
195
+
196
+ expect(warnings).toHaveLength(1);
197
+ expect(warnings[0]?.file).toBe("apps/demo/helper.ts");
198
+ });
199
+ });
package/qualityScanner.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { readdir, readFile, stat } from "node:fs/promises";
3
3
  import path from "node:path";
4
+ import { RESERVED_ROUTE_CONFIG_EXPORTS } from "akanjs/common";
4
5
  import ignore from "ignore";
5
6
  import ts from "typescript";
6
7
  import { AbstractDoc } from "./abstractDoc";
7
8
  import { formatSsrBalance, type SsrBalanceEntry, SsrScanner } from "./ssrScanner";
9
+ import { appRootAllowedFiles, libFacetRootAllowedFiles } from "./workspaceLayout";
8
10
 
9
11
  type QualitySeverity = "warning";
10
12
  type QualityScope = "global" | "file" | "convention" | "layout" | "ssr";
@@ -90,29 +92,6 @@ const SUGGESTED_RULES = [
90
92
  "Avoid large mixed-purpose class files; class export files should import helpers from neighboring utility files instead of declaring them inline.",
91
93
  ];
92
94
 
93
- const APP_ROOT_FILES = new Set([
94
- "akan.app.json",
95
- "akan.config.ts",
96
- "capacitor.config.ts",
97
- "client.ts",
98
- "main.ts",
99
- "package.json",
100
- "server.ts",
101
- "tsconfig.json",
102
- ]);
103
-
104
- const LIB_ROOT_FILES = new Set([
105
- "cnst.ts",
106
- "db.ts",
107
- "dict.ts",
108
- "option.ts",
109
- "sig.ts",
110
- "srv.ts",
111
- "st.ts",
112
- "useClient.ts",
113
- "useServer.ts",
114
- ]);
115
-
116
95
  const CONVENTION_SUFFIXES = [
117
96
  ".constant.ts",
118
97
  ".dictionary.ts",
@@ -122,24 +101,6 @@ const CONVENTION_SUFFIXES = [
122
101
  ".store.ts",
123
102
  ] as const;
124
103
 
125
- // Non-PascalCase exports the framework recognizes on page/layout route modules (see PageModule/LayoutModule
126
- // in pkgs/akanjs/client/csrTypes.ts). PascalCase route exports (Loading, NotFound, Error) pass the component
127
- // check, and the `default` export is handled separately.
128
- const PAGE_RESERVED_EXPORTS = new Set([
129
- "pageConfig",
130
- "head",
131
- "metadata",
132
- "generateHead",
133
- "generateMetadata",
134
- "fonts",
135
- "manifest",
136
- "theme",
137
- "reconnect",
138
- "wsConnect",
139
- "layoutStyle",
140
- "gaTrackingId",
141
- ]);
142
-
143
104
  // How to remediate each rule, keyed by rule id. Surfaced as a `fix:` line per warning (text + JSON output)
144
105
  // so the scan result tells the reader what to do, not just what is wrong.
145
106
  const RULE_FIXES: Record<string, string> = {
@@ -449,7 +410,7 @@ export class AkanQualityScanner {
449
410
  #scanLayoutQuality(sourceFile: SourceFileInfo): QualityWarning[] {
450
411
  const segments = sourceFile.file.split("/");
451
412
  const warnings: QualityWarning[] = [];
452
- if (segments[0] === "apps" && segments.length === 3 && !APP_ROOT_FILES.has(segments[2])) {
413
+ if (segments[0] === "apps" && segments.length === 3 && !appRootAllowedFiles.has(segments[2])) {
453
414
  warnings.push({
454
415
  rule: "akan.layout.app-root-file",
455
416
  scope: "layout",
@@ -460,7 +421,7 @@ export class AkanQualityScanner {
460
421
  }
461
422
 
462
423
  const libRootFile = getLibRootFile(sourceFile.file);
463
- if (libRootFile && !LIB_ROOT_FILES.has(libRootFile)) {
424
+ if (libRootFile && !libFacetRootAllowedFiles.has(libRootFile)) {
464
425
  warnings.push({
465
426
  rule: "akan.layout.lib-root-file",
466
427
  scope: "layout",
@@ -691,7 +652,7 @@ function isRestrictedInternalKind(kind: ComponentFileDeclaration["kind"]) {
691
652
 
692
653
  function isAllowedComponentExport(declaration: ComponentFileDeclaration, isPage: boolean) {
693
654
  if (isComponentValueKind(declaration.kind) && isPascalCaseName(declaration.name)) return true;
694
- return isPage && PAGE_RESERVED_EXPORTS.has(declaration.name);
655
+ return isPage && RESERVED_ROUTE_CONFIG_EXPORTS.has(declaration.name);
695
656
  }
696
657
 
697
658
  function isPascalCaseName(name: string) {
@@ -0,0 +1,42 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import path from "node:path";
3
+
4
+ // Resolved once per root: every CLI command builds a WorkspaceExecutor, and this would otherwise fork git on each.
5
+ const resolved = new Map<string, string>();
6
+
7
+ const readRemoteName = (workspaceRoot: string): string | null => {
8
+ try {
9
+ const url = execFileSync("git", ["config", "--get", "remote.origin.url"], {
10
+ cwd: workspaceRoot,
11
+ encoding: "utf-8",
12
+ stdio: ["ignore", "pipe", "ignore"],
13
+ }).trim();
14
+ // Both remote spellings end in the repository: git@host:owner/name.git and https://host/owner/name.git.
15
+ return (
16
+ url
17
+ .replace(/\.git$/, "")
18
+ .split(/[/:]/)
19
+ .pop() || null
20
+ );
21
+ } catch {
22
+ // No git, no origin, or no git binary — a fresh `akan workspace` before its first commit lands here.
23
+ return null;
24
+ }
25
+ };
26
+
27
+ /**
28
+ * The repository's own name.
29
+ *
30
+ * Deriving it from the working directory made every generated file that names the repo — the AGENTS.md title, its
31
+ * `- Repo:` line — depend on what each person happened to call the folder they cloned into, so one commit rendered
32
+ * a different guide per developer and the diff never settled. The origin remote is the one identity every clone
33
+ * shares. `AKAN_PUBLIC_REPO_NAME` is deliberately not consulted: that is a deployment namespace (queue prefixes,
34
+ * cache keys, secret paths) which a monorepo hosting several products legitimately points somewhere else.
35
+ */
36
+ export const resolveRepoName = (workspaceRoot: string): string => {
37
+ const cached = resolved.get(workspaceRoot);
38
+ if (cached) return cached;
39
+ const repoName = readRemoteName(workspaceRoot) ?? path.basename(workspaceRoot);
40
+ resolved.set(workspaceRoot, repoName);
41
+ return repoName;
42
+ };
@@ -0,0 +1,41 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { RouteSourceValidator } from "./routeSourceValidator";
4
+
5
+ const validate = (source: string, kind: "page" | "layout", rootLayout = false) =>
6
+ RouteSourceValidator.validateRouteSourceExports(source, `page/_${kind}.tsx`, kind, { rootLayout });
7
+
8
+ describe("RouteSourceValidator", () => {
9
+ test("accepts every root-layout config export the route tree honors", () => {
10
+ const source = [
11
+ "export default function Layout() { return null; }",
12
+ "export const fonts = [];",
13
+ "export const manifest = {};",
14
+ 'export const theme = "dark";',
15
+ "export const reconnect = true;",
16
+ "export const wsConnect = true;",
17
+ "export const layoutStyle = {};",
18
+ 'export const gaTrackingId = "G-1";',
19
+ ].join("\n");
20
+
21
+ expect(() => validate(source, "layout", true)).not.toThrow();
22
+ });
23
+
24
+ test("rejects root-layout-only exports on a nested layout and on a page", () => {
25
+ const source = ["export default function Layout() { return null; }", "export const wsConnect = true;"].join("\n");
26
+
27
+ expect(() => validate(source, "layout")).toThrow('unsupported export "wsConnect"');
28
+ expect(() => validate(source, "page")).toThrow('unsupported export "wsConnect"');
29
+ });
30
+
31
+ test("reads devOnly off pageConfig without evaluating the module", () => {
32
+ const source = [
33
+ "export default function Page() { return null; }",
34
+ "export const pageConfig = { devOnly: true };",
35
+ ].join("\n");
36
+
37
+ expect(RouteSourceValidator.validateRouteSourceExports(source, "page/_index.tsx", "page")).toEqual({
38
+ devOnly: true,
39
+ });
40
+ });
41
+ });
@@ -1,3 +1,4 @@
1
+ import { getRouteExports } from "akanjs/common";
1
2
  import ts from "typescript";
2
3
 
3
4
  /** What the build needs out of a route module without evaluating it. */
@@ -16,44 +17,6 @@ export interface RouteSourceInfo {
16
17
  * validate a route source stay lean.
17
18
  */
18
19
  export class RouteSourceValidator {
19
- static readonly #pageExports = new Set([
20
- "default",
21
- "pageConfig",
22
- "head",
23
- "metadata",
24
- "generateHead",
25
- "generateMetadata",
26
- "Loading",
27
- ]);
28
- static readonly #rootLayoutExports = new Set([
29
- "default",
30
- "pageConfig",
31
- "head",
32
- "metadata",
33
- "generateHead",
34
- "generateMetadata",
35
- "fonts",
36
- "manifest",
37
- "theme",
38
- "reconnect",
39
- "layoutStyle",
40
- "gaTrackingId",
41
- "Loading",
42
- "NotFound",
43
- "Error",
44
- ]);
45
- static readonly #layoutExports = new Set([
46
- "default",
47
- "pageConfig",
48
- "head",
49
- "metadata",
50
- "generateHead",
51
- "generateMetadata",
52
- "Loading",
53
- "NotFound",
54
- "Error",
55
- ]);
56
-
57
20
  static validateRouteSourceExports(
58
21
  source: string,
59
22
  filePath: string,
@@ -61,12 +24,7 @@ export class RouteSourceValidator {
61
24
  options: { rootLayout?: boolean } = {},
62
25
  ): RouteSourceInfo {
63
26
  const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
64
- const allowed =
65
- kind === "page"
66
- ? RouteSourceValidator.#pageExports
67
- : options.rootLayout
68
- ? RouteSourceValidator.#rootLayoutExports
69
- : RouteSourceValidator.#layoutExports;
27
+ const allowed = getRouteExports(kind, { rootLayout: options.rootLayout });
70
28
  const exported = new Set<string>();
71
29
  const assertExport = (name: string) => {
72
30
  if (!allowed.has(name)) {
package/scanInfo.ts CHANGED
@@ -11,6 +11,7 @@ import type {
11
11
  } from "./akanConfig";
12
12
 
13
13
  import { AppExecutor, LibExecutor, PkgExecutor, WorkspaceExecutor } from "./executors";
14
+ import { appRootAllowedDirs, appRootAllowedFiles, libFacetRootAllowedFiles } from "./workspaceLayout";
14
15
 
15
16
  const scalarFileTypes = ["constant", "dictionary", "document", "template", "unit", "util", "view", "zone"] as const;
16
17
  type ScalarFileType = (typeof scalarFileTypes)[number];
@@ -43,49 +44,7 @@ type DatabaseFileType = (typeof databaseFileTypes)[number];
43
44
 
44
45
  type ModuleKind = "database" | "service" | "scalar";
45
46
 
46
- const appRootAllowedFiles = new Set([
47
- // 스코프 에이전트 가이드 — scan(write) 이 유지하는 색인 + 마커 밖 hand-written 내용 (agentsIndex.ts)
48
- "AGENTS.md",
49
- "CLAUDE.md",
50
- "akan.app.json",
51
- "akan.config.ts",
52
- "capacitor.config.ts",
53
- "client.ts",
54
- "main.ts",
55
- "package.json",
56
- "server.ts",
57
- "tsconfig.json",
58
- "tsconfig.tsbuildinfo",
59
- ]);
60
47
  const generatedRootCapacitorConfigFiles = ["capacitor.config.js", "capacitor.config.json"] as const;
61
- const appRootAllowedDirs = new Set([
62
- ".akan",
63
- "android",
64
- "env",
65
- "ios",
66
- "lib",
67
- "mobile",
68
- "page",
69
- "private",
70
- "public",
71
- "script",
72
- "ui",
73
- "srvkit",
74
- "webkit",
75
- "common",
76
- "secrets",
77
- ]);
78
- const libRootAllowedFiles = new Set([
79
- "cnst.ts",
80
- "db.ts",
81
- "dict.ts",
82
- "option.ts",
83
- "sig.ts",
84
- "srv.ts",
85
- "st.ts",
86
- "useClient.ts",
87
- "useServer.ts",
88
- ]);
89
48
  const internalLibDirs = new Set(["__lib", "__scalar"]);
90
49
  const moduleNonUiFileTypes = {
91
50
  database: new Set(["constant", "dictionary", "document", "service", "signal", "store"]),
@@ -107,7 +66,7 @@ const createDependencyScanner = async (exec: AppExecutor | LibExecutor | PkgExec
107
66
 
108
67
  const isAllowedTestFile = (filename: string) => testFilePattern.test(filename);
109
68
  const isAllowedLibRootFile = (filename: string) =>
110
- libRootAllowedFiles.has(filename) || rootSignalTestFilePattern.test(filename);
69
+ libFacetRootAllowedFiles.has(filename) || rootSignalTestFilePattern.test(filename);
111
70
  const getScanPath = (exec: AppExecutor | LibExecutor, relativePath: string) =>
112
71
  path.posix.join(`${exec.type}s`, exec.name, relativePath.split(path.sep).join("/"));
113
72
  async function clearGeneratedRootCapacitorConfigs(exec: AppExecutor | LibExecutor) {
@@ -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,60 @@
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 의 목록도 같이 갱신한다.
8
+ */
9
+
10
+ export const appRootAllowedFiles = new Set([
11
+ // 스코프 에이전트 가이드 — scan(write) 이 유지하는 색인 + 마커 밖 hand-written 내용 (agentsIndex.ts)
12
+ "AGENTS.md",
13
+ "CLAUDE.md",
14
+ "akan.app.json",
15
+ "akan.config.ts",
16
+ "capacitor.config.ts",
17
+ "client.ts",
18
+ "main.ts",
19
+ "package.json",
20
+ "server.ts",
21
+ "tsconfig.json",
22
+ "tsconfig.tsbuildinfo",
23
+ ]);
24
+
25
+ export const appRootAllowedDirs = new Set([
26
+ ".akan",
27
+ "android",
28
+ "common",
29
+ "env",
30
+ "ios",
31
+ "lib",
32
+ "mobile",
33
+ "page",
34
+ "plugin",
35
+ "private",
36
+ "public",
37
+ "script",
38
+ "secrets",
39
+ "srvkit",
40
+ "ui",
41
+ "webkit",
42
+ ]);
43
+
44
+ export const libFacetRootAllowedFiles = new Set([
45
+ "cnst.ts",
46
+ "db.ts",
47
+ "dict.ts",
48
+ "option.ts",
49
+ "sig.ts",
50
+ "srv.ts",
51
+ "st.ts",
52
+ "useClient.ts",
53
+ "useServer.ts",
54
+ ]);
55
+
56
+ /**
57
+ * scanSync 는 앱 루트를 `Bun.Glob("*")` 로 읽어 dotfile 을 아예 보지 못한다. 디렉터리를 직접 읽는
58
+ * doctor 가 그 차이만큼 `.DS_Store` 같은 툴 산출물을 에러로 올리므로 같은 기준으로 걸러낸다.
59
+ */
60
+ export const isScannedAppRootEntry = (name: string) => !name.startsWith(".") || appRootAllowedDirs.has(name);