@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 +25 -0
- package/artifact/implicitRootLayout.ts +51 -6
- package/artifact/routeSeedIndex.ts +3 -1
- package/executors.ts +40 -1
- package/package.json +2 -2
- package/qualityScanner.ts +205 -5
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 =
|
|
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();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "2.3.11
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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)
|