@akanjs/devkit 2.3.11-rc.1 → 2.3.11-rc.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,66 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ createFirebaseMessagingServiceWorker,
4
+ normalizeFirebaseClientConfig,
5
+ } from "./firebaseMessagingSw";
6
+
7
+ describe("normalizeFirebaseClientConfig", () => {
8
+ test("whitelists client fields and drops secrets / vapidKey", () => {
9
+ const normalized = normalizeFirebaseClientConfig({
10
+ apiKey: "public-api-key",
11
+ authDomain: "example.firebaseapp.com",
12
+ projectId: "public-project",
13
+ storageBucket: "public-project.appspot.com",
14
+ messagingSenderId: "1234567890",
15
+ appId: "public-app-id",
16
+ vapidKey: "public-vapid-key",
17
+ private_key: "SERVER_PRIVATE_KEY_MUST_NOT_LEAK",
18
+ });
19
+ expect(normalized).toEqual({
20
+ apiKey: "public-api-key",
21
+ authDomain: "example.firebaseapp.com",
22
+ projectId: "public-project",
23
+ storageBucket: "public-project.appspot.com",
24
+ messagingSenderId: "1234567890",
25
+ appId: "public-app-id",
26
+ });
27
+ expect(normalized).not.toHaveProperty("vapidKey");
28
+ expect(normalized).not.toHaveProperty("private_key");
29
+ });
30
+
31
+ test("returns null when required fields are missing or input is not an object", () => {
32
+ expect(normalizeFirebaseClientConfig(undefined)).toBe(null);
33
+ expect(normalizeFirebaseClientConfig(null)).toBe(null);
34
+ expect(normalizeFirebaseClientConfig("nope")).toBe(null);
35
+ expect(normalizeFirebaseClientConfig({ apiKey: "only-key" })).toBe(null);
36
+ });
37
+ });
38
+
39
+ describe("createFirebaseMessagingServiceWorker", () => {
40
+ test("inlines client config and imports firebase compat SDK, without leaking secrets", () => {
41
+ const config = normalizeFirebaseClientConfig({
42
+ apiKey: "public-api-key",
43
+ projectId: "public-project",
44
+ messagingSenderId: "1234567890",
45
+ appId: "public-app-id",
46
+ private_key: "SERVER_PRIVATE_KEY_MUST_NOT_LEAK",
47
+ });
48
+ const body = createFirebaseMessagingServiceWorker(config);
49
+ expect(body).toContain("public-api-key");
50
+ expect(body).toContain("public-project");
51
+ expect(body).toContain("firebase-messaging-compat.js");
52
+ expect(body).toContain("onBackgroundMessage");
53
+ expect(body).toContain("notificationclick");
54
+ expect(body).not.toContain("SERVER_PRIVATE_KEY_MUST_NOT_LEAK");
55
+ expect(body).not.toContain("private_key");
56
+ });
57
+
58
+ test("produces a no-op worker (config null) that still registers notificationclick", () => {
59
+ const body = createFirebaseMessagingServiceWorker(null);
60
+ // config is null → the importScripts/onBackgroundMessage block is gated by `if (firebaseConfig)` at runtime,
61
+ // but notificationclick is always registered.
62
+ expect(body).toContain("const firebaseConfig = null");
63
+ expect(body).toContain("notificationclick");
64
+ expect(body).toContain("if (firebaseConfig)");
65
+ });
66
+ });
@@ -0,0 +1,74 @@
1
+ const FIREBASE_WEB_SDK_VERSION = "12.13.0";
2
+
3
+ export interface FirebaseClientEnvConfig {
4
+ apiKey: string;
5
+ authDomain?: string;
6
+ projectId: string;
7
+ storageBucket?: string;
8
+ messagingSenderId: string;
9
+ appId: string;
10
+ vapidKey?: string;
11
+ }
12
+
13
+ //* env.client 의 firebase 설정을 검증하고, 서비스워커에 필요한 필드만 화이트리스트한다.
14
+ //* vapidKey/서버 시크릿 등 나머지 필드는 의도적으로 제외한다(SW 본문에 유출 방지).
15
+ export function normalizeFirebaseClientConfig(config: unknown): FirebaseClientEnvConfig | null {
16
+ if (!config || typeof config !== "object") return null;
17
+ const value = config as Partial<Record<keyof FirebaseClientEnvConfig, unknown>>;
18
+ if (
19
+ typeof value.apiKey !== "string" ||
20
+ typeof value.projectId !== "string" ||
21
+ typeof value.messagingSenderId !== "string" ||
22
+ typeof value.appId !== "string"
23
+ ) {
24
+ return null;
25
+ }
26
+ return {
27
+ apiKey: value.apiKey,
28
+ ...(typeof value.authDomain === "string" ? { authDomain: value.authDomain } : {}),
29
+ projectId: value.projectId,
30
+ ...(typeof value.storageBucket === "string" ? { storageBucket: value.storageBucket } : {}),
31
+ messagingSenderId: value.messagingSenderId,
32
+ appId: value.appId,
33
+ };
34
+ }
35
+
36
+ //* firebase-messaging-sw.js 생성 코드
37
+ export function createFirebaseMessagingServiceWorker(config: FirebaseClientEnvConfig | null): string {
38
+ const configJson = JSON.stringify(config);
39
+ return `/* Generated by Akan.js. Do not edit. */
40
+ const firebaseConfig = ${configJson};
41
+
42
+ if (firebaseConfig) {
43
+ importScripts("https://www.gstatic.com/firebasejs/${FIREBASE_WEB_SDK_VERSION}/firebase-app-compat.js");
44
+ importScripts("https://www.gstatic.com/firebasejs/${FIREBASE_WEB_SDK_VERSION}/firebase-messaging-compat.js");
45
+
46
+ firebase.initializeApp(firebaseConfig);
47
+ const messaging = firebase.messaging();
48
+
49
+ const notificationUrl = (payload) =>
50
+ payload?.data?.url || payload?.fcmOptions?.link || payload?.notification?.click_action;
51
+
52
+ messaging.onBackgroundMessage((payload) => {
53
+ const title = payload?.notification?.title || "";
54
+ const options = {
55
+ body: payload?.notification?.body,
56
+ icon: payload?.notification?.icon,
57
+ image: payload?.notification?.image,
58
+ data: {
59
+ url: notificationUrl(payload),
60
+ FCM_MSG: payload,
61
+ },
62
+ };
63
+ self.registration.showNotification(title, options);
64
+ });
65
+ }
66
+
67
+ self.addEventListener("notificationclick", (event) => {
68
+ const url = event.notification?.data?.url || event.notification?.data?.FCM_MSG?.data?.url;
69
+ event.notification?.close();
70
+ if (!url) return;
71
+ event.waitUntil(clients.openWindow(url));
72
+ });
73
+ `;
74
+ }
package/index.ts CHANGED
@@ -18,6 +18,7 @@ export * from "./dependencyScanner";
18
18
  export * from "./executors";
