@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 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", () => {
@@ -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 this.getDatabaseModeRuntimePackages(databaseMode)
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 = opts.pageKeys
265
- .filter((key) => !rootLayoutKeys.has(key))
266
- .map((key) => ({
267
- key,
268
- moduleAbsPath: path.resolve(absPageDir, key),
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
- validateRouteSourceExports(await Bun.file(absPath).text(), absPath, parsed.kind, { rootLayout: isRootLayout });
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
- return [name.replace(FACET_SOURCE_FILE_RE, "")];
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.11-rc.9",
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.11-rc.9",
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.sort(compareWarnings),
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 exportedNames = getExportedDeclarationNames(sourceFile.sourceFile);
263
- const allowedInternalInterfaces = new Set([...exportedNames].map((name) => `${name}Props`));
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 getInternalTypeOrFunctionDeclarations(sourceFile.sourceFile)) {
266
- if (exportedNames.has(declaration.name)) continue;
267
- if (declaration.kind === "interface" && allowedInternalInterfaces.has(declaration.name)) continue;
268
- warnings.push({
269
- rule: "akan.file.component-internal-declaration",
270
- scope: "file",
271
- severity: "warning",
272
- file: sourceFile.file,
273
- line: declaration.line,
274
- message: `Component file should not declare non-exported ${declaration.kind} "${declaration.name}". Only a "<Component>Props" interface may stay internal; move other types or helpers to a separate file in ui/, webkit/, or common/ by purpose.`,
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 getExportedDeclarationNames(sourceFile: ts.SourceFile) {
468
- const names = new Set<string>();
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
- names.add(element.name.text);
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
- return names;
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
- if (isExported(statement)) continue;
501
- if (ts.isInterfaceDeclaration(statement)) {
502
- declarations.push({ name: statement.name.text, kind: "interface", line: getLine(sourceFile, statement) });
503
- } else if (ts.isTypeAliasDeclaration(statement)) {
504
- declarations.push({ name: statement.name.text, kind: "type", line: getLine(sourceFile, statement) });
505
- } else if (ts.isFunctionDeclaration(statement) && statement.name) {
506
- declarations.push({ name: statement.name.text, kind: "function", line: getLine(sourceFile, statement) });
507
- } else if (ts.isVariableStatement(statement)) {
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) || !isFunctionLikeInitializer(declaration.initializer)) continue;
510
- declarations.push({ name: declaration.name.text, kind: "function", line: getLine(sourceFile, declaration) });
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)