@akanjs/devkit 2.3.11-rc.9 → 2.3.12-rc.0
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/CHANGELOG.md +25 -0
- package/akanConfig/akanConfig.test.ts +2 -0
- package/akanConfig/akanConfig.ts +14 -1
- package/artifact/implicitRootLayout.ts +51 -6
- package/artifact/routeSeedIndex.ts +3 -1
- package/executors.ts +40 -1
- package/frontendBuild/devGeneratedIndexSync.test.ts +66 -0
- package/frontendBuild/devGeneratedIndexSync.ts +8 -2
- package/package.json +2 -2
- package/qualityScanner.ts +165 -52
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# @akanjs/devkit
|
|
2
2
|
|
|
3
|
+
## 2.3.11
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 595390a: feat: UiOverride 시스템 및 \_overrides.tsx 지원 추가
|
|
8
|
+
|
|
9
|
+
- `akanjs/ui/UiOverride` 추가: `Provider`, `createOverridable`, `useUiOverride`, `override` API로 UI 컴포넌트 커스터마이징 지원
|
|
10
|
+
- 모든 akanjs UI 컴포넌트(Button, Modal, Select, Table 등)에 `useUiOverride()` 통합
|
|
11
|
+
- 라우트 시스템에 `_overrides.tsx` 지원 추가 (routeConvention, routeTreeBuilder)
|
|
12
|
+
- qualityScanner에 `_overrides.tsx` 파일 검증 로직 추가
|
|
13
|
+
- 앱 예제: `apps/minimal`에 `_overrides.tsx`, `BrandModal`, `OverrideDemo` 추가
|
|
14
|
+
- `apps/akan` 문서에 UI 커스터마이징 가이드 페이지 추가
|
|
15
|
+
- devkit에 `no-throw-raw-error.grit` lint rule 추가
|
|
16
|
+
- `PushNotificationServer.ts` 리팩토링
|
|
17
|
+
- biome.json 업데이트 및 패키지 의존성 정리
|
|
18
|
+
|
|
19
|
+
### Patch Changes
|
|
20
|
+
|
|
21
|
+
- 5ce752a: enhance: add host option for staging server tests
|
|
22
|
+
- 5ce752a: add host option for staging server tests
|
|
23
|
+
- Updated dependencies [5ce752a]
|
|
24
|
+
- Updated dependencies [5ce752a]
|
|
25
|
+
- Updated dependencies [595390a]
|
|
26
|
+
- akanjs@2.4.0
|
|
27
|
+
|
|
3
28
|
## 2.3.10
|
|
4
29
|
|
|
5
30
|
### Patch Changes
|
|
@@ -264,6 +264,8 @@ describe("AkanAppConfig", () => {
|
|
|
264
264
|
`postgres@${runtimeDependencies.postgres}`,
|
|
265
265
|
`protobufjs@${runtimeDependencies.protobufjs}`,
|
|
266
266
|
]);
|
|
267
|
+
expect(config.getMobileRuntimePackages()).toEqual(["firebase"]);
|
|
268
|
+
expect(config.getMissingMobileDependencySpecs()).toEqual([`firebase@${runtimeDependencies.firebase}`]);
|
|
267
269
|
});
|
|
268
270
|
|
|
269
271
|
test("normalizes multiple mobile targets and validates base paths", () => {
|
package/akanConfig/akanConfig.ts
CHANGED
|
@@ -51,6 +51,9 @@ const WORKSPACE_BARREL_FACETS = ["ui", "webkit", "common", "client", "server"] a
|
|
|
51
51
|
const SSR_RUNTIME_PACKAGES = ["react", "react-dom", "react-server-dom-webpack"] as const;
|
|
52
52
|
const NATIVE_RUNTIME_PACKAGES = ["sharp"] as const;
|
|
53
53
|
const DEFAULT_BACKEND_RUNTIME_PACKAGES = ["croner"] as const;
|
|
54
|
+
// Native-only client packages that are not bundled into the base bootstrap; installed
|
|
55
|
+
// on demand when a mobile target is built or started (e.g. firebase for push tokens).
|
|
56
|
+
const MOBILE_RUNTIME_PACKAGES = ["firebase"] as const;
|
|
54
57
|
const DATABASE_MODE_RUNTIME_PACKAGES = {
|
|
55
58
|
single: [],
|
|
56
59
|
multiple: ["@libsql/client", "bullmq", "ioredis", "protobufjs"],
|
|
@@ -60,6 +63,7 @@ const AKAN_RUNTIME_PACKAGES = new Set<string>([
|
|
|
60
63
|
...SSR_RUNTIME_PACKAGES,
|
|
61
64
|
...NATIVE_RUNTIME_PACKAGES,
|
|
62
65
|
...DEFAULT_BACKEND_RUNTIME_PACKAGES,
|
|
66
|
+
...MOBILE_RUNTIME_PACKAGES,
|
|
63
67
|
...Object.values(DATABASE_MODE_RUNTIME_PACKAGES).flat(),
|
|
64
68
|
]);
|
|
65
69
|
const DEFAULT_AKAN_IMAGE_CONFIG: AkanImageConfig = {
|
|
@@ -360,11 +364,20 @@ CMD [${command.map((c) => `"${c}"`).join(",")}]`;
|
|
|
360
364
|
return [...DATABASE_MODE_RUNTIME_PACKAGES[databaseMode]];
|
|
361
365
|
}
|
|
362
366
|
getMissingDatabaseModeDependencySpecs(databaseMode: DatabaseMode = this.defaultDatabaseMode) {
|
|
367
|
+
return this.#getMissingDependencySpecs(this.getDatabaseModeRuntimePackages(databaseMode));
|
|
368
|
+
}
|
|
369
|
+
getMobileRuntimePackages() {
|
|
370
|
+
return [...MOBILE_RUNTIME_PACKAGES];
|
|
371
|
+
}
|
|
372
|
+
getMissingMobileDependencySpecs() {
|
|
373
|
+
return this.#getMissingDependencySpecs(this.getMobileRuntimePackages());
|
|
374
|
+
}
|
|
375
|
+
#getMissingDependencySpecs(libs: readonly string[]) {
|
|
363
376
|
const rootDependencies = {
|
|
364
377
|
...this.rootPackageJson.dependencies,
|
|
365
378
|
...this.rootPackageJson.devDependencies,
|
|
366
379
|
};
|
|
367
|
-
return
|
|
380
|
+
return libs
|
|
368
381
|
.filter((lib) => !rootDependencies[lib])
|
|
369
382
|
.map((lib) => {
|
|
370
383
|
const version = this.#resolveProductionDependencyVersion(lib);
|
|
@@ -16,6 +16,38 @@ async function appHasStModule(appCwdPath: string): Promise<boolean> {
|
|
|
16
16
|
|
|
17
17
|
const IMPLICIT_LAYOUT_DIR = path.join(".akan", "generated", "root-layouts");
|
|
18
18
|
const IMPLICIT_DICT_DIR = path.join(".akan", "generated", "dict");
|
|
19
|
+
const IMPLICIT_OVERRIDES_DIR = path.join(".akan", "generated", "overrides");
|
|
20
|
+
const OVERRIDES_KEY_RE = /^\.\/(.+\/)?_overrides\.(tsx|ts|jsx|js)$/;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A `_overrides.tsx` manifest is a plain, server-safe module (`export default override({ Modal: BrandModal })`)
|
|
24
|
+
* with no `"use client"` directive. The `UiOverrideProvider` that consumes it is a client component, so the
|
|
25
|
+
* build emits a `"use client"` wrapper layout that reads the manifest's default map and mounts the provider
|
|
26
|
+
* around the subtree. As a normal `"use client"` module the wrapper participates in client-entry discovery and
|
|
27
|
+
* the RSC client manifest, so the server (as a client reference) and the client (as the real component) both
|
|
28
|
+
* resolve it — and the author never writes `"use client"`.
|
|
29
|
+
*/
|
|
30
|
+
async function writeGeneratedOverridesLayoutFile(opts: {
|
|
31
|
+
appCwdPath: string;
|
|
32
|
+
key: string;
|
|
33
|
+
userAbsPath: string;
|
|
34
|
+
}): Promise<string> {
|
|
35
|
+
const filename = `${opts.key.replace(/^\.\//, "").replace(/[^a-zA-Z0-9]+/g, "_")}.tsx`;
|
|
36
|
+
const absPath = path.join(path.resolve(opts.appCwdPath), IMPLICIT_OVERRIDES_DIR, filename);
|
|
37
|
+
const userRel = path.relative(path.dirname(absPath), opts.userAbsPath).split(path.sep).join("/");
|
|
38
|
+
const userSpecifier = userRel.startsWith(".") ? userRel : `./${userRel}`;
|
|
39
|
+
const source = `"use client";
|
|
40
|
+
import { UiOverrideProvider } from "akanjs/ui";
|
|
41
|
+
import { createElement, type ReactNode } from "react";
|
|
42
|
+
import value from ${JSON.stringify(userSpecifier)};
|
|
43
|
+
|
|
44
|
+
export default function AkanUiOverridesLayout({ children }: { children?: ReactNode }) {
|
|
45
|
+
return createElement(UiOverrideProvider, { value }, children);
|
|
46
|
+
}
|
|
47
|
+
`;
|
|
48
|
+
await Bun.write(absPath, source);
|
|
49
|
+
return absPath;
|
|
50
|
+
}
|
|
19
51
|
|
|
20
52
|
interface RootBoundary {
|
|
21
53
|
sourceKey: string | null;
|
|
@@ -261,12 +293,25 @@ export async function resolveSsrPageEntries(opts: {
|
|
|
261
293
|
return segments !== null && isRootBoundarySegments(segments, basePaths);
|
|
262
294
|
}),
|
|
263
295
|
);
|
|
264
|
-
const base =
|
|
265
|
-
.
|
|
266
|
-
|
|
267
|
-
key
|
|
268
|
-
|
|
269
|
-
|
|
296
|
+
const base = await Promise.all(
|
|
297
|
+
opts.pageKeys
|
|
298
|
+
.filter((key) => !rootLayoutKeys.has(key))
|
|
299
|
+
.map(async (key) => {
|
|
300
|
+
const userAbsPath = path.resolve(absPageDir, key);
|
|
301
|
+
// `_overrides.tsx` is served through a generated `"use client"` wrapper layout that mounts the provider,
|
|
302
|
+
// so the author writes no directive; the raw manifest becomes a build seed so its slot components (and
|
|
303
|
+
// the manifest itself) enter the client graph / RSC client manifest.
|
|
304
|
+
if (OVERRIDES_KEY_RE.test(key)) {
|
|
305
|
+
const moduleAbsPath = await writeGeneratedOverridesLayoutFile({
|
|
306
|
+
appCwdPath: opts.appCwdPath,
|
|
307
|
+
key,
|
|
308
|
+
userAbsPath,
|
|
309
|
+
});
|
|
310
|
+
return { key, moduleAbsPath, seedAbsPaths: [userAbsPath] };
|
|
311
|
+
}
|
|
312
|
+
return { key, moduleAbsPath: userAbsPath };
|
|
313
|
+
}),
|
|
314
|
+
);
|
|
270
315
|
const generated = await Promise.all(
|
|
271
316
|
rootBoundaries.map(async (boundary) => ({
|
|
272
317
|
key: implicitRootLayoutKey(boundary.segments),
|
|
@@ -50,7 +50,9 @@ export function computeRouteSeedIndex(pageEntries: PageEntry[]): RouteSeedIndex
|
|
|
50
50
|
for (const { key, moduleAbsPath, seedAbsPaths } of pageEntries) {
|
|
51
51
|
const parsed = parseRouteModuleKey(key);
|
|
52
52
|
const files = [path.resolve(moduleAbsPath), ...(seedAbsPaths ?? []).map((seed) => path.resolve(seed))];
|
|
53
|
-
if (parsed.kind === "layout") {
|
|
53
|
+
if (parsed.kind === "layout" || parsed.kind === "overrides") {
|
|
54
|
+
// Overrides seed the client graph like layouts: every route under the prefix must pull the generated
|
|
55
|
+
// `"use client"` override wrapper (and its slot components) into the client bundle / RSC client manifest.
|
|
54
56
|
const prefix = parsed.routeSegments.join("/");
|
|
55
57
|
const prev = layoutsByPrefix.get(prefix) ?? [];
|
|
56
58
|
layoutsByPrefix.set(prefix, [...prev, ...files]);
|
package/executors.ts
CHANGED
|
@@ -262,6 +262,43 @@ function validateRouteSourceExports(
|
|
|
262
262
|
}
|
|
263
263
|
}
|
|
264
264
|
|
|
265
|
+
/**
|
|
266
|
+
* Statically enforces that a `_overrides.tsx` route file is a logic-free activation manifest: a plain module
|
|
267
|
+
* (no `"use client"` — the framework generates the client wrapper) that only imports components and binds them
|
|
268
|
+
* to slots through a single `export default override({ Modal: BrandModal })`. It must not declare components
|
|
269
|
+
* inline or run logic — that keeps the override contract a thin binding layer rather than a second place to
|
|
270
|
+
* author UI. Slot names and value types are validated at compile time by `override`.
|
|
271
|
+
*/
|
|
272
|
+
function validateOverridesSourceExports(source: string, filePath: string) {
|
|
273
|
+
const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
274
|
+
const fail = (message: string): never => {
|
|
275
|
+
throw new Error(`[route-convention] ${message}: ${filePath}`);
|
|
276
|
+
};
|
|
277
|
+
let defaultOverride: ts.ExportAssignment | null = null;
|
|
278
|
+
for (const statement of sourceFile.statements) {
|
|
279
|
+
// A "use client" directive is unnecessary (the framework wraps the manifest) but harmless if present.
|
|
280
|
+
if (ts.isExpressionStatement(statement) && ts.isStringLiteral(statement.expression)) continue;
|
|
281
|
+
// The manifest imports the app components it binds; imports and type-only decls carry no runtime logic.
|
|
282
|
+
if (ts.isImportDeclaration(statement)) continue;
|
|
283
|
+
if (ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement)) continue;
|
|
284
|
+
if (ts.isExportAssignment(statement) && !statement.isExportEquals) {
|
|
285
|
+
defaultOverride = statement;
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
fail(`_overrides.tsx may only contain imports and a single "export default override({ ... })"`);
|
|
289
|
+
}
|
|
290
|
+
if (!defaultOverride) fail(`_overrides.tsx must "export default override({ ... })"`);
|
|
291
|
+
const expression = defaultOverride.expression;
|
|
292
|
+
if (
|
|
293
|
+
!ts.isCallExpression(expression) ||
|
|
294
|
+
!ts.isIdentifier(expression.expression) ||
|
|
295
|
+
expression.expression.text !== "override"
|
|
296
|
+
)
|
|
297
|
+
fail(
|
|
298
|
+
`_overrides.tsx default export must be a call to "override", e.g. "export default override({ Modal: BrandModal })"`,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
265
302
|
export class Executor {
|
|
266
303
|
static verbose = false;
|
|
267
304
|
static setVerbose(verbose: boolean) {
|
|
@@ -1466,7 +1503,9 @@ export class AppExecutor extends SysExecutor {
|
|
|
1466
1503
|
throw new Error(`[route-convention] __root_layout is reserved for Akan.js generated root layout: ${absPath}`);
|
|
1467
1504
|
}
|
|
1468
1505
|
const isRootLayout = parsed.kind === "layout" && parsed.moduleSegments.at(-1) === "_layout";
|
|
1469
|
-
|
|
1506
|
+
const routeSource = await Bun.file(absPath).text();
|
|
1507
|
+
if (parsed.kind === "overrides") validateOverridesSourceExports(routeSource, absPath);
|
|
1508
|
+
else validateRouteSourceExports(routeSource, absPath, parsed.kind, { rootLayout: isRootLayout });
|
|
1470
1509
|
pageKeys.push(key);
|
|
1471
1510
|
}
|
|
1472
1511
|
pageKeys.sort();
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { DevGeneratedIndexSync } from "./devGeneratedIndexSync";
|
|
6
|
+
|
|
7
|
+
const tempRoots: string[] = [];
|
|
8
|
+
|
|
9
|
+
const makeTempRoot = async () => {
|
|
10
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "akan-generated-index-"));
|
|
11
|
+
tempRoots.push(root);
|
|
12
|
+
return root;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const seedFacet = async (root: string, facet: string, { files, dirs }: { files: string[]; dirs: string[] }) => {
|
|
16
|
+
const dir = path.join(root, "libs", "util", facet);
|
|
17
|
+
await mkdir(dir, { recursive: true });
|
|
18
|
+
await Promise.all(files.map((name) => writeFile(path.join(dir, name), "export const x = 1;\n")));
|
|
19
|
+
await Promise.all(dirs.map((name) => mkdir(path.join(dir, name), { recursive: true })));
|
|
20
|
+
return dir;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const barrelFor = async (root: string, facet: string, seed: { files: string[]; dirs: string[]; trigger: string }) => {
|
|
24
|
+
const dir = await seedFacet(root, facet, seed);
|
|
25
|
+
const sync = new DevGeneratedIndexSync({ workspaceRoot: root });
|
|
26
|
+
const result = await sync.syncForBatch([path.join(dir, seed.trigger)]);
|
|
27
|
+
expect(result.errors).toEqual([]);
|
|
28
|
+
return readFile(path.join(dir, "index.ts"), "utf8");
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
afterEach(async () => {
|
|
32
|
+
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("DevGeneratedIndexSync facet barrels", () => {
|
|
36
|
+
test("camelCase facets export only clean camelCase names", async () => {
|
|
37
|
+
const content = await barrelFor(await makeTempRoot(), "srvkit", {
|
|
38
|
+
files: [
|
|
39
|
+
"aes.ts",
|
|
40
|
+
"cloudflareApi.ts",
|
|
41
|
+
"cloudflareApi.helper.ts", // dotted → skipped
|
|
42
|
+
"pushNotificationServer.type.ts", // dotted → skipped
|
|
43
|
+
"PushNotificationServer.ts", // PascalCase in camel facet → skipped
|
|
44
|
+
"my_snake.ts", // snake_case → skipped
|
|
45
|
+
"kebab-case.ts", // kebab-case → skipped
|
|
46
|
+
],
|
|
47
|
+
dirs: ["storageApi", "BadDir"], // PascalCase dir → skipped
|
|
48
|
+
trigger: "aes.ts",
|
|
49
|
+
});
|
|
50
|
+
expect(content).toBe(`export * from "./aes";\nexport * from "./cloudflareApi";\nexport * from "./storageApi";\n`);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("ui facet exports only clean PascalCase names", async () => {
|
|
54
|
+
const content = await barrelFor(await makeTempRoot(), "ui", {
|
|
55
|
+
files: [
|
|
56
|
+
"Globe.tsx",
|
|
57
|
+
"AkanLogo.tsx",
|
|
58
|
+
"Globe_Dynamic.tsx", // underscore → skipped
|
|
59
|
+
"lowerStart.tsx", // camelCase in ui facet → skipped
|
|
60
|
+
],
|
|
61
|
+
dirs: ["Code", "badDir"], // camelCase dir → skipped
|
|
62
|
+
trigger: "Globe.tsx",
|
|
63
|
+
});
|
|
64
|
+
expect(content).toBe(`export * from "./AkanLogo";\nexport * from "./Code";\nexport * from "./Globe";\n`);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -4,6 +4,10 @@ import path from "node:path";
|
|
|
4
4
|
const BARREL_FACETS = new Set(["common", "srvkit", "ui", "webkit"]);
|
|
5
5
|
const FACET_SOURCE_FILE_RE = /\.(ts|tsx)$/;
|
|
6
6
|
const FACET_EXCLUDED_FILE_RE = /(^index\.tsx?$|\.d\.ts$|\.(test|spec)\.(ts|tsx)$|\.css$|\.scss$|\.sass$)/;
|
|
7
|
+
// `ui` exports PascalCase names only; `common`/`srvkit`/`webkit` export camelCase names only. Names with
|
|
8
|
+
// dots, underscores, or hyphens (e.g. `foo.helper`, `Globe_Dynamic`, `kebab-case`) match neither and are skipped.
|
|
9
|
+
const FACET_PASCAL_CASE_RE = /^[A-Z][A-Za-z0-9]*$/;
|
|
10
|
+
const FACET_CAMEL_CASE_RE = /^[a-z][A-Za-z0-9]*$/;
|
|
7
11
|
const MODULE_UI_TYPES = ["Template", "Unit", "Util", "View", "Zone"] as const;
|
|
8
12
|
const SERVICE_UI_TYPES = ["Util", "Zone"] as const;
|
|
9
13
|
const SCALAR_UI_TYPES = ["Template", "Unit"] as const;
|
|
@@ -109,15 +113,17 @@ export class DevGeneratedIndexSync {
|
|
|
109
113
|
}
|
|
110
114
|
|
|
111
115
|
async #facetContent(dir: string): Promise<string | null> {
|
|
116
|
+
const nameCasePattern = path.basename(dir) === "ui" ? FACET_PASCAL_CASE_RE : FACET_CAMEL_CASE_RE;
|
|
112
117
|
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
113
118
|
const exportNames = entries
|
|
114
119
|
.flatMap((entry) => {
|
|
115
120
|
const name = entry.name;
|
|
116
121
|
if (name.startsWith(".")) return [];
|
|
117
|
-
if (entry.isDirectory()) return [name];
|
|
122
|
+
if (entry.isDirectory()) return nameCasePattern.test(name) ? [name] : [];
|
|
118
123
|
if (!entry.isFile()) return [];
|
|
119
124
|
if (!FACET_SOURCE_FILE_RE.test(name) || FACET_EXCLUDED_FILE_RE.test(name)) return [];
|
|
120
|
-
|
|
125
|
+
const exportName = name.replace(FACET_SOURCE_FILE_RE, "");
|
|
126
|
+
return nameCasePattern.test(exportName) ? [exportName] : [];
|
|
121
127
|
})
|
|
122
128
|
.sort();
|
|
123
129
|
if (exportNames.length === 0) return null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.12-rc.0",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"@langchain/openai": "^1.4.6",
|
|
33
33
|
"@tailwindcss/node": "^4.3.0",
|
|
34
34
|
"@trapezedev/project": "^7.1.4",
|
|
35
|
-
"akanjs": "2.3.
|
|
35
|
+
"akanjs": "2.3.12-rc.0",
|
|
36
36
|
"chalk": "^5.6.2",
|
|
37
37
|
"commander": "^14.0.3",
|
|
38
38
|
"daisyui": "5.5.23",
|
package/qualityScanner.ts
CHANGED
|
@@ -15,6 +15,7 @@ export interface QualityWarning {
|
|
|
15
15
|
file?: string;
|
|
16
16
|
line?: number;
|
|
17
17
|
locations?: Array<{ file: string; line: number }>;
|
|
18
|
+
fix?: string;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
export interface QualityScanResult {
|
|
@@ -48,6 +49,14 @@ interface TopLevelDeclaration {
|
|
|
48
49
|
node: ts.Statement;
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
interface ComponentFileDeclaration {
|
|
53
|
+
name: string;
|
|
54
|
+
kind: "interface" | "type" | "function" | "variable" | "class" | "enum";
|
|
55
|
+
line: number;
|
|
56
|
+
exported: boolean;
|
|
57
|
+
isDefaultExport: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
51
60
|
const MAX_FILE_LINES = 2000;
|
|
52
61
|
const PLACEHOLDER_EXPORT_NAMES = new Set([
|
|
53
62
|
"aa",
|
|
@@ -105,6 +114,61 @@ const CONVENTION_SUFFIXES = [
|
|
|
105
114
|
".store.ts",
|
|
106
115
|
] as const;
|
|
107
116
|
|
|
117
|
+
// Non-PascalCase exports the framework recognizes on page/layout route modules (see PageModule/LayoutModule
|
|
118
|
+
// in pkgs/akanjs/client/csrTypes.ts). PascalCase route exports (Loading, NotFound, Error) pass the component
|
|
119
|
+
// check, and the `default` export is handled separately.
|
|
120
|
+
const PAGE_RESERVED_EXPORTS = new Set([
|
|
121
|
+
"pageConfig",
|
|
122
|
+
"head",
|
|
123
|
+
"metadata",
|
|
124
|
+
"generateHead",
|
|
125
|
+
"generateMetadata",
|
|
126
|
+
"fonts",
|
|
127
|
+
"manifest",
|
|
128
|
+
"theme",
|
|
129
|
+
"reconnect",
|
|
130
|
+
"wsConnect",
|
|
131
|
+
"layoutStyle",
|
|
132
|
+
"gaTrackingId",
|
|
133
|
+
]);
|
|
134
|
+
|
|
135
|
+
// How to remediate each rule, keyed by rule id. Surfaced as a `fix:` line per warning (text + JSON output)
|
|
136
|
+
// so the scan result tells the reader what to do, not just what is wrong.
|
|
137
|
+
const RULE_FIXES: Record<string, string> = {
|
|
138
|
+
"akan.global.duplicate-exported-function-name":
|
|
139
|
+
"Rename one of the exports, or if they are the same thing, extract it into one shared module and import it in both places.",
|
|
140
|
+
"akan.global.duplicate-exported-function-body":
|
|
141
|
+
"Extract the shared implementation into a single exported helper and import it, instead of copying the body.",
|
|
142
|
+
"akan.file.recommended-max-lines":
|
|
143
|
+
"Split the file by responsibility — move Zones, Utils, or subcomponents into sibling files.",
|
|
144
|
+
"akan.file.max-lines": "Break the file into smaller focused modules; keep one primary responsibility per file.",
|
|
145
|
+
"akan.file.placeholder-export":
|
|
146
|
+
"Remove the placeholder export; generated indexes should only re-export real modules.",
|
|
147
|
+
"akan.file.dictionary-stale-text": "Replace the scaffold text with real localized copy for this dictionary entry.",
|
|
148
|
+
"akan.file.global-declaration":
|
|
149
|
+
"Move the global declaration into an approved low-level integration file (e.g. webkit) and keep it isolated.",
|
|
150
|
+
"akan.file.window-augmentation":
|
|
151
|
+
"Move the Window augmentation into an approved browser integration file and keep it isolated.",
|
|
152
|
+
"akan.file.prototype-mutation": "Avoid prototype mutation, or isolate it in an approved low-level integration file.",
|
|
153
|
+
"akan.file.class-export-global-declaration": "Move the helper to a sibling file and import it into the class module.",
|
|
154
|
+
"akan.file.component-internal-declaration":
|
|
155
|
+
"Move the type or helper to a type/util file in ui/, webkit/, or common/ by purpose. If it is the component's props, declare it as `interface <Component>Props`.",
|
|
156
|
+
"akan.file.component-export":
|
|
157
|
+
"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.",
|
|
158
|
+
"akan.layout.app-root-file":
|
|
159
|
+
"Move the file into a conventional app folder (common, env, lib, page, private, public, script, srvkit, ui, or webkit).",
|
|
160
|
+
"akan.layout.lib-root-file":
|
|
161
|
+
"Move the file into a domain module folder under lib/; keep lib root limited to generated support facets.",
|
|
162
|
+
"akan.layout.module-ui-file":
|
|
163
|
+
"Rename the file to an allowed module UI name, or move it to ui/ if it is not a module component.",
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
function getRuleFix(rule: string): string | undefined {
|
|
167
|
+
if (rule.startsWith("akan.convention"))
|
|
168
|
+
return "Keep only the model's allowed declarations in this file; move other logic to the matching domain file (service, document, store, etc.).";
|
|
169
|
+
return RULE_FIXES[rule];
|
|
170
|
+
}
|
|
171
|
+
|
|
108
172
|
export class AkanQualityScanner {
|
|
109
173
|
async scan(workspaceRoot: string): Promise<QualityScanResult> {
|
|
110
174
|
const targetFiles = await this.#collectTargetFiles(workspaceRoot);
|
|
@@ -120,7 +184,9 @@ export class AkanQualityScanner {
|
|
|
120
184
|
return {
|
|
121
185
|
workspaceRoot,
|
|
122
186
|
scannedFiles: sourceFiles.length,
|
|
123
|
-
warnings: warnings
|
|
187
|
+
warnings: warnings
|
|
188
|
+
.map((warning) => ({ ...warning, fix: warning.fix ?? getRuleFix(warning.rule) }))
|
|
189
|
+
.sort(compareWarnings),
|
|
124
190
|
suggestedRules: SUGGESTED_RULES,
|
|
125
191
|
};
|
|
126
192
|
}
|
|
@@ -259,20 +325,45 @@ export class AkanQualityScanner {
|
|
|
259
325
|
|
|
260
326
|
#scanComponentQuality(sourceFile: SourceFileInfo): QualityWarning[] {
|
|
261
327
|
if (!isComponentDeclarationFile(sourceFile.file)) return [];
|
|
262
|
-
const
|
|
263
|
-
const
|
|
328
|
+
const isPage = isPageRouteFile(sourceFile.file);
|
|
329
|
+
const declarations = getComponentFileDeclarations(sourceFile.sourceFile);
|
|
330
|
+
// Compound/namespaced components (e.g. `Like.WithDislike = WithDislike`) are valid without being exported.
|
|
331
|
+
const compoundComponentNames = getCompoundComponentNames(sourceFile.sourceFile);
|
|
332
|
+
// Components are the exported (or compound) PascalCase values; their "<Component>Props" interface may live here.
|
|
333
|
+
const componentNames = declarations
|
|
334
|
+
.filter((declaration) => declaration.exported && isComponentValueKind(declaration.kind))
|
|
335
|
+
.filter((declaration) => isPascalCaseName(declaration.name))
|
|
336
|
+
.map((declaration) => declaration.name);
|
|
337
|
+
const allowedComponentNames = new Set([...componentNames, ...compoundComponentNames]);
|
|
338
|
+
const allowedPropsInterfaces = new Set([...allowedComponentNames].map((name) => `${name}Props`));
|
|
264
339
|
const warnings: QualityWarning[] = [];
|
|
265
|
-
for (const declaration of
|
|
266
|
-
if (
|
|
267
|
-
if (declaration.kind === "interface" &&
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
340
|
+
for (const declaration of declarations) {
|
|
341
|
+
if (declaration.isDefaultExport) continue;
|
|
342
|
+
if (declaration.kind === "interface" && allowedPropsInterfaces.has(declaration.name)) continue;
|
|
343
|
+
if (declaration.exported) {
|
|
344
|
+
if (isAllowedComponentExport(declaration, isPage)) continue;
|
|
345
|
+
warnings.push({
|
|
346
|
+
rule: "akan.file.component-export",
|
|
347
|
+
scope: "file",
|
|
348
|
+
severity: "warning",
|
|
349
|
+
file: sourceFile.file,
|
|
350
|
+
line: declaration.line,
|
|
351
|
+
message: `Component file exports ${declaration.kind} "${declaration.name}", which is not a PascalCase component${isPage ? " or reserved route export" : ""}.`,
|
|
352
|
+
});
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
// A non-exported PascalCase component attached as a compound member is an accepted pattern.
|
|
356
|
+
if (isComponentValueKind(declaration.kind) && compoundComponentNames.has(declaration.name)) continue;
|
|
357
|
+
if (isRestrictedInternalKind(declaration.kind)) {
|
|
358
|
+
warnings.push({
|
|
359
|
+
rule: "akan.file.component-internal-declaration",
|
|
360
|
+
scope: "file",
|
|
361
|
+
severity: "warning",
|
|
362
|
+
file: sourceFile.file,
|
|
363
|
+
line: declaration.line,
|
|
364
|
+
message: `Component file declares non-exported ${declaration.kind} "${declaration.name}". Only "interface <Component>Props" may stay internal.`,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
276
367
|
}
|
|
277
368
|
return warnings;
|
|
278
369
|
}
|
|
@@ -358,6 +449,7 @@ export function formatQualityWarnings(warnings: QualityWarning[]) {
|
|
|
358
449
|
...warning.locations.map(({ file, line }) => ` note: related location ${formatQualityLocation(file, line)}`),
|
|
359
450
|
);
|
|
360
451
|
}
|
|
452
|
+
if (warning.fix) lines.push(` fix: ${warning.fix}`);
|
|
361
453
|
return lines;
|
|
362
454
|
});
|
|
363
455
|
}
|
|
@@ -464,56 +556,77 @@ function isComponentDeclarationFile(file: string) {
|
|
|
464
556
|
return root === "apps" && area === "page";
|
|
465
557
|
}
|
|
466
558
|
|
|
467
|
-
function
|
|
468
|
-
const
|
|
559
|
+
function getComponentFileDeclarations(sourceFile: ts.SourceFile): ComponentFileDeclaration[] {
|
|
560
|
+
const reExportedNames = new Set<string>();
|
|
469
561
|
for (const statement of sourceFile.statements) {
|
|
470
562
|
if (ts.isExportDeclaration(statement) && statement.exportClause && ts.isNamedExports(statement.exportClause)) {
|
|
471
|
-
for (const element of statement.exportClause.elements)
|
|
472
|
-
|
|
473
|
-
if (element.propertyName) names.add(element.propertyName.text);
|
|
474
|
-
}
|
|
475
|
-
continue;
|
|
563
|
+
for (const element of statement.exportClause.elements)
|
|
564
|
+
reExportedNames.add((element.propertyName ?? element.name).text);
|
|
476
565
|
}
|
|
477
|
-
if (!isExported(statement)) continue;
|
|
478
|
-
for (const name of getStatementDeclarationNames(statement)) names.add(name);
|
|
479
566
|
}
|
|
480
|
-
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
function getStatementDeclarationNames(statement: ts.Statement): string[] {
|
|
484
|
-
if (ts.isFunctionDeclaration(statement) && statement.name) return [statement.name.text];
|
|
485
|
-
if (ts.isClassDeclaration(statement) && statement.name) return [statement.name.text];
|
|
486
|
-
if (ts.isInterfaceDeclaration(statement)) return [statement.name.text];
|
|
487
|
-
if (ts.isTypeAliasDeclaration(statement)) return [statement.name.text];
|
|
488
|
-
if (ts.isEnumDeclaration(statement)) return [statement.name.text];
|
|
489
|
-
if (ts.isVariableStatement(statement)) {
|
|
490
|
-
return statement.declarationList.declarations
|
|
491
|
-
.filter((declaration) => ts.isIdentifier(declaration.name))
|
|
492
|
-
.map((declaration) => (declaration.name as ts.Identifier).text);
|
|
493
|
-
}
|
|
494
|
-
return [];
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
function getInternalTypeOrFunctionDeclarations(sourceFile: ts.SourceFile) {
|
|
498
|
-
const declarations: Array<{ name: string; kind: "interface" | "type" | "function"; line: number }> = [];
|
|
567
|
+
const declarations: ComponentFileDeclaration[] = [];
|
|
499
568
|
for (const statement of sourceFile.statements) {
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
569
|
+
const isDefaultExport = isDefaultExportStatement(statement);
|
|
570
|
+
const inlineExported = isExported(statement);
|
|
571
|
+
const add = (name: string, kind: ComponentFileDeclaration["kind"], line: number) =>
|
|
572
|
+
declarations.push({ name, kind, line, exported: inlineExported || reExportedNames.has(name), isDefaultExport });
|
|
573
|
+
if (ts.isInterfaceDeclaration(statement)) add(statement.name.text, "interface", getLine(sourceFile, statement));
|
|
574
|
+
else if (ts.isTypeAliasDeclaration(statement)) add(statement.name.text, "type", getLine(sourceFile, statement));
|
|
575
|
+
else if (ts.isEnumDeclaration(statement)) add(statement.name.text, "enum", getLine(sourceFile, statement));
|
|
576
|
+
else if (ts.isFunctionDeclaration(statement) && statement.name)
|
|
577
|
+
add(statement.name.text, "function", getLine(sourceFile, statement));
|
|
578
|
+
else if (ts.isClassDeclaration(statement) && statement.name)
|
|
579
|
+
add(statement.name.text, "class", getLine(sourceFile, statement));
|
|
580
|
+
else if (ts.isVariableStatement(statement)) {
|
|
508
581
|
for (const declaration of statement.declarationList.declarations) {
|
|
509
|
-
if (!ts.isIdentifier(declaration.name)
|
|
510
|
-
|
|
582
|
+
if (!ts.isIdentifier(declaration.name)) continue;
|
|
583
|
+
const kind = isFunctionLikeInitializer(declaration.initializer) ? "function" : "variable";
|
|
584
|
+
add(declaration.name.text, kind, getLine(sourceFile, declaration));
|
|
511
585
|
}
|
|
512
586
|
}
|
|
513
587
|
}
|
|
514
588
|
return declarations;
|
|
515
589
|
}
|
|
516
590
|
|
|
591
|
+
// Detects compound/namespaced component assignments like `Like.WithDislike = WithDislike`. The PascalCase
|
|
592
|
+
// member is the public component name; when the right side is a PascalCase identifier it is the local
|
|
593
|
+
// definition. Both are treated as valid components so the definition and its "<Component>Props" may stay local.
|
|
594
|
+
function getCompoundComponentNames(sourceFile: ts.SourceFile): Set<string> {
|
|
595
|
+
const names = new Set<string>();
|
|
596
|
+
for (const statement of sourceFile.statements) {
|
|
597
|
+
if (!ts.isExpressionStatement(statement)) continue;
|
|
598
|
+
const { expression } = statement;
|
|
599
|
+
if (!ts.isBinaryExpression(expression) || expression.operatorToken.kind !== ts.SyntaxKind.EqualsToken) continue;
|
|
600
|
+
if (!ts.isPropertyAccessExpression(expression.left) || !isPascalCaseName(expression.left.name.text)) continue;
|
|
601
|
+
names.add(expression.left.name.text);
|
|
602
|
+
if (ts.isIdentifier(expression.right) && isPascalCaseName(expression.right.text)) names.add(expression.right.text);
|
|
603
|
+
}
|
|
604
|
+
return names;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function isComponentValueKind(kind: ComponentFileDeclaration["kind"]) {
|
|
608
|
+
return kind === "variable" || kind === "function" || kind === "class";
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function isRestrictedInternalKind(kind: ComponentFileDeclaration["kind"]) {
|
|
612
|
+
return kind === "interface" || kind === "type" || kind === "function";
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function isAllowedComponentExport(declaration: ComponentFileDeclaration, isPage: boolean) {
|
|
616
|
+
if (isComponentValueKind(declaration.kind) && isPascalCaseName(declaration.name)) return true;
|
|
617
|
+
return isPage && PAGE_RESERVED_EXPORTS.has(declaration.name);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function isPascalCaseName(name: string) {
|
|
621
|
+
// PascalCase component names start uppercase and are not SCREAMING_SNAKE_CASE constants.
|
|
622
|
+
return /^[A-Z]/.test(name) && !/^[A-Z0-9_]+$/.test(name);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function isDefaultExportStatement(statement: ts.Statement) {
|
|
626
|
+
if (ts.isExportAssignment(statement)) return !statement.isExportEquals;
|
|
627
|
+
return !!(ts.getCombinedModifierFlags(statement as ts.Declaration) & ts.ModifierFlags.Default);
|
|
628
|
+
}
|
|
629
|
+
|
|
517
630
|
function getPlaceholderExportWarnings(sourceFile: SourceFileInfo): QualityWarning[] {
|
|
518
631
|
if (!sourceFile.file.endsWith("/index.ts") && !sourceFile.file.endsWith("/index.tsx")) return [];
|
|
519
632
|
return getTopLevelDeclarations(sourceFile)
|