@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.
- package/akanConfig/akanConfig.test.ts +47 -0
- package/akanConfig/akanConfig.ts +22 -5
- package/akanContext.ts +13 -11
- package/executors.ts +12 -1
- package/frontendBuild/styleGuard.test.ts +15 -0
- package/frontendBuild/styleGuard.ts +17 -0
- package/lint/no-daisyui-legacy-class.grit +26 -9
- package/package.json +2 -2
- package/qualityScanner.test.ts +43 -0
- package/qualityScanner.ts +40 -17
- package/scanInfo.ts +21 -20
- package/workspaceLayout.test.ts +56 -4
- package/workspaceLayout.ts +48 -2
|
@@ -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;
|
package/akanConfig/akanConfig.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 {
|
|
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
|
|
670
|
-
const
|
|
671
|
-
for (const entry of await safeReadDir(
|
|
672
|
-
if (!
|
|
673
|
-
const allowed = entry.isDirectory()
|
|
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 ${
|
|
678
|
-
|
|
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:
|
|
684
|
-
path: `${
|
|
685
|
-
message: `Unexpected ${entry.isDirectory() ? "folder" : "file"} in
|
|
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`, ...)
|
|
6
|
-
//
|
|
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
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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.
|
|
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.
|
|
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",
|
package/qualityScanner.test.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
|
429
|
-
if (
|
|
439
|
+
const libFacetFile = getLibFacetRootFile(sourceFile.file);
|
|
440
|
+
if (libFacetFile && !isAllowedLibFacetRootFile(libFacetFile)) {
|
|
430
441
|
warnings.push({
|
|
431
|
-
rule: "akan.layout.lib-
|
|
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 "${
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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) => !
|
|
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
|
package/workspaceLayout.test.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
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(
|
|
23
|
-
expect(
|
|
24
|
-
expect(
|
|
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
|
});
|
package/workspaceLayout.ts
CHANGED
|
@@ -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 는
|
|
102
|
+
* scanSync 는 루트를 `Bun.Glob("*")` 로 읽어 dotfile 을 아예 보지 못한다. 디렉터리를 직접 읽는
|
|
58
103
|
* doctor 가 그 차이만큼 `.DS_Store` 같은 툴 산출물을 에러로 올리므로 같은 기준으로 걸러낸다.
|
|
59
104
|
*/
|
|
60
|
-
export const
|
|
105
|
+
export const isScannedRootEntry = (type: SysType, name: string) =>
|
|
106
|
+
!name.startsWith(".") || rootAllowedDirs[type].has(name);
|