@akanjs/devkit 3.0.0-alpha.45 → 3.0.0-alpha.47

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.
@@ -3,6 +3,7 @@ import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { AppExecutor, WorkspaceExecutor } from "../executors";
6
7
  import type { PackageJson } from "../types";
7
8
  import { AkanAppConfig, AkanLibConfig, deriveDefaultAppId } from "./akanConfig";
8
9
  import type { DeepPartial, LibConfigResult } from "./types";
@@ -382,6 +383,52 @@ describe("deriveDefaultAppId", () => {
382
383
  });
383
384
  });
384
385
 
386
+ describe("AkanAppConfig lib externalLibs", () => {
387
+ test("merges lib-declared external libs into the app's own, deduped", () => {
388
+ const libAwarePackageJson: PackageJson = {
389
+ ...packageJson,
390
+ dependencies: { ...packageJson.dependencies, puppeteer: "24.0.0" },
391
+ };
392
+ const config = new AkanAppConfig(
393
+ app,
394
+ ["shared"],
395
+ libAwarePackageJson,
396
+ { externalLibs: ["@external/runtime"] },
397
+ baseDevEnv,
398
+ [],
399
+ ["@external/runtime", "puppeteer"],
400
+ );
401
+
402
+ expect(config.externalLibs).toEqual(["@external/runtime", "puppeteer"]);
403
+ expect(config.getProductionPackageJson().dependencies).toMatchObject({
404
+ "@external/runtime": "2.0.0",
405
+ puppeteer: "24.0.0",
406
+ });
407
+ });
408
+
409
+ test("reads them off every workspace lib config on load", async () => {
410
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "akan-config-libext-"));
411
+ try {
412
+ fs.mkdirSync(path.join(root, "apps/extapp"), { recursive: true });
413
+ fs.mkdirSync(path.join(root, "libs/extlib"), { recursive: true });
414
+ fs.writeFileSync(path.join(root, "apps/extapp/akan.config.ts"), "export default { externalLibs: ['shiki'] };\n");
415
+ fs.writeFileSync(
416
+ path.join(root, "libs/extlib/akan.config.ts"),
417
+ "export default { externalLibs: ['puppeteer'] };\n",
418
+ );
419
+ fs.writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "extrepo", version: "1.0.0" }));
420
+ fs.writeFileSync(path.join(root, ".env"), "AKAN_PUBLIC_REPO_NAME=extrepo\nAKAN_PUBLIC_SERVE_DOMAIN=ext.test\n");
421
+
422
+ const workspace = WorkspaceExecutor.fromRoot({ workspaceRoot: root, repoName: "extrepo" });
423
+ const config = await AppExecutor.from(workspace, "extapp").getConfig();
424
+
425
+ expect(config.externalLibs).toEqual(["shiki", "puppeteer"]);
426
+ } finally {
427
+ fs.rmSync(root, { recursive: true, force: true });
428
+ }
429
+ });
430
+ });
431
+
385
432
  describe("AkanLibConfig", () => {
386
433
  test("uses empty external libs by default and preserves explicit libs", () => {
387
434
  const lib = { name: "shared" } as never;
@@ -5,7 +5,7 @@ import type { AkanPlugin } from "akanjs";
5
5
  import { type AkanI18nConfig, resolveAkanI18nConfig } from "akanjs/common";
6
6
  import type { AkanImageConfig } from "akanjs/server";
7
7
  import type { App, Lib } from "../commandDecorators";
8
- import { WorkspaceExecutor } from "../executors";
8
+ import { LibExecutor, WorkspaceExecutor } from "../executors";
9
9
  import type { BaseDevEnv, PackageJson } from "../types";
10
10
  import {
11
11
  type AkanMobileConfig,
@@ -195,6 +195,7 @@ export class AkanAppConfig implements AppConfigResult {
195
195
  config: DeepPartial<AppConfigResult>,
196
196
  baseDevEnv: BaseDevEnv,
197
197
  plugins: AkanPlugin[] = [],
198
+ libExternalLibs: readonly string[] = [],
198
199
  ) {
199
200
  this.app = app;
200
201
  this.rootPackageJson = rootPackageJson;
@@ -203,7 +204,7 @@ export class AkanAppConfig implements AppConfigResult {
203
204
  this.plugins = plugins;
204
205
  this.#applyRoutes(config?.routes);
205
206
  this.defaultDatabaseMode = config?.defaultDatabaseMode ?? "single";
206
- this.externalLibs = config?.externalLibs ?? [];
207
+ this.externalLibs = [...new Set([...(config?.externalLibs ?? []), ...libExternalLibs])];
207
208
  this.barrelImports = [
208
209
  ...DEFAULT_BARREL_IMPORTS,
209
210
  ...WORKSPACE_BARREL_FACETS.map((facet) => `@apps/${app.name}/${facet}`),
@@ -412,7 +413,23 @@ CMD [${command.map((c) => `"${c}"`).join(",")}]`;
412
413
  ]);
413
414
  const resolved = typeof configImp === "function" ? configImp(app) : configImp;
414
415
  const { plugins, ...config } = (resolved ?? {}) as DeepPartial<AppConfigResult> & { plugins?: AkanPlugin[] };
415
- return new AkanAppConfig(app, libs, rootPackageJson, config, baseDevEnv, plugins ?? []);
416
+ const libExternalLibs = await AkanAppConfig.#collectLibExternalLibs(app, libs, bustImportCache);
417
+ return new AkanAppConfig(app, libs, rootPackageJson, config, baseDevEnv, plugins ?? [], libExternalLibs);
418
+ }
419
+ //* Every workspace lib is read, not just this app's lib deps: narrowing the set needs the dependency
420
+ //* scan, and the incremental page rebundle re-reads this config on every file change.
421
+ static async #collectLibExternalLibs(app: App, libs: string[], bustImportCache: boolean): Promise<string[]> {
422
+ const libConfigs = await Promise.all(
423
+ libs.map(async (libName) =>
424
+ LibExecutor.from(app, libName)
425
+ .getConfig({ refresh: bustImportCache })
426
+ .catch((error: unknown) => {
427
+ app.logger.warn(`Skipped libs/${libName}/akan.config.ts externalLibs: ${String(error)}`);
428
+ return null;
429
+ }),
430
+ ),
431
+ );
432
+ return libConfigs.flatMap((libConfig) => libConfig?.externalLibs ?? []);
416
433
  }
417
434
  #resolveProductionDependencyVersion(lib: string) {
418
435
  const rootVersion = this.rootPackageJson.dependencies?.[lib] ?? this.rootPackageJson.devDependencies?.[lib];
@@ -529,8 +546,8 @@ export class AkanLibConfig implements LibConfigResult {
529
546
  this.externalLibs = config?.externalLibs ?? [];
530
547
  this.plugins = plugins;
531
548
  }
532
- static async from(lib: Lib) {
533
- const [configImp] = await Promise.all([import(`${lib.cwdPath}/akan.config.ts`).then((mod) => mod.default)]);
549
+ static async from(lib: Lib, { bustImportCache = false }: { bustImportCache?: boolean } = {}) {
550
+ const configImp = await AkanAppConfig.importConfigModule(lib.cwdPath, { bustImportCache });
534
551
  const resolved = typeof configImp === "function" ? configImp(lib) : configImp;
535
552
  const { plugins, ...config } = (resolved ?? {}) as DeepPartial<LibConfigResult> & { plugins?: AkanPlugin[] };
536
553
  return new AkanLibConfig(lib, config, plugins ?? []);
package/akanContext.ts CHANGED
@@ -15,7 +15,7 @@ import {
15
15
  workflowRunArtifactPath,
16
16
  workflowSyncDir,
17
17
  } from "./workflow";
18
- import { appRootAllowedDirs, appRootAllowedFiles, isScannedAppRootEntry } from "./workspaceLayout";
18
+ import { isScannedRootEntry, rootAllowedDirs, rootAllowedFiles } from "./workspaceLayout";
19
19
 
20
20
  export type AkanContextFormat = "json" | "markdown";
21
21
  export type AkanModuleKind = "domain" | "service" | "scalar";
@@ -666,23 +666,25 @@ export class AkanContextAnalyzer {
666
666
  ),
667
667
  ];
668
668
 
669
- for (const app of context.apps) {
670
- const appPath = path.join(workspace.workspaceRoot, app.path);
671
- for (const entry of await safeReadDir(appPath)) {
672
- if (!isScannedAppRootEntry(entry.name)) continue;
673
- const allowed = entry.isDirectory() ? appRootAllowedDirs.has(entry.name) : appRootAllowedFiles.has(entry.name);
669
+ for (const sys of [...context.apps, ...context.libs]) {
670
+ const sysPath = path.join(workspace.workspaceRoot, sys.path);
671
+ for (const entry of await safeReadDir(sysPath)) {
672
+ if (!isScannedRootEntry(sys.type, entry.name)) continue;
673
+ const allowed = entry.isDirectory()
674
+ ? rootAllowedDirs[sys.type].has(entry.name)
675
+ : rootAllowedFiles[sys.type].has(entry.name);
674
676
  if (!allowed) {
675
677
  const action = repairAction(
676
678
  "module-shape",
677
- `akan repair module-shape --app ${app.name}`,
678
- "Review app root shape and remove or move the unknown entry.",
679
+ `akan repair module-shape --app ${sys.name}`,
680
+ `Review ${sys.type} root shape and remove or move the unknown entry.`,
679
681
  false,
680
682
  );
681
683
  diagnostics.push({
682
684
  severity: "error",
683
- code: "app-root-unknown-entry",
684
- path: `${app.path}/${entry.name}`,
685
- message: `Unexpected ${entry.isDirectory() ? "folder" : "file"} in app root: ${app.path}/${entry.name}`,
685
+ code: `${sys.type}-root-unknown-entry`,
686
+ path: `${sys.path}/${entry.name}`,
687
+ message: `Unexpected ${entry.isDirectory() ? "folder" : "file"} in ${sys.type} root: ${sys.path}/${entry.name}`,
686
688
  repairActions: [action],
687
689
  });
688
690
  repairActions.push(action);
package/executors.ts CHANGED
@@ -23,6 +23,7 @@ import { pathToFileURL } from "node:url";
23
23
  import type { AkanPlugin, AkanSyncContext, PluginRuntimeContext } from "akanjs";
24
24
  import {
25
25
  capitalize,
26
+ getPageSourceFileViolation,
26
27
  isRouteSourceFile,
27
28
  Logger,
28
29
  parseRouteModuleKey,
@@ -1006,6 +1007,16 @@ export class SysExecutor extends Executor {
1006
1007
  const path = this.type === "app" ? `apps/${this.name}/lib` : `libs/${this.name}/lib`;
1007
1008
  return await this.workspace.getDirInModule(path, this.name);
1008
1009
  }
1010
+ async getPageConventionViolations(): Promise<{ relativePath: string; reason: string }[]> {
1011
+ if (!(await this.exists("page"))) return [];
1012
+ const violations: { relativePath: string; reason: string }[] = [];
1013
+ for (const entry of await this.getAllFiles("**/*", { cwd: this.getPath("page") })) {
1014
+ const relativePath = entry.split(path.sep).join("/");
1015
+ const reason = getPageSourceFileViolation(relativePath);
1016
+ if (reason) violations.push({ relativePath: `page/${relativePath}`, reason });
1017
+ }
1018
+ return violations;
1019
+ }
1009
1020
 
1010
1021
  #scanInfo: AppInfo | LibInfo | null = null;
1011
1022
  hasScanInfo() {
@@ -1707,7 +1718,7 @@ export class LibExecutor extends SysExecutor {
1707
1718
  #akanConfig: AkanLibConfig | null = null;
1708
1719
  override async getConfig({ refresh }: { refresh?: boolean } = {}) {
1709
1720
  if (this.#akanConfig && !refresh) return this.#akanConfig;
1710
- this.#akanConfig = await AkanLibConfig.from(this);
1721
+ this.#akanConfig = await AkanLibConfig.from(this, { bustImportCache: refresh });
1711
1722
  return this.#akanConfig;
1712
1723
  }
1713
1724
  }
@@ -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, {
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "3.0.0-alpha.45",
3
+ "version": "3.0.0-alpha.47",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -45,7 +45,7 @@
45
45
  "@langchain/openai": "^1.4.6",
46
46
  "@tailwindcss/node": "^4.3.0",
47
47
  "@trapezedev/project": "^7.1.4",
48
- "akanjs": "3.0.0-alpha.45",
48
+ "akanjs": "3.0.0-alpha.47",
49
49
  "chalk": "^5.6.2",
50
50
  "commander": "^14.0.3",
51
51
  "dayjs": "^1.11.20",
@@ -205,4 +205,47 @@ describe("AkanQualityScanner layout rules", () => {
205
205
  expect(warnings).toHaveLength(1);
206
206
  expect(warnings[0]?.file).toBe("apps/demo/helper.ts");
207
207
  });
208
+
209
+ test("flags an unknown lib root file but not a facet entrypoint", async () => {
210
+ const root = await makeWorkspace({
211
+ "libs/demo/client.ts": "export const client = 1;\n",
212
+ "libs/demo/helper.ts": "export const helper = 1;\n",
213
+ });
214
+
215
+ const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.layout.lib-root-file");
216
+
217
+ expect(warnings).toHaveLength(1);
218
+ expect(warnings[0]?.file).toBe("libs/demo/helper.ts");
219
+ });
220
+
221
+ test("flags a root folder no facet owns, on both sides", async () => {
222
+ const root = await makeWorkspace({
223
+ "apps/demo/base/helper.ts": "export const helper = 1;\n",
224
+ "apps/demo/common/helper.ts": "export const helper = 1;\n",
225
+ "libs/demo/base/helper.ts": "export const helper = 1;\n",
226
+ "libs/demo/common/helper.ts": "export const helper = 1;\n",
227
+ });
228
+
229
+ const result = await new AkanQualityScanner().scan(root);
230
+
231
+ expect(rulesOf(result, "akan.layout.app-root-folder").map((warning) => warning.file)).toEqual([
232
+ "apps/demo/base/helper.ts",
233
+ ]);
234
+ expect(rulesOf(result, "akan.layout.lib-root-folder").map((warning) => warning.file)).toEqual([
235
+ "libs/demo/base/helper.ts",
236
+ ]);
237
+ });
238
+
239
+ test("keeps a root signal test out of the lib facet rule", async () => {
240
+ const root = await makeWorkspace({
241
+ "libs/demo/lib/cnst.ts": "export const cnst = 1;\n",
242
+ "libs/demo/lib/user.signal.test.ts": "export const test = 1;\n",
243
+ "libs/demo/lib/helper.ts": "export const helper = 1;\n",
244
+ });
245
+
246
+ const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.layout.lib-facet-file");
247
+
248
+ expect(warnings).toHaveLength(1);
249
+ expect(warnings[0]?.file).toBe("libs/demo/lib/helper.ts");
250
+ });
208
251
  });
package/qualityScanner.ts CHANGED
@@ -7,7 +7,7 @@ import ts from "typescript";
7
7
  import { AbstractDoc } from "./abstractDoc";
8
8
  import { FormSetterScanner } from "./formSetterScanner";
9
9
  import { formatSsrBalance, type SsrBalanceEntry, SsrScanner } from "./ssrScanner";
10
- import { appRootAllowedFiles, libFacetRootAllowedFiles } from "./workspaceLayout";
10
+ import { isAllowedLibFacetRootFile, rootAllowedDirs, rootAllowedFiles } from "./workspaceLayout";
11
11
 
12
12
  type QualitySeverity = "warning";
13
13
  type QualityScope = "global" | "file" | "convention" | "layout" | "ssr" | "agent";
@@ -90,7 +90,7 @@ const SUGGESTED_RULES = [
90
90
  "Use domain module folders consistently: lib/<model> for database modules, lib/_<service> for service modules, and lib/__scalar/<scalar> for scalar modules.",
91
91
  "Keep module UI filenames predictable: database modules use <Model>.Template/Unit/Util/View/Zone.tsx, service modules use <Service>.Util/Zone.tsx, and scalar modules use <Scalar>.Template/Unit.tsx.",
92
92
  "Hand a form control its store setter by reference so the field publishes an agent tool; put normalization in the control's transform prop and a multi-write in a _postSet<Field> hook on the store.",
93
- "Move shared app utilities to apps/*/common instead of creating apps/*/base.",
93
+ "Move shared app and lib utilities to common/ instead of creating apps/*/base or libs/*/base.",
94
94
  "Avoid large mixed-purpose class files; class export files should import helpers from neighboring utility files instead of declaring them inline.",
95
95
  ];
96
96
 
@@ -132,8 +132,14 @@ const RULE_FIXES: Record<string, string> = {
132
132
  "Move the value or type to a util/constant/type file in ui/, webkit/, or common/ and import it. Adding `export` is not a valid fix — only PascalCase components and their `<Component>Props` interface belong here.",
133
133
  "akan.layout.app-root-file":
134
134
  "Move the file into a conventional app folder (common, env, lib, page, private, public, script, srvkit, ui, or webkit).",
135
+ "akan.layout.app-root-folder":
136
+ "Move the folder's contents into a conventional app folder (common, env, lib, page, private, public, script, srvkit, ui, or webkit) and delete it.",
135
137
  "akan.layout.lib-root-file":
136
- "Move the file into a domain module folder under lib/; keep lib root limited to generated support facets.",
138
+ "Move the file into a conventional lib folder (common, env, lib, page, private, public, srvkit, ui, or webkit).",
139
+ "akan.layout.lib-root-folder":
140
+ "Move the folder's contents into a conventional lib folder (common, env, lib, page, private, public, srvkit, ui, or webkit) and delete it.",
141
+ "akan.layout.lib-facet-file":
142
+ "Move the file into a domain module folder under lib/; keep the lib facet root limited to generated support facets.",
137
143
  "akan.layout.module-ui-file":
138
144
  "Rename the file to an allowed module UI name, or move it to ui/ if it is not a module component.",
139
145
  "akan.ssr.unnecessary-use-client":
@@ -413,26 +419,31 @@ export class AkanQualityScanner {
413
419
  }
414
420
 
415
421
  #scanLayoutQuality(sourceFile: SourceFileInfo): QualityWarning[] {
416
- const segments = sourceFile.file.split("/");
417
422
  const warnings: QualityWarning[] = [];
418
- if (segments[0] === "apps" && segments.length === 3 && !appRootAllowedFiles.has(segments[2])) {
419
- warnings.push({
420
- rule: "akan.layout.app-root-file",
421
- scope: "layout",
422
- severity: "warning",
423
- file: sourceFile.file,
424
- message: `Unexpected app root file "${segments[2]}". Keep application code in conventional app folders.`,
425
- });
423
+ const rootEntry = getSysRootEntry(sourceFile.file);
424
+ if (rootEntry) {
425
+ const { type, name, isDir } = rootEntry;
426
+ const allowed = isDir ? rootAllowedDirs[type].has(name) : rootAllowedFiles[type].has(name);
427
+ const kind = isDir ? "folder" : "file";
428
+ if (!allowed) {
429
+ warnings.push({
430
+ rule: `akan.layout.${type}-root-${kind}`,
431
+ scope: "layout",
432
+ severity: "warning",
433
+ file: sourceFile.file,
434
+ message: `Unexpected ${type} root ${kind} "${name}". Keep ${type} code in conventional ${type} folders.`,
435
+ });
436
+ }
426
437
  }
427
438
 
428
- const libRootFile = getLibRootFile(sourceFile.file);
429
- if (libRootFile && !libFacetRootAllowedFiles.has(libRootFile)) {
439
+ const libFacetFile = getLibFacetRootFile(sourceFile.file);
440
+ if (libFacetFile && !isAllowedLibFacetRootFile(libFacetFile)) {
430
441
  warnings.push({
431
- rule: "akan.layout.lib-root-file",
442
+ rule: "akan.layout.lib-facet-file",
432
443
  scope: "layout",
433
444
  severity: "warning",
434
445
  file: sourceFile.file,
435
- message: `Unexpected lib root file "${libRootFile}". Keep direct lib root files limited to generated support facets.`,
446
+ message: `Unexpected lib facet root file "${libFacetFile}". Keep direct lib/ root files limited to generated support facets.`,
436
447
  });
437
448
  }
438
449
 
@@ -826,7 +837,19 @@ function getConventionDescription(suffix: (typeof CONVENTION_SUFFIXES)[number],
826
837
  return `${modelName}Store`;
827
838
  }
828
839
 
829
- function getLibRootFile(file: string) {
840
+ const sysRootTypes = { apps: "app", libs: "lib" } as const;
841
+
842
+ function getSysRootEntry(file: string) {
843
+ const segments = file.split("/");
844
+ const root = segments[0];
845
+ const name = segments[2];
846
+ if (!root || !name || segments.length < 3) return null;
847
+ const type = sysRootTypes[root as keyof typeof sysRootTypes];
848
+ if (!type) return null;
849
+ return { type, name, isDir: segments.length > 3 };
850
+ }
851
+
852
+ function getLibFacetRootFile(file: string) {
830
853
  const segments = file.split("/");
831
854
  if ((segments[0] === "libs" || segments[0] === "apps") && segments.length === 4 && segments[2] === "lib")
832
855
  return segments[3];
package/scanInfo.ts CHANGED
@@ -9,9 +9,8 @@ import type {
9
9
  PkgScanResult,
10
10
  ScanResult,
11
11
  } from "./akanConfig";
12
-
13
12
  import { AppExecutor, LibExecutor, PkgExecutor, WorkspaceExecutor } from "./executors";
14
- import { appRootAllowedDirs, appRootAllowedFiles, libFacetRootAllowedFiles } from "./workspaceLayout";
13
+ import { isAllowedLibFacetRootFile, rootAllowedDirs, rootAllowedFiles } from "./workspaceLayout";
15
14
 
16
15
  const scalarFileTypes = ["constant", "dictionary", "document", "template", "unit", "util", "view", "zone"] as const;
17
16
  type ScalarFileType = (typeof scalarFileTypes)[number];
@@ -57,7 +56,6 @@ const moduleUiFileTypes = {
57
56
  scalar: new Set(["Template", "Unit"]),
58
57
  } satisfies Record<ModuleKind, Set<string>>;
59
58
  const testFilePattern = /\.(test|spec)\.(ts|tsx)$/;
60
- const rootSignalTestFilePattern = /^[A-Za-z][A-Za-z0-9_-]*\.signal\.(test|spec)\.(ts|tsx)$/;
61
59
 
62
60
  // The dependency scanner needs `typescript` (~65MB resident). Only the scan/sync commands run it, so
63
61
  // load it on demand rather than through the module graph of every process that imports scan info.
@@ -65,8 +63,6 @@ const createDependencyScanner = async (exec: AppExecutor | LibExecutor | PkgExec
65
63
  (await import("./dependencyScanner")).TypeScriptDependencyScanner.from(exec);
66
64
 
67
65
  const isAllowedTestFile = (filename: string) => testFilePattern.test(filename);
68
- const isAllowedLibRootFile = (filename: string) =>
69
- libFacetRootAllowedFiles.has(filename) || rootSignalTestFilePattern.test(filename);
70
66
  const getScanPath = (exec: AppExecutor | LibExecutor, relativePath: string) =>
71
67
  path.posix.join(`${exec.type}s`, exec.name, relativePath.split(path.sep).join("/"));
72
68
  async function clearGeneratedRootCapacitorConfigs(exec: AppExecutor | LibExecutor) {
@@ -85,24 +81,29 @@ async function assertScanConvention(exec: AppExecutor | LibExecutor, libRoot: {
85
81
  violations.push(`${getScanPath(exec, relativePath)}: ${reason}`);
86
82
  };
87
83
 
88
- if (exec.type === "app") {
89
- const { files, dirs } = await exec.getFilesAndDirs(".");
90
- files
91
- .filter((filename) => !appRootAllowedFiles.has(filename))
92
- .forEach((filename) => {
93
- addViolation(filename, "unsupported app root file");
94
- });
95
- dirs
96
- .filter((dirname) => !appRootAllowedDirs.has(dirname))
97
- .forEach((dirname) => {
98
- addViolation(dirname, "unsupported app root folder");
99
- });
100
- }
84
+ const allowedRootFiles: ReadonlySet<string> = rootAllowedFiles[exec.type];
85
+ const allowedRootDirs: ReadonlySet<string> = rootAllowedDirs[exec.type];
86
+ const { files, dirs } = await exec.getFilesAndDirs(".");
87
+ files
88
+ .filter((filename) => !allowedRootFiles.has(filename))
89
+ .forEach((filename) => {
90
+ addViolation(filename, `unsupported ${exec.type} root file`);
91
+ });
92
+ dirs
93
+ .filter((dirname) => !allowedRootDirs.has(dirname))
94
+ .forEach((dirname) => {
95
+ addViolation(dirname, `unsupported ${exec.type} root folder`);
96
+ });
97
+
98
+ //* An app validates its page tree in `getPageKeys`, which a lib has no equivalent of — so a lib's own
99
+ //* routes went unchecked until whichever app opted into them with `syncPageLibs` blew up on the sync.
100
+ if (exec.type === "lib")
101
+ for (const { relativePath, reason } of await exec.getPageConventionViolations()) addViolation(relativePath, reason);
101
102
 
102
103
  libRoot.files
103
- .filter((filename) => !isAllowedLibRootFile(filename))
104
+ .filter((filename) => !isAllowedLibFacetRootFile(filename))
104
105
  .forEach((filename) => {
105
- addViolation(path.join("lib", filename), "unsupported lib root file");
106
+ addViolation(path.join("lib", filename), "unsupported lib facet root file");
106
107
  });
107
108
 
108
109
  libRoot.dirs
@@ -1,5 +1,12 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { appRootAllowedDirs, appRootAllowedFiles, isScannedAppRootEntry } from "./workspaceLayout";
2
+ import {
3
+ appRootAllowedDirs,
4
+ appRootAllowedFiles,
5
+ isAllowedLibFacetRootFile,
6
+ isScannedRootEntry,
7
+ libRootAllowedDirs,
8
+ libRootAllowedFiles,
9
+ } from "./workspaceLayout";
3
10
 
4
11
  describe("app root layout allowlist", () => {
5
12
  test("admits the scoped agent guides sync writes into every app", () => {
@@ -19,8 +26,53 @@ describe("app root layout allowlist", () => {
19
26
  });
20
27
 
21
28
  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);
29
+ expect(isScannedRootEntry("app", ".DS_Store")).toBe(false);
30
+ expect(isScannedRootEntry("app", ".akan")).toBe(true);
31
+ expect(isScannedRootEntry("app", "lib")).toBe(true);
32
+ });
33
+ });
34
+
35
+ describe("lib root layout allowlist", () => {
36
+ test("admits what the libRoot template and scan write", () => {
37
+ for (const filename of ["AGENTS.md", "akan.config.ts", "akan.lib.json", "client.ts", "index.ts", "server.ts"]) {
38
+ expect(libRootAllowedFiles.has(filename)).toBe(true);
39
+ }
40
+ for (const dirname of ["common", "env", "lib", "page", "plugin", "private", "public", "srvkit", "ui", "webkit"]) {
41
+ expect(libRootAllowedDirs.has(dirname)).toBe(true);
42
+ }
43
+ });
44
+
45
+ test("rejects a lib root entry no facet owns", () => {
46
+ expect(libRootAllowedFiles.has("helper.ts")).toBe(false);
47
+ expect(libRootAllowedDirs.has("base")).toBe(false);
48
+ });
49
+
50
+ test("rejects the app-only run and mobile entries", () => {
51
+ for (const filename of ["main.ts", "capacitor.config.ts", "akan.app.json"]) {
52
+ expect(libRootAllowedFiles.has(filename)).toBe(false);
53
+ }
54
+ for (const dirname of [".akan", "android", "ios", "mobile", "script", "secrets"]) {
55
+ expect(libRootAllowedDirs.has(dirname)).toBe(false);
56
+ }
57
+ });
58
+
59
+ test("skips dotfile artifacts in a lib root too", () => {
60
+ expect(isScannedRootEntry("lib", ".gitignore")).toBe(false);
61
+ expect(isScannedRootEntry("lib", ".akan")).toBe(false);
62
+ expect(isScannedRootEntry("lib", "ui")).toBe(true);
63
+ });
64
+ });
65
+
66
+ describe("lib facet root allowlist", () => {
67
+ test("admits the generated support facets and a root signal test", () => {
68
+ expect(isAllowedLibFacetRootFile("cnst.ts")).toBe(true);
69
+ expect(isAllowedLibFacetRootFile("option.ts")).toBe(true);
70
+ expect(isAllowedLibFacetRootFile("user.signal.test.ts")).toBe(true);
71
+ expect(isAllowedLibFacetRootFile("user.signal.spec.ts")).toBe(true);
72
+ });
73
+
74
+ test("rejects a hand-written file beside the barrels", () => {
75
+ expect(isAllowedLibFacetRootFile("helper.ts")).toBe(false);
76
+ expect(isAllowedLibFacetRootFile("user.test.ts")).toBe(false);
25
77
  });
26
78
  });
@@ -7,6 +7,8 @@
7
7
  * 규칙을 추가할 때는 이 파일만 고치고, 루트 AGENTS.md 의 목록도 같이 갱신한다.
8
8
  */
9
9
 
10
+ export type SysType = "app" | "lib";
11
+
10
12
  export const appRootAllowedFiles = new Set([
11
13
  // 스코프 에이전트 가이드 — scan(write) 이 유지하는 색인 + 마커 밖 hand-written 내용 (agentsIndex.ts)
12
14
  "AGENTS.md",
@@ -41,6 +43,43 @@ export const appRootAllowedDirs = new Set([
41
43
  "webkit",
42
44
  ]);
43
45
 
46
+ /**
47
+ * 라이브러리 루트는 앱 루트에서 실행/모바일 전용 항목(`main.ts`, `capacitor.config.ts`, `.akan`,
48
+ * `android`, `ios`, `mobile`, `script`, `secrets`)을 뺀 집합이다 — 라이브러리는 부팅되지도, 패키징되지도
49
+ * 않는다. `index.ts` 는 lib 에만 생성되고, `README.md` / `tsconfig.spec.json` 은 라이브러리가 패키지로
50
+ * 배포되기 때문에 남는다. `public` / `private` 은 syncAssets 가 앱으로 심링크하므로 라이브러리도 가진다.
51
+ */
52
+ export const libRootAllowedFiles = new Set([
53
+ "AGENTS.md",
54
+ "CLAUDE.md",
55
+ "README.md",
56
+ "akan.config.ts",
57
+ "akan.lib.json",
58
+ "client.ts",
59
+ "index.ts",
60
+ "package.json",
61
+ "server.ts",
62
+ "tsconfig.json",
63
+ "tsconfig.spec.json",
64
+ "tsconfig.tsbuildinfo",
65
+ ]);
66
+
67
+ export const libRootAllowedDirs = new Set([
68
+ "common",
69
+ "env",
70
+ "lib",
71
+ "page",
72
+ "plugin",
73
+ "private",
74
+ "public",
75
+ "srvkit",
76
+ "ui",
77
+ "webkit",
78
+ ]);
79
+
80
+ export const rootAllowedFiles = { app: appRootAllowedFiles, lib: libRootAllowedFiles } as const;
81
+ export const rootAllowedDirs = { app: appRootAllowedDirs, lib: libRootAllowedDirs } as const;
82
+
44
83
  export const libFacetRootAllowedFiles = new Set([
45
84
  "cnst.ts",
46
85
  "db.ts",
@@ -53,8 +92,15 @@ export const libFacetRootAllowedFiles = new Set([
53
92
  "useServer.ts",
54
93
  ]);
55
94
 
95
+ /** `<model>.signal.test.ts` — lib 파싯 루트에 놓이는 유일한 비생성 파일 (테스트가 배럴 전체를 부팅한다). */
96
+ const libFacetRootTestPattern = /^[A-Za-z][A-Za-z0-9_-]*\.signal\.(test|spec)\.(ts|tsx)$/;
97
+
98
+ export const isAllowedLibFacetRootFile = (filename: string) =>
99
+ libFacetRootAllowedFiles.has(filename) || libFacetRootTestPattern.test(filename);
100
+
56
101
  /**
57
- * scanSync 는 앱 루트를 `Bun.Glob("*")` 로 읽어 dotfile 을 아예 보지 못한다. 디렉터리를 직접 읽는
102
+ * scanSync 는 루트를 `Bun.Glob("*")` 로 읽어 dotfile 을 아예 보지 못한다. 디렉터리를 직접 읽는
58
103
  * doctor 가 그 차이만큼 `.DS_Store` 같은 툴 산출물을 에러로 올리므로 같은 기준으로 걸러낸다.
59
104
  */
60
- export const isScannedAppRootEntry = (name: string) => !name.startsWith(".") || appRootAllowedDirs.has(name);
105
+ export const isScannedRootEntry = (type: SysType, name: string) =>
106
+ !name.startsWith(".") || rootAllowedDirs[type].has(name);