@akanjs/devkit 2.3.11-rc.8 → 2.3.11

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
@@ -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();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "2.3.11-rc.8",
3
+ "version": "2.3.11",
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.8",
35
+ "akanjs": "2.3.11",
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);
@@ -112,6 +176,7 @@ export class AkanQualityScanner {
112
176
  const warnings = [
113
177
  ...this.#scanGlobalQuality(sourceFiles),
114
178
  ...sourceFiles.flatMap((sourceFile) => this.#scanSingleFileQuality(sourceFile)),
179
+ ...sourceFiles.flatMap((sourceFile) => this.#scanComponentQuality(sourceFile)),
115
180
  ...sourceFiles.flatMap((sourceFile) => this.#scanConventionQuality(sourceFile)),
116
181
  ...sourceFiles.flatMap((sourceFile) => this.#scanLayoutQuality(sourceFile)),
117
182
  ];
@@ -119,7 +184,9 @@ export class AkanQualityScanner {
119
184
  return {
120
185
  workspaceRoot,
121
186
  scannedFiles: sourceFiles.length,
122
- warnings: warnings.sort(compareWarnings),
187
+ warnings: warnings
188
+ .map((warning) => ({ ...warning, fix: warning.fix ?? getRuleFix(warning.rule) }))
189
+ .sort(compareWarnings),
123
190
  suggestedRules: SUGGESTED_RULES,
124
191
  };
125
192
  }
@@ -256,6 +323,51 @@ export class AkanQualityScanner {
256
323
  return warnings;
257
324
  }
258
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
+
259
371
  #scanConventionQuality(sourceFile: SourceFileInfo): QualityWarning[] {
260
372
  const suffix = CONVENTION_SUFFIXES.find((candidate) => sourceFile.file.endsWith(candidate));
261
373
  if (!suffix) return [];
@@ -337,6 +449,7 @@ export function formatQualityWarnings(warnings: QualityWarning[]) {
337
449
  ...warning.locations.map(({ file, line }) => ` note: related location ${formatQualityLocation(file, line)}`),
338
450
  );
339
451
  }
452
+ if (warning.fix) lines.push(` fix: ${warning.fix}`);
340
453
  return lines;
341
454
  });
342
455
  }
@@ -347,7 +460,9 @@ function formatQualityLocation(file: string | undefined, line: number | undefine
347
460
 
348
461
  function getExportedFunctionLikes(sourceFile: SourceFileInfo): ExportedFunctionLike[] {
349
462
  const declarations: ExportedFunctionLike[] = [];
350
- const pageExempt = isPageRouteFile(sourceFile.file);
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);
351
466
  for (const statement of sourceFile.sourceFile.statements) {
352
467
  if (ts.isFunctionDeclaration(statement) && statement.name && isExported(statement)) {
353
468
  declarations.push({
@@ -356,7 +471,7 @@ function getExportedFunctionLikes(sourceFile: SourceFileInfo): ExportedFunctionL
356
471
  file: sourceFile.file,
357
472
  line: getLine(sourceFile.sourceFile, statement),
358
473
  bodyFingerprint: getBodyFingerprint(sourceFile.sourceFile, statement.body),
359
- duplicateNameExempt: pageExempt || isConventionDuplicateNameExempt(sourceFile.file, false),
474
+ duplicateNameExempt: nameExempt || isConventionDuplicateNameExempt(sourceFile.file, false),
360
475
  });
361
476
  }
362
477
  if (ts.isClassDeclaration(statement) && statement.name && isExported(statement)) {
@@ -367,7 +482,7 @@ function getExportedFunctionLikes(sourceFile: SourceFileInfo): ExportedFunctionL
367
482
  line: getLine(sourceFile.sourceFile, statement),
368
483
  bodyFingerprint: getBodyFingerprint(sourceFile.sourceFile, statement),
369
484
  duplicateNameExempt:
370
- pageExempt ||
485
+ nameExempt ||
371
486
  isConventionDuplicateNameExempt(sourceFile.file, isEnumClassStatement(sourceFile.sourceFile, statement)),
372
487
  });
373
488
  }
@@ -380,7 +495,7 @@ function getExportedFunctionLikes(sourceFile: SourceFileInfo): ExportedFunctionL
380
495
  file: sourceFile.file,
381
496
  line: getLine(sourceFile.sourceFile, declaration),
382
497
  bodyFingerprint: getBodyFingerprint(sourceFile.sourceFile, declaration.initializer),
383
- duplicateNameExempt: pageExempt || isConventionDuplicateNameExempt(sourceFile.file, false),
498
+ duplicateNameExempt: nameExempt || isConventionDuplicateNameExempt(sourceFile.file, false),
384
499
  });
385
500
  }
386
501
  }
@@ -393,6 +508,11 @@ function isPageRouteFile(file: string) {
393
508
  return (segments[0] === "apps" || segments[0] === "libs") && segments[2] === "page";
394
509
  }
395
510
 
511
+ function isUiComponentFile(file: string) {
512
+ const segments = file.split("/");
513
+ return (segments[0] === "apps" || segments[0] === "libs") && segments[2] === "ui";
514
+ }
515
+
396
516
  function isConventionDuplicateNameExempt(file: string, isEnumClass: boolean) {
397
517
  if (!isInLibModule(file)) return false;
398
518
  if (file.endsWith(".tsx")) return true;
@@ -427,6 +547,86 @@ function getExportedClassNames(sourceFile: ts.SourceFile) {
427
547
  .map((statement) => statement.name!.text);
428
548
  }
429
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
+
430
630
  function getPlaceholderExportWarnings(sourceFile: SourceFileInfo): QualityWarning[] {
431
631
  if (!sourceFile.file.endsWith("/index.ts") && !sourceFile.file.endsWith("/index.tsx")) return [];
432
632
  return getTopLevelDeclarations(sourceFile)