@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.
- package/akanApp/akanApp.host.ts +38 -0
- package/akanConfig/akanConfig.ts +1 -3
- package/akanContext.ts +67 -2
- package/applicationTestPreload.ts +7 -2
- package/artifact/implicitRootLayout.ts +51 -6
- package/artifact/routeSeedIndex.ts +3 -1
- package/capacitorApp.ts +89 -14
- package/executors.test.ts +1 -1
- package/executors.ts +72 -1
- package/firebaseMessagingSw.test.ts +66 -0
- package/firebaseMessagingSw.ts +74 -0
- package/index.ts +1 -0
- package/lint/no-deep-internal-import.grit +10 -0
- package/lint/no-redeclare-predefined-endpoint.grit +28 -0
- package/lint/no-throw-raw-error.grit +40 -0
- package/package.json +2 -2
- package/qualityScanner.ts +245 -9
- package/scanInfo.ts +8 -0
- package/workflow/artifacts.ts +0 -1
- package/workflow/executor.ts +20 -0
- package/workflow/source.test.ts +114 -1
- package/workflow/source.ts +131 -0
- package/workflow/types.ts +10 -0
|
@@ -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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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")
|
|
553
|
-
|
|
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}`);
|
package/workflow/artifacts.ts
CHANGED
|
@@ -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
|
|