19
19
  export * from "./extractDeps";
20
20
  export * from "./fileSys";
21
+ export * from "./firebaseMessagingSw";
21
22
  export * from "./frontendBuild";
22
23
  export * from "./getCredentials";
23
24
  export * from "./getDirname";
@@ -21,5 +21,15 @@ or {
21
21
  span = $source,
22
22
  message = "Module files should not import from two or more parent directories."
23
23
  )
24
+ },
25
+ JsModuleSource() as $source where {
26
+ $source <: within JsImport(),
27
+ not $filename <: r".*\.(?:test|spec)\.tsx?",
28
+ $filename <: r".*/lib/.+\.tsx",
29
+ $source <: r"\"\.\./.*\"",
30
+ register_diagnostic(
31
+ span = $source,
32
+ message = "Module UI (.tsx) files under lib must not use internal relative imports like ../cnst. Import from the package client/server entrypoint instead."
33
+ )
24
34
  }
25
35
  }
@@ -0,0 +1,28 @@
1
+ engine biome(1.0)
2
+ language js(typescript, jsx)
3
+
4
+ // Every model already gets auto-generated CRUD endpoints in its slice fetch
5
+ // contract (see pkgs/akanjs/fetch/fetchType/sliceFetch.type.ts and
6
+ // pkgs/akanjs/fetch/client/fetchClient.ts):
7
+ // <refName>, light<Model>, create<Model>, update<Model>, remove<Model>,
8
+ // view<Model>, edit<Model>, merge<Model>.
9
+ // Re-declaring one of them inside the `endpoint(...)` block of a
10
+ // `<model>.signal.ts` file shadows the framework contract and can pass
11
+ // sync/typecheck/build while failing at runtime, so flag it here.
12
+ `$key: $value` as $prop where {
13
+ $prop <: within `class $className extends $base {}` where {
14
+ $base <: contains `endpoint`,
15
+ $className <: r"([A-Z][a-zA-Z0-9]*)Endpoint"($Cap)
16
+ },
17
+ $filename <: r".*/([a-z][a-zA-Z0-9]*)\.signal\.ts"($model),
18
+ or {
19
+ $key <: $model,
20
+ $key <: r"(?:light|create|update|remove|view|edit|merge)([A-Z][a-zA-Z0-9]*)"($keyCap) where {
21
+ $keyCap <: $Cap
22
+ }
23
+ },
24
+ register_diagnostic(
25
+ span = $key,
26
+ message = "This endpoint name collides with an auto-generated CRUD endpoint (<model>, light/create/update/remove/view/edit/merge<Model>). Rename it or move the logic into the service; do not re-declare predefined endpoints."
27
+ )
28
+ }
@@ -0,0 +1,40 @@
1
+ engine biome(1.0)
2
+ language js(typescript, jsx)
3
+
4
+ // Domain code in apps/ and libs/ must not throw a raw `Error`. Throw the typed
5
+ // `Err` instead, which is generated per app/lib by `makeTrans`
6
+ // (pkgs/akanjs/dictionary/trans.ts) and imported via `import { Err } from "../dict"`
7
+ // (or the app/lib client entrypoint). `Err` carries a translatable error key,
8
+ // a status code, and structured payload, so raw `Error` throws lose i18n and
9
+ // HTTP-status handling.
10
+ //
11
+ // The `$msg` metavariable in the argument position leniently matches any arity,
12
+ // so `throw new Error()`, `throw new Error("x")`, and
13
+ // `throw new Error("x", { cause })` are all caught, as is the `new`-less
14
+ // `throw Error(...)` call form. Only real throw statements match — a
15
+ // `new Error(...)` inside a string literal or one that is not thrown is ignored.
16
+ //
17
+ // No autofix: `Err`'s first argument must be a typed dictionary error key
18
+ // (e.g. throw new Err('model.errorKey')), so a mechanical rewrite of an
19
+ // arbitrary message string would not typecheck. Each site must be migrated to a
20
+ // real error key by hand.
21
+ //
22
+ // NOTE: the diagnostic messages below use single quotes in the example on
23
+ // purpose. Escaped double quotes (\") inside a GritQL string trigger a
24
+ // double-unescape bug in Biome 2.4.4 that corrupts the rendered message.
25
+ or {
26
+ `throw new Error($msg)` as $throw where {
27
+ register_diagnostic(
28
+ span = $throw,
29
+ message = "Do not throw a raw `Error`. Throw the typed `Err` instead (import { Err } from the app/lib dict), e.g. throw new Err('model.errorKey').",
30
+ severity = "error"
31
+ )
32
+ },
33
+ `throw Error($msg)` as $throw where {
34
+ register_diagnostic(
35
+ span = $throw,
36
+ message = "Do not throw a raw `Error`. Throw the typed `Err` instead (import { Err } from the app/lib dict), e.g. throw new Err('model.errorKey').",
37
+ severity = "error"
38
+ )
39
+ }
40
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "2.3.11-rc.1",
3
+ "version": "2.3.11-rc.10",
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.1",
35
+ "akanjs": "2.3.11-rc.10",
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 {
@@ -37,6 +38,7 @@ interface ExportedFunctionLike {
37
38
  file: string;
38
39
  line: number;
39
40
  bodyFingerprint?: string;
41
+ duplicateNameExempt: boolean;
40
42
  }
41
43
 
42
44
  interface TopLevelDeclaration {
@@ -47,6 +49,14 @@ interface TopLevelDeclaration {
47
49
  node: ts.Statement;
48
50
  }
49
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
+
50
60
  const MAX_FILE_LINES = 2000;
51
61
  const PLACEHOLDER_EXPORT_NAMES = new Set([
52
62
  "aa",
@@ -104,6 +114,61 @@ const CONVENTION_SUFFIXES = [
104
114
  ".store.ts",
105
115
  ] as const;
106
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
+
107
172
  export class AkanQualityScanner {
108
173
  async scan(workspaceRoot: string): Promise<QualityScanResult> {
109
174
  const targetFiles = await this.#collectTargetFiles(workspaceRoot);
@@ -111,6 +176,7 @@ export class AkanQualityScanner {
111
176
  const warnings = [
112
177
  ...this.#scanGlobalQuality(sourceFiles),
113
178
  ...sourceFiles.flatMap((sourceFile) => this.#scanSingleFileQuality(sourceFile)),
179
+ ...sourceFiles.flatMap((sourceFile) => this.#scanComponentQuality(sourceFile)),
114
180
  ...sourceFiles.flatMap((sourceFile) => this.#scanConventionQuality(sourceFile)),
115
181
  ...sourceFiles.flatMap((sourceFile) => this.#scanLayoutQuality(sourceFile)),
116
182
  ];
@@ -118,7 +184,9 @@ export class AkanQualityScanner {
118
184
  return {
119
185
  workspaceRoot,
120
186
  scannedFiles: sourceFiles.length,
121
- warnings: warnings.sort(compareWarnings),
187
+ warnings: warnings
188
+ .map((warning) => ({ ...warning, fix: warning.fix ?? getRuleFix(warning.rule) }))
189
+ .sort(compareWarnings),
122
190
  suggestedRules: SUGGESTED_RULES,
123
191
  };
124
192
  }
@@ -177,7 +245,8 @@ export class AkanQualityScanner {
177
245
  const exportedFunctionLikes = sourceFiles.flatMap((sourceFile) => getExportedFunctionLikes(sourceFile));
178
246
  const warnings: QualityWarning[] = [];
179
247
 
180
- for (const [name, declarations] of groupBy(exportedFunctionLikes, (declaration) => declaration.name)) {
248
+ const nameCheckedDeclarations = exportedFunctionLikes.filter((declaration) => !declaration.duplicateNameExempt);
249
+ for (const [name, declarations] of groupBy(nameCheckedDeclarations, (declaration) => declaration.name)) {
181
250
  if (declarations.length < 2) continue;
182
251
  warnings.push({
183
252
  rule: "akan.global.duplicate-exported-function-name",
@@ -254,6 +323,51 @@ export class AkanQualityScanner {
254
323
  return warnings;
255
324
  }
256
325
 
326
+ #scanComponentQuality(sourceFile: SourceFileInfo): QualityWarning[] {
327
+ if (!isComponentDeclarationFile(sourceFile.file)) return [];
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`));
339
+ const warnings: QualityWarning[] = [];
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
+ }
367
+ }
368
+ return warnings;
369
+ }
370
+
257
371
  #scanConventionQuality(sourceFile: SourceFileInfo): QualityWarning[] {
258
372
  const suffix = CONVENTION_SUFFIXES.find((candidate) => sourceFile.file.endsWith(candidate));
259
373
  if (!suffix) return [];
@@ -335,6 +449,7 @@ export function formatQualityWarnings(warnings: QualityWarning[]) {
335
449
  ...warning.locations.map(({ file, line }) => ` note: related location ${formatQualityLocation(file, line)}`),
336
450
  );
337
451
  }
452
+ if (warning.fix) lines.push(` fix: ${warning.fix}`);
338
453
  return lines;
339
454
  });
340
455
  }
@@ -345,6 +460,9 @@ function formatQualityLocation(file: string | undefined, line: number | undefine
345
460
 
346
461
  function getExportedFunctionLikes(sourceFile: SourceFileInfo): ExportedFunctionLike[] {
347
462
  const declarations: ExportedFunctionLike[] = [];
463
+ // UI component names (e.g. Card, Button) naturally repeat across apps/libs, so exempt ui files from the
464
+ // duplicate-exported-name check. This only relaxes the name check; the shared-body check still applies.
465
+ const nameExempt = isPageRouteFile(sourceFile.file) || isUiComponentFile(sourceFile.file);
348
466
  for (const statement of sourceFile.sourceFile.statements) {
349
467
  if (ts.isFunctionDeclaration(statement) && statement.name && isExported(statement)) {
350
468
  declarations.push({
@@ -353,6 +471,7 @@ function getExportedFunctionLikes(sourceFile: SourceFileInfo): ExportedFunctionL
353
471
  file: sourceFile.file,
354
472
  line: getLine(sourceFile.sourceFile, statement),
355
473
  bodyFingerprint: getBodyFingerprint(sourceFile.sourceFile, statement.body),
474
+ duplicateNameExempt: nameExempt || isConventionDuplicateNameExempt(sourceFile.file, false),
356
475
  });
357
476
  }
358
477
  if (ts.isClassDeclaration(statement) && statement.name && isExported(statement)) {
@@ -362,6 +481,9 @@ function getExportedFunctionLikes(sourceFile: SourceFileInfo): ExportedFunctionL
362
481
  file: sourceFile.file,
363
482
  line: getLine(sourceFile.sourceFile, statement),
364
483
  bodyFingerprint: getBodyFingerprint(sourceFile.sourceFile, statement),
484
+ duplicateNameExempt:
485
+ nameExempt ||
486
+ isConventionDuplicateNameExempt(sourceFile.file, isEnumClassStatement(sourceFile.sourceFile, statement)),
365
487
  });
366
488
  }
367
489
  if (ts.isVariableStatement(statement) && isExported(statement)) {
@@ -373,6 +495,7 @@ function getExportedFunctionLikes(sourceFile: SourceFileInfo): ExportedFunctionL
373
495
  file: sourceFile.file,
374
496
  line: getLine(sourceFile.sourceFile, declaration),
375
497
  bodyFingerprint: getBodyFingerprint(sourceFile.sourceFile, declaration.initializer),
498
+ duplicateNameExempt: nameExempt || isConventionDuplicateNameExempt(sourceFile.file, false),
376
499
  });
377
500
  }
378
501
  }
@@ -380,6 +503,43 @@ function getExportedFunctionLikes(sourceFile: SourceFileInfo): ExportedFunctionL
380
503
  return declarations;
381
504
  }
382
505
 
506
+ function isPageRouteFile(file: string) {
507
+ const segments = file.split("/");
508
+ return (segments[0] === "apps" || segments[0] === "libs") && segments[2] === "page";
509
+ }
510
+
511
+ function isUiComponentFile(file: string) {
512
+ const segments = file.split("/");
513
+ return (segments[0] === "apps" || segments[0] === "libs") && segments[2] === "ui";
514
+ }
515
+
516
+ function isConventionDuplicateNameExempt(file: string, isEnumClass: boolean) {
517
+ if (!isInLibModule(file)) return false;
518
+ if (file.endsWith(".tsx")) return true;
519
+ if (
520
+ file.endsWith(".document.ts") ||
521
+ file.endsWith(".service.ts") ||
522
+ file.endsWith(".signal.ts") ||
523
+ file.endsWith(".store.ts")
524
+ )
525
+ return true;
526
+ // Model view classes may repeat across modules; enum classes must stay uniquely named.
527
+ if (file.endsWith(".constant.ts")) return !isEnumClass;
528
+ return false;
529
+ }
530
+
531
+ function isInLibModule(file: string) {
532
+ const segments = file.split("/");
533
+ return (segments[0] === "apps" || segments[0] === "libs") && segments.includes("lib");
534
+ }
535
+
536
+ function isEnumClassStatement(sourceFile: ts.SourceFile, statement: ts.Statement) {
537
+ if (!ts.isClassDeclaration(statement)) return false;
538
+ const heritageClause = statement.heritageClauses?.find((clause) => clause.token === ts.SyntaxKind.ExtendsKeyword);
539
+ const expression = heritageClause?.types[0]?.expression;
540
+ return !!expression && expression.getText(sourceFile).startsWith("enumOf(");
541
+ }
542
+
383
543
  function getExportedClassNames(sourceFile: ts.SourceFile) {
384
544
  return sourceFile.statements
385
545
  .filter((statement): statement is ts.ClassDeclaration => ts.isClassDeclaration(statement) && !!statement.name)
@@ -387,6 +547,86 @@ function getExportedClassNames(sourceFile: ts.SourceFile) {
387
547
  .map((statement) => statement.name!.text);
388
548
  }
389
549
 
550
+ function isComponentDeclarationFile(file: string) {
551
+ if (!file.endsWith(".tsx")) return false;
552
+ const segments = file.split("/");
553
+ const [root, , area] = segments;
554
+ if (root !== "apps" && root !== "libs") return false;
555
+ if (area === "lib" || area === "ui") return true;
556
+ return root === "apps" && area === "page";
557
+ }
558
+
559
+ function getComponentFileDeclarations(sourceFile: ts.SourceFile): ComponentFileDeclaration[] {
560
+ const reExportedNames = new Set<string>();
561
+ for (const statement of sourceFile.statements) {
562
+ if (ts.isExportDeclaration(statement) && statement.exportClause && ts.isNamedExports(statement.exportClause)) {
563
+ for (const element of statement.exportClause.elements)
564
+ reExportedNames.add((element.propertyName ?? element.name).text);
565
+ }
566
+ }
567
+ const declarations: ComponentFileDeclaration[] = [];
568
+ for (const statement of sourceFile.statements) {
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)) {
581
+ for (const declaration of statement.declarationList.declarations) {
582
+ if (!ts.isIdentifier(declaration.name)) continue;
583
+ const kind = isFunctionLikeInitializer(declaration.initializer) ? "function" : "variable";
584
+ add(declaration.name.text, kind, getLine(sourceFile, declaration));
585
+ }
586
+ }
587
+ }
588
+ return declarations;
589
+ }
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
+
390
630
  function getPlaceholderExportWarnings(sourceFile: SourceFileInfo): QualityWarning[] {
391
631
  if (!sourceFile.file.endsWith("/index.ts") && !sourceFile.file.endsWith("/index.tsx")) return [];
392
632
  return getTopLevelDeclarations(sourceFile)
@@ -403,11 +643,7 @@ function getPlaceholderExportWarnings(sourceFile: SourceFileInfo): QualityWarnin
403
643
 
404
644
  function getDictionaryTextWarnings(sourceFile: SourceFileInfo): QualityWarning[] {
405
645
  if (!sourceFile.file.endsWith(".dictionary.ts")) return [];
406
- const stalePatterns = [
407
- { pattern: /\b[A-Z][A-Za-z0-9]* description\b/, label: "scaffold description text" },
408
- { pattern: /settting/, label: "misspelling: settting" },
409
- { pattern: /배너 수/, label: "stale copied Korean domain noun: 배너 수" },
410
- ];
646
+ const stalePatterns = [{ pattern: /\b[A-Z][A-Za-z0-9]* description\b/, label: "scaffold description text" }];
411
647
  return stalePatterns.flatMap(({ pattern, label }) =>
412
648
  findPatternLines(sourceFile.content, pattern).map((line) => ({
413
649
  rule: "akan.file.dictionary-stale-text",
@@ -549,8 +785,8 @@ function getConventionDescription(suffix: (typeof CONVENTION_SUFFIXES)[number],
549
785
 
550
786
  function getLibRootFile(file: string) {
551
787
  const segments = file.split("/");
552
- if (segments[0] === "libs" && segments.length === 4 && segments[2] === "lib") return segments[3];
553
- if (segments[0] === "apps" && segments.length === 5 && segments[2] === "lib") return segments[4];
788
+ if ((segments[0] === "libs" || segments[0] === "apps") && segments.length === 4 && segments[2] === "lib")
789
+ return segments[3];
554
790
  return null;
555
791
  }
556
792
 
package/scanInfo.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import path from "node:path";
2
+ import { rm } from "node:fs/promises";
2
3
  import type {
3
4
  AppConfigResult,
4
5
  AppScanResult,
@@ -52,7 +53,9 @@ const appRootAllowedFiles = new Set([
52
53
  "package.json",
53
54
  "server.ts",
54
55
  "tsconfig.json",
56
+ "tsconfig.tsbuildinfo",
55
57
  ]);
58
+ const generatedRootCapacitorConfigFiles = ["capacitor.config.js", "capacitor.config.json"] as const;
56
59
  const appRootAllowedDirs = new Set([
57
60
  ".akan",
58
61
  "android",
@@ -99,12 +102,17 @@ const isAllowedLibRootFile = (filename: string) =>
99
102
  libRootAllowedFiles.has(filename) || rootSignalTestFilePattern.test(filename);
100
103
  const getScanPath = (exec: AppExecutor | LibExecutor, relativePath: string) =>
101
104
  path.posix.join(`${exec.type}s`, exec.name, relativePath.split(path.sep).join("/"));
105
+ async function clearGeneratedRootCapacitorConfigs(exec: AppExecutor | LibExecutor) {
106
+ if (exec.type !== "app") return;
107
+ await Promise.all(generatedRootCapacitorConfigFiles.map((filename) => rm(exec.getPath(filename), { force: true })));
108
+ }
102
109
  const getModuleNameFromPath = (kind: ModuleKind, modulePath: string) => {
103
110
  const dirname = path.basename(modulePath);
104
111
  return kind === "service" ? dirname.replace(/^_+/, "") : dirname;
105
112
  };
106
113
 
107
114
  async function assertScanConvention(exec: AppExecutor | LibExecutor, libRoot: { files: string[]; dirs: string[] }) {
115
+ await clearGeneratedRootCapacitorConfigs(exec);
108
116
  const violations: string[] = [];
109
117
  const addViolation = (relativePath: string, reason: string) => {
110
118
  violations.push(`${getScanPath(exec, relativePath)}: ${reason}`);
@@ -196,7 +196,6 @@ export const generatedFilePathsForTarget = (targetRoot: string, reason = "Genera
196
196
  [
197
197
  { path: `${targetRoot}/lib/cnst.ts`, action: "sync", reason },
198
198
  { path: `${targetRoot}/lib/dict.ts`, action: "sync", reason },
199
- { path: `${targetRoot}/lib/option.ts`, action: "sync", reason },
200
199
  { path: `${targetRoot}/lib/index.ts`, action: "sync", reason },
201
200
  ] satisfies PrimitiveGeneratedFile[];
202
201