@akanjs/devkit 2.3.9-rc.9 → 2.3.10-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/akanConfig/akanConfig.test.ts +14 -2
- package/akanConfig/akanConfig.ts +54 -1
- package/applicationReleasePackager.ts +17 -14
- package/capacitorApp.test.ts +4 -2
- package/capacitorApp.ts +141 -23
- package/frontendBuild/ssrBaseArtifactBuilder.ts +9 -0
- package/index.ts +1 -0
- package/package.json +2 -2
- package/qualityScanner.ts +687 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# @akanjs/devkit
|
|
2
2
|
|
|
3
|
+
## 2.3.9
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- f518afd: Improve dictionary type inference and lint coverage for generated workspaces.
|
|
8
|
+
- 6bc2209: auto-select app when the workspace has only one app
|
|
9
|
+
- Updated dependencies [f518afd]
|
|
10
|
+
- Updated dependencies [f518afd]
|
|
11
|
+
- akanjs@2.3.9
|
|
12
|
+
|
|
3
13
|
## 2.3.6
|
|
4
14
|
|
|
5
15
|
### Patch Changes
|
|
@@ -281,11 +281,17 @@ describe("AkanAppConfig", () => {
|
|
|
281
281
|
targets: {
|
|
282
282
|
admin: {
|
|
283
283
|
basePath: "admin",
|
|
284
|
+
indexPath: "/admin/home/",
|
|
284
285
|
appName: "Portal Admin",
|
|
285
286
|
appId: "com.portal.admin",
|
|
286
287
|
buildNum: 8,
|
|
287
288
|
permissions: ["camera"],
|
|
288
|
-
|
|
289
|
+
deepLinks: {
|
|
290
|
+
schemes: ["portal-admin", "portal-admin"],
|
|
291
|
+
domains: ["https://Portal.Admin/"],
|
|
292
|
+
ios: { teamId: " TEAMID " },
|
|
293
|
+
android: { sha256CertFingerprints: ["AA:BB", "AA:BB"] },
|
|
294
|
+
},
|
|
289
295
|
},
|
|
290
296
|
},
|
|
291
297
|
},
|
|
@@ -296,12 +302,18 @@ describe("AkanAppConfig", () => {
|
|
|
296
302
|
expect(config.mobile.targets.admin).toMatchObject({
|
|
297
303
|
name: "admin",
|
|
298
304
|
basePath: "admin",
|
|
305
|
+
indexPath: "/admin/home",
|
|
299
306
|
appName: "Portal Admin",
|
|
300
307
|
appId: "com.portal.admin",
|
|
301
308
|
version: "1.0.0",
|
|
302
309
|
buildNum: 8,
|
|
303
310
|
permissions: ["camera"],
|
|
304
|
-
|
|
311
|
+
deepLinks: {
|
|
312
|
+
schemes: ["portal-admin"],
|
|
313
|
+
domains: ["portal.admin"],
|
|
314
|
+
ios: { teamId: "TEAMID" },
|
|
315
|
+
android: { sha256CertFingerprints: ["AA:BB"] },
|
|
316
|
+
},
|
|
305
317
|
});
|
|
306
318
|
|
|
307
319
|
expect(
|
package/akanConfig/akanConfig.ts
CHANGED
|
@@ -76,6 +76,47 @@ const DEFAULT_AKAN_IMAGE_CONFIG: AkanImageConfig = {
|
|
|
76
76
|
maxRemoteBytes: 25 * 1024 * 1024,
|
|
77
77
|
};
|
|
78
78
|
|
|
79
|
+
const normalizeIndexPath = (indexPath: string | undefined): string | undefined => {
|
|
80
|
+
const normalized = indexPath?.trim();
|
|
81
|
+
if (!normalized) return undefined;
|
|
82
|
+
const path = `/${normalized.replace(/^\/+|\/+$/g, "")}`;
|
|
83
|
+
return path === "/" ? "/" : path;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const normalizeStringList = (values: string[] | undefined) => {
|
|
87
|
+
const normalized = values?.map((value) => value.trim()).filter(Boolean) ?? [];
|
|
88
|
+
return normalized.length > 0 ? [...new Set(normalized)] : undefined;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const normalizeDeepLinkDomain = (domain: string) => {
|
|
92
|
+
const normalized = domain.trim();
|
|
93
|
+
if (!normalized) return "";
|
|
94
|
+
try {
|
|
95
|
+
const url = new URL(normalized.includes("://") ? normalized : `https://${normalized}`);
|
|
96
|
+
return url.host.toLowerCase();
|
|
97
|
+
} catch {
|
|
98
|
+
return normalized
|
|
99
|
+
.replace(/^https?:\/\//, "")
|
|
100
|
+
.replace(/\/+$/g, "")
|
|
101
|
+
.toLowerCase();
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const normalizeDeepLinks = (deepLinks: DeepPartial<AkanMobileTargetConfig["deepLinks"]> | undefined) => {
|
|
106
|
+
if (!deepLinks) return undefined;
|
|
107
|
+
const schemes = normalizeStringList(deepLinks.schemes as string[] | undefined);
|
|
108
|
+
const domains = normalizeStringList((deepLinks.domains as string[] | undefined)?.map(normalizeDeepLinkDomain));
|
|
109
|
+
const teamId = (deepLinks.ios?.teamId as string | undefined)?.trim();
|
|
110
|
+
const sha256CertFingerprints = normalizeStringList(deepLinks.android?.sha256CertFingerprints as string[] | undefined);
|
|
111
|
+
if (!schemes && !domains && !teamId && !sha256CertFingerprints) return undefined;
|
|
112
|
+
return {
|
|
113
|
+
...(schemes ? { schemes } : {}),
|
|
114
|
+
...(domains ? { domains } : {}),
|
|
115
|
+
...(teamId ? { ios: { teamId } } : {}),
|
|
116
|
+
...(sha256CertFingerprints ? { android: { sha256CertFingerprints } } : {}),
|
|
117
|
+
} satisfies AkanMobileTargetConfig["deepLinks"];
|
|
118
|
+
};
|
|
119
|
+
|
|
79
120
|
export class AkanAppConfig implements AppConfigResult {
|
|
80
121
|
app: App;
|
|
81
122
|
rootPackageJson: PackageJson;
|
|
@@ -88,6 +129,7 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
88
129
|
i18n: AkanI18nConfig;
|
|
89
130
|
publicEnv: string[];
|
|
90
131
|
mobile: AkanMobileConfig;
|
|
132
|
+
secrets: string[];
|
|
91
133
|
baseDevEnv: BaseDevEnv;
|
|
92
134
|
libs: string[];
|
|
93
135
|
domains = new Set<string>();
|
|
@@ -120,11 +162,18 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
120
162
|
process.env.AKAN_PUBLIC_DEFAULT_LOCALE = this.i18n.defaultLocale;
|
|
121
163
|
process.env.AKAN_PUBLIC_LOCALES = this.i18n.locales.join(",");
|
|
122
164
|
this.publicEnv = (config?.publicEnv as string[] | undefined) ?? ([] as string[]);
|
|
165
|
+
this.secrets = (config?.secrets as string[] | undefined) ?? ([] as string[]);
|
|
123
166
|
this.mobile = this.#resolveMobileConfig(config.mobile);
|
|
124
167
|
this.docker = this.#makeDockerContent(config?.docker ?? {});
|
|
125
168
|
}
|
|
126
169
|
#resolveMobileConfig(mobile: DeepPartial<AkanMobileConfig> | undefined): AkanMobileConfig {
|
|
127
|
-
const {
|
|
170
|
+
const {
|
|
171
|
+
targets: rawTargets,
|
|
172
|
+
indexPath: _indexPath,
|
|
173
|
+
...rawMobile
|
|
174
|
+
} = (mobile ?? {}) as DeepPartial<AkanMobileConfig> & {
|
|
175
|
+
indexPath?: unknown;
|
|
176
|
+
};
|
|
128
177
|
const appName = rawMobile.appName ?? this.app.name;
|
|
129
178
|
const appId = rawMobile.appId ?? `com.${this.app.name}.app`;
|
|
130
179
|
const version = rawMobile.version ?? "0.0.1";
|
|
@@ -140,6 +189,8 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
140
189
|
const target = rawTarget as DeepPartial<AkanMobileTargetConfig>;
|
|
141
190
|
const fallbackBasePath = !rawTargets && this.basePaths.has(name) ? name : undefined;
|
|
142
191
|
const basePath = (target.basePath ?? fallbackBasePath)?.replace(/^\/+|\/+$/g, "") || undefined;
|
|
192
|
+
const indexPath = normalizeIndexPath(target.indexPath as string | undefined);
|
|
193
|
+
const deepLinks = normalizeDeepLinks(target.deepLinks);
|
|
143
194
|
if (basePath && !this.basePaths.has(basePath)) {
|
|
144
195
|
throw new Error(
|
|
145
196
|
`Mobile target '${name}' uses unknown basePath '${basePath}' in apps/${this.app.name}/akan.config.ts`,
|
|
@@ -150,6 +201,8 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
150
201
|
...target,
|
|
151
202
|
name,
|
|
152
203
|
basePath,
|
|
204
|
+
indexPath,
|
|
205
|
+
deepLinks,
|
|
153
206
|
appName: target.appName ?? appName,
|
|
154
207
|
appId: target.appId ?? appId,
|
|
155
208
|
version: target.version ?? version,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { cp, mkdir, rm } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
2
3
|
import type { App } from "./commandDecorators";
|
|
3
4
|
import { FileSys } from "./fileSys";
|
|
4
5
|
import { uploadRelease } from "./uploadRelease";
|
|
@@ -61,13 +62,14 @@ export class ApplicationReleasePackager {
|
|
|
61
62
|
await cp(this.#app.dist.cwdPath, buildRoot, { recursive: true });
|
|
62
63
|
await rm(`${buildRoot}/frontend/.next`, { recursive: true, force: true });
|
|
63
64
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
65
|
+
const releaseRoot = this.#app.workspace.workspaceRoot;
|
|
66
|
+
// Pass a path relative to cwd so tar never sees a Windows drive letter (e.g. "C:\...")
|
|
67
|
+
// which GNU tar would interpret as a remote "host:file" spec.
|
|
68
|
+
const releaseArchivePath = path
|
|
69
|
+
.relative(releaseRoot, `${releaseRoot}/releases/builds/${this.#app.name}-release.tar.gz`)
|
|
70
|
+
.split(path.sep)
|
|
71
|
+
.join("/");
|
|
72
|
+
await this.#app.workspace.spawn("tar", ["-zcf", releaseArchivePath, "-C", buildRoot, "./"], { cwd: releaseRoot });
|
|
71
73
|
await this.#writeCsrZipIfPresent();
|
|
72
74
|
return { platformVersion };
|
|
73
75
|
}
|
|
@@ -109,13 +111,14 @@ export class ApplicationReleasePackager {
|
|
|
109
111
|
);
|
|
110
112
|
await this.#writeSourceTsconfig(sourceRoot, libDeps);
|
|
111
113
|
await Bun.write(`${sourceRoot}/README.md`, readme);
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
114
|
+
const sourceCwd = this.#app.workspace.cwdPath;
|
|
115
|
+
// Pass a path relative to cwd so tar never sees a Windows drive letter (e.g. "C:\...")
|
|
116
|
+
// which GNU tar would interpret as a remote "host:file" spec.
|
|
117
|
+
const sourceArchivePath = path
|
|
118
|
+
.relative(sourceCwd, `${sourceCwd}/releases/sources/${this.#app.name}-source.tar.gz`)
|
|
119
|
+
.split(path.sep)
|
|
120
|
+
.join("/");
|
|
121
|
+
await this.#app.workspace.spawn("tar", ["-zcf", sourceArchivePath, "-C", sourceRoot, "./"], { cwd: sourceCwd });
|
|
119
122
|
}
|
|
120
123
|
|
|
121
124
|
async #resetSourceRoot(sourceRoot: string): Promise<void> {
|
package/capacitorApp.test.ts
CHANGED
|
@@ -34,9 +34,10 @@ const baseTarget: AkanMobileTargetConfig = {
|
|
|
34
34
|
version: "1.2.3",
|
|
35
35
|
buildNum: 7,
|
|
36
36
|
basePath: "admin",
|
|
37
|
+
indexPath: "/dashboard",
|
|
37
38
|
assets: { icon: "mobile/icon.png" },
|
|
38
39
|
permissions: ["camera"],
|
|
39
|
-
|
|
40
|
+
deepLinks: { schemes: ["minimal"], domains: ["minimal.app"] },
|
|
40
41
|
files: { android: { "app/google-services.json": "private/google-services.json" } },
|
|
41
42
|
};
|
|
42
43
|
|
|
@@ -74,11 +75,12 @@ describe("materializeCapacitorConfig", () => {
|
|
|
74
75
|
});
|
|
75
76
|
expect(config).not.toHaveProperty("name");
|
|
76
77
|
expect(config).not.toHaveProperty("basePath");
|
|
78
|
+
expect(config).not.toHaveProperty("indexPath");
|
|
77
79
|
expect(config).not.toHaveProperty("version");
|
|
78
80
|
expect(config).not.toHaveProperty("buildNum");
|
|
79
81
|
expect(config).not.toHaveProperty("assets");
|
|
80
82
|
expect(config).not.toHaveProperty("permissions");
|
|
81
|
-
expect(config).not.toHaveProperty("
|
|
83
|
+
expect(config).not.toHaveProperty("deepLinks");
|
|
82
84
|
expect(config).not.toHaveProperty("files");
|
|
83
85
|
expect(config).not.toHaveProperty("server");
|
|
84
86
|
});
|
package/capacitorApp.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { cp, mkdir, rm } from "node:fs/promises";
|
|
1
|
+
import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import type { CapacitorConfig } from "@capacitor/cli";
|
|
@@ -463,11 +463,12 @@ export function materializeCapacitorConfig(
|
|
|
463
463
|
const {
|
|
464
464
|
name,
|
|
465
465
|
basePath: _basePath,
|
|
466
|
+
indexPath: _indexPath,
|
|
466
467
|
version: _version,
|
|
467
468
|
buildNum: _buildNum,
|
|
468
469
|
assets: _assets,
|
|
469
470
|
permissions: _permissions,
|
|
470
|
-
|
|
471
|
+
deepLinks: _deepLinks,
|
|
471
472
|
files: _files,
|
|
472
473
|
appId,
|
|
473
474
|
appName,
|
|
@@ -587,7 +588,7 @@ export class CapacitorApp {
|
|
|
587
588
|
await this.#prepareExternalFiles("ios");
|
|
588
589
|
await this.#applyIosMetadata();
|
|
589
590
|
await this.#applyPermissions();
|
|
590
|
-
await this.#
|
|
591
|
+
await this.#applyDeepLinks("ios", { operation, env });
|
|
591
592
|
await this.project.commit();
|
|
592
593
|
await this.#generateAssets({ operation, env });
|
|
593
594
|
this.app.verbose(`syncing iOS`);
|
|
@@ -749,12 +750,13 @@ export class CapacitorApp {
|
|
|
749
750
|
await this.#prepareExternalFiles("android");
|
|
750
751
|
await this.#applyAndroidMetadata();
|
|
751
752
|
await this.#applyPermissions();
|
|
752
|
-
await this.#
|
|
753
|
+
await this.#applyDeepLinks("android", { operation, env });
|
|
753
754
|
await this.project.commit();
|
|
754
755
|
await this.#generateAssets({ operation, env });
|
|
755
756
|
await this.#ensureAndroidAssetsDir();
|
|
756
757
|
await this.#ensureAndroidDebugKeystore();
|
|
757
758
|
await this.#spawnMobile("npx", ["cap", "sync", "android"], { operation, env });
|
|
759
|
+
await this.#setDeepLinksInAndroid(this.target.deepLinks?.schemes ?? [], this.target.deepLinks?.domains ?? []);
|
|
758
760
|
}
|
|
759
761
|
|
|
760
762
|
async #updateAndroidBuildTypes() {
|
|
@@ -898,7 +900,11 @@ export class CapacitorApp {
|
|
|
898
900
|
}
|
|
899
901
|
#injectMobileTargetMeta(html: string) {
|
|
900
902
|
const basePath = this.target.basePath?.replace(/^\/+|\/+$/g, "") ?? "";
|
|
901
|
-
const script = `<script>window.__AKAN_MOBILE_TARGET__=${JSON.stringify({
|
|
903
|
+
const script = `<script>window.__AKAN_MOBILE_TARGET__=${JSON.stringify({
|
|
904
|
+
name: this.target.name,
|
|
905
|
+
basePath,
|
|
906
|
+
indexPath: this.target.indexPath,
|
|
907
|
+
})};</script>`;
|
|
902
908
|
if (html.includes("window.__AKAN_MOBILE_TARGET__")) return html;
|
|
903
909
|
return html.replace(/<\/head\s*>/i, `${script}\n</head>`);
|
|
904
910
|
}
|
|
@@ -977,14 +983,23 @@ export class CapacitorApp {
|
|
|
977
983
|
else if (permission === "push") await this.addPush();
|
|
978
984
|
}
|
|
979
985
|
}
|
|
980
|
-
async #
|
|
981
|
-
const
|
|
982
|
-
if (!
|
|
983
|
-
const schemes =
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
986
|
+
async #applyDeepLinks(platform: "ios" | "android", { operation, env }: Pick<RunConfig, "operation" | "env">) {
|
|
987
|
+
const deepLinks = this.target.deepLinks;
|
|
988
|
+
if (!deepLinks) return;
|
|
989
|
+
const schemes = deepLinks.schemes ?? [];
|
|
990
|
+
const domains = deepLinks.domains ?? [];
|
|
991
|
+
if (domains.length > 0) this.#assertDeepLinkVerificationConfig(platform, { operation, env });
|
|
992
|
+
if (platform === "ios") {
|
|
993
|
+
if (schemes.length > 0) {
|
|
994
|
+
await this.#setPermissionInIos({
|
|
995
|
+
appTransportSecurity: "",
|
|
996
|
+
});
|
|
997
|
+
await this.#setUrlSchemesInIos(schemes);
|
|
998
|
+
}
|
|
999
|
+
if (domains.length > 0) await this.#setAssociatedDomainsInIos(domains);
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
if (platform === "android") {
|
|
988
1003
|
for (const scheme of schemes) {
|
|
989
1004
|
this.project.android
|
|
990
1005
|
.getAndroidManifest()
|
|
@@ -993,18 +1008,30 @@ export class CapacitorApp {
|
|
|
993
1008
|
`<intent-filter><action android:name="android.intent.action.VIEW" /><category android:name="android.intent.category.DEFAULT" /><category android:name="android.intent.category.BROWSABLE" /><data android:scheme="${scheme}" /></intent-filter>`,
|
|
994
1009
|
);
|
|
995
1010
|
}
|
|
1011
|
+
for (const domain of domains) {
|
|
1012
|
+
const pathPrefix = resolveMobilePath(this.target, "/");
|
|
1013
|
+
this.project.android
|
|
1014
|
+
.getAndroidManifest()
|
|
1015
|
+
.injectFragment(
|
|
1016
|
+
"activity",
|
|
1017
|
+
`<intent-filter android:autoVerify="true"><action android:name="android.intent.action.VIEW" /><category android:name="android.intent.category.DEFAULT" /><category android:name="android.intent.category.BROWSABLE" /><data android:scheme="https" android:host="${domain}" android:pathPrefix="${pathPrefix}" /></intent-filter>`,
|
|
1018
|
+
);
|
|
1019
|
+
}
|
|
1020
|
+
await this.#setDeepLinksInAndroid(schemes, domains);
|
|
996
1021
|
}
|
|
997
|
-
|
|
998
|
-
|
|
1022
|
+
}
|
|
1023
|
+
#assertDeepLinkVerificationConfig(platform: "ios" | "android", { operation, env }: Pick<RunConfig, "operation" | "env">) {
|
|
1024
|
+
const deepLinks = this.target.deepLinks;
|
|
1025
|
+
if (!deepLinks?.domains?.length) return;
|
|
1026
|
+
if (platform === "ios" && !deepLinks.ios?.teamId) {
|
|
1027
|
+
throw new Error(
|
|
1028
|
+
`Mobile target '${this.target.name}' uses deepLinks.domains but is missing deepLinks.ios.teamId in apps/${this.app.name}/akan.config.ts`,
|
|
1029
|
+
);
|
|
999
1030
|
}
|
|
1000
|
-
|
|
1001
|
-
const
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
.injectFragment(
|
|
1005
|
-
"activity",
|
|
1006
|
-
`<intent-filter android:autoVerify="true"><action android:name="android.intent.action.VIEW" /><category android:name="android.intent.category.DEFAULT" /><category android:name="android.intent.category.BROWSABLE" /><data android:scheme="https" android:host="${host}" android:pathPrefix="${pathPrefix}" /></intent-filter>`,
|
|
1007
|
-
);
|
|
1031
|
+
if (platform === "android" && !deepLinks.android?.sha256CertFingerprints?.length) {
|
|
1032
|
+
const message = `Mobile target '${this.target.name}' uses deepLinks.domains but is missing deepLinks.android.sha256CertFingerprints in apps/${this.app.name}/akan.config.ts`;
|
|
1033
|
+
if (operation === "release" || env === "main") throw new Error(message);
|
|
1034
|
+
this.app.logger.warn(message);
|
|
1008
1035
|
}
|
|
1009
1036
|
}
|
|
1010
1037
|
async #commandEnv(operation: "local" | "release", env: "local" | "debug" | "develop" | "main") {
|
|
@@ -1024,6 +1051,7 @@ export class CapacitorApp {
|
|
|
1024
1051
|
const port = commandEnv.AKAN_PUBLIC_CLIENT_PORT ?? commandEnv.PORT ?? "8282";
|
|
1025
1052
|
const params = new URLSearchParams({ csr: "true", akanMobileTarget: this.target.name });
|
|
1026
1053
|
if (basePath) params.set("akanMobileBasePath", basePath);
|
|
1054
|
+
if (this.target.indexPath) params.set("akanMobileIndexPath", this.target.indexPath);
|
|
1027
1055
|
return `http://${ip}:${port}/${pathname}?${params}`;
|
|
1028
1056
|
}
|
|
1029
1057
|
async #clearRootCapacitorConfigs() {
|
|
@@ -1090,6 +1118,96 @@ export class CapacitorApp {
|
|
|
1090
1118
|
this.project.ios.updateInfoPlist(this.iosTargetName, "Release", updateNs),
|
|
1091
1119
|
]);
|
|
1092
1120
|
}
|
|
1121
|
+
async #setUrlSchemesInIos(schemes: string[]) {
|
|
1122
|
+
const urlTypes = schemes.map((scheme) => ({
|
|
1123
|
+
CFBundleURLName: this.target.appId,
|
|
1124
|
+
CFBundleURLSchemes: [scheme],
|
|
1125
|
+
}));
|
|
1126
|
+
await Promise.all([
|
|
1127
|
+
this.project.ios.updateInfoPlist(this.iosTargetName, "Debug", { CFBundleURLTypes: urlTypes }),
|
|
1128
|
+
this.project.ios.updateInfoPlist(this.iosTargetName, "Release", { CFBundleURLTypes: urlTypes }),
|
|
1129
|
+
]);
|
|
1130
|
+
}
|
|
1131
|
+
async #setAssociatedDomainsInIos(domains: string[]) {
|
|
1132
|
+
const entitlementsRelPath = "App/App.entitlements";
|
|
1133
|
+
const entitlementsPath = path.join(this.app.cwdPath, this.iosProjectPath, entitlementsRelPath);
|
|
1134
|
+
const values = domains.map((domain) => `applinks:${domain}`);
|
|
1135
|
+
const body = [
|
|
1136
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
1137
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
1138
|
+
'<plist version="1.0">',
|
|
1139
|
+
"<dict>",
|
|
1140
|
+
" <key>com.apple.developer.associated-domains</key>",
|
|
1141
|
+
" <array>",
|
|
1142
|
+
...values.map((value) => ` <string>${value}</string>`),
|
|
1143
|
+
" </array>",
|
|
1144
|
+
"</dict>",
|
|
1145
|
+
"</plist>",
|
|
1146
|
+
"",
|
|
1147
|
+
].join("\n");
|
|
1148
|
+
await writeFile(entitlementsPath, body);
|
|
1149
|
+
await this.#setCodeSignEntitlementsInIos(entitlementsRelPath);
|
|
1150
|
+
}
|
|
1151
|
+
async #setCodeSignEntitlementsInIos(entitlementsRelPath: string) {
|
|
1152
|
+
const pbxprojPath = path.join(this.app.cwdPath, this.iosProjectPath, "App.xcodeproj/project.pbxproj");
|
|
1153
|
+
const lines = (await readFile(pbxprojPath, "utf8")).split("\n");
|
|
1154
|
+
let changed = false;
|
|
1155
|
+
for (let index = 0; index < lines.length; index++) {
|
|
1156
|
+
const line = lines[index] ?? "";
|
|
1157
|
+
if (!line.includes(`PRODUCT_BUNDLE_IDENTIFIER = ${this.target.appId};`)) continue;
|
|
1158
|
+
let start = index;
|
|
1159
|
+
while (start >= 0 && !lines[start]?.includes("buildSettings = {")) start--;
|
|
1160
|
+
let end = index;
|
|
1161
|
+
while (end < lines.length && !/^\s*\};\s*$/.test(lines[end] ?? "")) end++;
|
|
1162
|
+
const settings = lines.slice(start, end + 1);
|
|
1163
|
+
if (settings.some((setting) => setting.includes("CODE_SIGN_ENTITLEMENTS"))) continue;
|
|
1164
|
+
const indent = line.match(/^\s*/)?.[0] ?? "";
|
|
1165
|
+
lines.splice(index, 0, `${indent}CODE_SIGN_ENTITLEMENTS = ${entitlementsRelPath};`);
|
|
1166
|
+
index++;
|
|
1167
|
+
changed = true;
|
|
1168
|
+
}
|
|
1169
|
+
if (changed) await writeFile(pbxprojPath, lines.join("\n"));
|
|
1170
|
+
}
|
|
1171
|
+
async #setUrlSchemesInAndroid(schemes: string[]) {
|
|
1172
|
+
const manifestPath = path.join(this.app.cwdPath, this.androidRootPath, "app/src/main/AndroidManifest.xml");
|
|
1173
|
+
let manifest = await readFile(manifestPath, "utf8");
|
|
1174
|
+
let changed = false;
|
|
1175
|
+
for (const scheme of schemes) {
|
|
1176
|
+
if (manifest.includes(`android:scheme="${scheme}"`)) continue;
|
|
1177
|
+
const filter = [
|
|
1178
|
+
" <intent-filter>",
|
|
1179
|
+
' <action android:name="android.intent.action.VIEW" />',
|
|
1180
|
+
' <category android:name="android.intent.category.DEFAULT" />',
|
|
1181
|
+
' <category android:name="android.intent.category.BROWSABLE" />',
|
|
1182
|
+
` <data android:scheme="${scheme}" />`,
|
|
1183
|
+
" </intent-filter>",
|
|
1184
|
+
].join("\n");
|
|
1185
|
+
manifest = manifest.replace(/(\s*<\/activity>)/, `\n${filter}$1`);
|
|
1186
|
+
changed = true;
|
|
1187
|
+
}
|
|
1188
|
+
if (changed) await writeFile(manifestPath, manifest);
|
|
1189
|
+
}
|
|
1190
|
+
async #setDeepLinksInAndroid(schemes: string[], domains: string[]) {
|
|
1191
|
+
await this.#setUrlSchemesInAndroid(schemes);
|
|
1192
|
+
const manifestPath = path.join(this.app.cwdPath, this.androidRootPath, "app/src/main/AndroidManifest.xml");
|
|
1193
|
+
let manifest = await readFile(manifestPath, "utf8");
|
|
1194
|
+
let changed = false;
|
|
1195
|
+
const pathPrefix = resolveMobilePath(this.target, "/");
|
|
1196
|
+
for (const domain of domains) {
|
|
1197
|
+
if (manifest.includes(`android:host="${domain}"`) && manifest.includes('android:scheme="https"')) continue;
|
|
1198
|
+
const filter = [
|
|
1199
|
+
' <intent-filter android:autoVerify="true">',
|
|
1200
|
+
' <action android:name="android.intent.action.VIEW" />',
|
|
1201
|
+
' <category android:name="android.intent.category.DEFAULT" />',
|
|
1202
|
+
' <category android:name="android.intent.category.BROWSABLE" />',
|
|
1203
|
+
` <data android:scheme="https" android:host="${domain}" android:pathPrefix="${pathPrefix}" />`,
|
|
1204
|
+
" </intent-filter>",
|
|
1205
|
+
].join("\n");
|
|
1206
|
+
manifest = manifest.replace(/(\s*<\/activity>)/, `\n${filter}$1`);
|
|
1207
|
+
changed = true;
|
|
1208
|
+
}
|
|
1209
|
+
if (changed) await writeFile(manifestPath, manifest);
|
|
1210
|
+
}
|
|
1093
1211
|
#setFeaturesInAndroid(features: string[]) {
|
|
1094
1212
|
for (const feature of features) {
|
|
1095
1213
|
if (this.#hasFeatureInAndroid(feature)) {
|
|
@@ -77,6 +77,15 @@ export class SsrBaseArtifactBuilder {
|
|
|
77
77
|
branches: [...akanConfig.branches],
|
|
78
78
|
i18n: akanConfig.i18n,
|
|
79
79
|
imageConfig: akanConfig.images,
|
|
80
|
+
deepLinkAssociations: Object.values(akanConfig.mobile.targets)
|
|
81
|
+
.filter((target) => (target.deepLinks?.domains?.length ?? 0) > 0)
|
|
82
|
+
.map((target) => ({
|
|
83
|
+
targetName: target.name,
|
|
84
|
+
appId: target.appId,
|
|
85
|
+
domains: target.deepLinks?.domains ?? [],
|
|
86
|
+
iosTeamId: target.deepLinks?.ios?.teamId,
|
|
87
|
+
androidSha256CertFingerprints: target.deepLinks?.android?.sha256CertFingerprints,
|
|
88
|
+
})),
|
|
80
89
|
};
|
|
81
90
|
await Bun.write(path.join(this.#absArtifactDir, "base-artifact.json"), `${JSON.stringify(artifact, null, 2)}\n`);
|
|
82
91
|
this.#app.verbose(`[base-artifact] complete in ${Date.now() - this.#started}ms`);
|
package/index.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.10-rc.0",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"@langchain/openai": "^1.4.6",
|
|
33
33
|
"@tailwindcss/node": "^4.3.0",
|
|
34
34
|
"@trapezedev/project": "^7.1.4",
|
|
35
|
-
"akanjs": "2.3.
|
|
35
|
+
"akanjs": "2.3.10-rc.0",
|
|
36
36
|
"chalk": "^5.6.2",
|
|
37
37
|
"commander": "^14.0.3",
|
|
38
38
|
"daisyui": "5.5.23",
|
|
@@ -0,0 +1,687 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import ignore from "ignore";
|
|
5
|
+
import ts from "typescript";
|
|
6
|
+
|
|
7
|
+
type QualitySeverity = "warning";
|
|
8
|
+
type QualityScope = "global" | "file" | "convention" | "layout";
|
|
9
|
+
|
|
10
|
+
export interface QualityWarning {
|
|
11
|
+
rule: string;
|
|
12
|
+
scope: QualityScope;
|
|
13
|
+
severity: QualitySeverity;
|
|
14
|
+
message: string;
|
|
15
|
+
file?: string;
|
|
16
|
+
line?: number;
|
|
17
|
+
locations?: Array<{ file: string; line: number }>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface QualityScanResult {
|
|
21
|
+
workspaceRoot: string;
|
|
22
|
+
scannedFiles: number;
|
|
23
|
+
warnings: QualityWarning[];
|
|
24
|
+
suggestedRules: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface SourceFileInfo {
|
|
28
|
+
file: string;
|
|
29
|
+
absolutePath: string;
|
|
30
|
+
content: string;
|
|
31
|
+
sourceFile: ts.SourceFile;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface ExportedFunctionLike {
|
|
35
|
+
name: string;
|
|
36
|
+
kind: "class" | "function" | "function-variable";
|
|
37
|
+
file: string;
|
|
38
|
+
line: number;
|
|
39
|
+
bodyFingerprint?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface TopLevelDeclaration {
|
|
43
|
+
name: string;
|
|
44
|
+
kind: string;
|
|
45
|
+
line: number;
|
|
46
|
+
exported: boolean;
|
|
47
|
+
node: ts.Statement;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const MAX_FILE_LINES = 2000;
|
|
51
|
+
const PLACEHOLDER_EXPORT_NAMES = new Set([
|
|
52
|
+
"aa",
|
|
53
|
+
"dumb",
|
|
54
|
+
"dumb2",
|
|
55
|
+
"someBaseLogic",
|
|
56
|
+
"someCommonLogic",
|
|
57
|
+
"someFrontendLogic",
|
|
58
|
+
"someBackendLogic",
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
const SUGGESTED_RULES = [
|
|
62
|
+
"Keep generated scanSync index files out of hand-written changes; generated indexes should only contain one-depth export statements.",
|
|
63
|
+
"Generated Akan index files should not contain placeholder exports such as aa, dumb, dumb2, or some*Logic.",
|
|
64
|
+
"Dictionary text should not contain scaffold wording, misspellings, or stale copied domain nouns.",
|
|
65
|
+
"Warn earlier on very long Akan files: 500 lines for services, 800 lines for Template/Zone files, and 1000 lines for Util files.",
|
|
66
|
+
"Global declarations, Window augmentation, and prototype mutation should stay in explicitly approved low-level integration files.",
|
|
67
|
+
"Keep app root folders small and conventional: application code belongs under common, env, lib, page, private, public, script, srvkit, ui, or webkit.",
|
|
68
|
+
"Keep apps/*/lib and libs/*/lib root files limited to generated support facets such as cnst.ts, db.ts, dict.ts, sig.ts, srv.ts, st.ts, useClient.ts, and useServer.ts.",
|
|
69
|
+
"Use domain module folders consistently: lib/<model> for database modules, lib/_<service> for service modules, and lib/__scalar/<scalar> for scalar modules.",
|
|
70
|
+
"Keep module UI filenames predictable: database modules use <Model>.Template/Unit/Util/View/Zone.tsx, service modules use <Service>.Util/Zone.tsx, and scalar modules use <Scalar>.Template/Unit.tsx.",
|
|
71
|
+
"Move shared app utilities to apps/*/common instead of creating apps/*/base.",
|
|
72
|
+
"Avoid large mixed-purpose class files; class export files should import helpers from neighboring utility files instead of declaring them inline.",
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
const APP_ROOT_FILES = new Set([
|
|
76
|
+
"akan.app.json",
|
|
77
|
+
"akan.config.ts",
|
|
78
|
+
"capacitor.config.ts",
|
|
79
|
+
"client.ts",
|
|
80
|
+
"main.ts",
|
|
81
|
+
"package.json",
|
|
82
|
+
"server.ts",
|
|
83
|
+
"tsconfig.json",
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
const LIB_ROOT_FILES = new Set([
|
|
87
|
+
"cnst.ts",
|
|
88
|
+
"db.ts",
|
|
89
|
+
"dict.ts",
|
|
90
|
+
"option.ts",
|
|
91
|
+
"sig.ts",
|
|
92
|
+
"srv.ts",
|
|
93
|
+
"st.ts",
|
|
94
|
+
"useClient.ts",
|
|
95
|
+
"useServer.ts",
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
const CONVENTION_SUFFIXES = [
|
|
99
|
+
".constant.ts",
|
|
100
|
+
".dictionary.ts",
|
|
101
|
+
".document.ts",
|
|
102
|
+
".service.ts",
|
|
103
|
+
".signal.ts",
|
|
104
|
+
".store.ts",
|
|
105
|
+
] as const;
|
|
106
|
+
|
|
107
|
+
export class AkanQualityScanner {
|
|
108
|
+
async scan(workspaceRoot: string): Promise<QualityScanResult> {
|
|
109
|
+
const targetFiles = await this.#collectTargetFiles(workspaceRoot);
|
|
110
|
+
const sourceFiles = await Promise.all(targetFiles.map((file) => this.#readSourceFile(workspaceRoot, file)));
|
|
111
|
+
const warnings = [
|
|
112
|
+
...this.#scanGlobalQuality(sourceFiles),
|
|
113
|
+
...sourceFiles.flatMap((sourceFile) => this.#scanSingleFileQuality(sourceFile)),
|
|
114
|
+
...sourceFiles.flatMap((sourceFile) => this.#scanConventionQuality(sourceFile)),
|
|
115
|
+
...sourceFiles.flatMap((sourceFile) => this.#scanLayoutQuality(sourceFile)),
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
workspaceRoot,
|
|
120
|
+
scannedFiles: sourceFiles.length,
|
|
121
|
+
warnings: warnings.sort(compareWarnings),
|
|
122
|
+
suggestedRules: SUGGESTED_RULES,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async #collectTargetFiles(workspaceRoot: string) {
|
|
127
|
+
const ignoreFilter = ignore().add(await this.#readGitIgnore(workspaceRoot));
|
|
128
|
+
const files: string[] = [];
|
|
129
|
+
for (const targetRoot of ["apps", "libs"]) {
|
|
130
|
+
const absoluteTargetRoot = path.join(workspaceRoot, targetRoot);
|
|
131
|
+
if (!(await isDirectory(absoluteTargetRoot))) continue;
|
|
132
|
+
await this.#walkTargetFiles(workspaceRoot, absoluteTargetRoot, ignoreFilter, files);
|
|
133
|
+
}
|
|
134
|
+
return files.sort();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async #readGitIgnore(workspaceRoot: string) {
|
|
138
|
+
const gitIgnorePath = path.join(workspaceRoot, ".gitignore");
|
|
139
|
+
if (!(await Bun.file(gitIgnorePath).exists())) return [];
|
|
140
|
+
return (await readFile(gitIgnorePath, "utf8")).split(/\r?\n/);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async #walkTargetFiles(
|
|
144
|
+
workspaceRoot: string,
|
|
145
|
+
currentPath: string,
|
|
146
|
+
ignoreFilter: ReturnType<typeof ignore>,
|
|
147
|
+
files: string[],
|
|
148
|
+
) {
|
|
149
|
+
const entries = await readdir(currentPath, { withFileTypes: true });
|
|
150
|
+
for (const entry of entries) {
|
|
151
|
+
const absolutePath = path.join(currentPath, entry.name);
|
|
152
|
+
const relativePath = toPosix(path.relative(workspaceRoot, absolutePath));
|
|
153
|
+
if (shouldSkipPath(relativePath, entry.isDirectory(), ignoreFilter)) continue;
|
|
154
|
+
|
|
155
|
+
if (entry.isDirectory()) {
|
|
156
|
+
await this.#walkTargetFiles(workspaceRoot, absolutePath, ignoreFilter, files);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if ((relativePath.endsWith(".ts") || relativePath.endsWith(".tsx")) && !relativePath.endsWith(".d.ts")) {
|
|
160
|
+
files.push(relativePath);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async #readSourceFile(workspaceRoot: string, file: string): Promise<SourceFileInfo> {
|
|
166
|
+
const absolutePath = path.join(workspaceRoot, file);
|
|
167
|
+
const content = await readFile(absolutePath, "utf8");
|
|
168
|
+
return {
|
|
169
|
+
file,
|
|
170
|
+
absolutePath,
|
|
171
|
+
content,
|
|
172
|
+
sourceFile: ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, getScriptKind(file)),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
#scanGlobalQuality(sourceFiles: SourceFileInfo[]): QualityWarning[] {
|
|
177
|
+
const exportedFunctionLikes = sourceFiles.flatMap((sourceFile) => getExportedFunctionLikes(sourceFile));
|
|
178
|
+
const warnings: QualityWarning[] = [];
|
|
179
|
+
|
|
180
|
+
for (const [name, declarations] of groupBy(exportedFunctionLikes, (declaration) => declaration.name)) {
|
|
181
|
+
if (declarations.length < 2) continue;
|
|
182
|
+
warnings.push({
|
|
183
|
+
rule: "akan.global.duplicate-exported-function-name",
|
|
184
|
+
scope: "global",
|
|
185
|
+
severity: "warning",
|
|
186
|
+
message: `Exported function or class name "${name}" is declared in ${declarations.length} files.`,
|
|
187
|
+
locations: declarations.map(({ file, line }) => ({ file, line })),
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const declarationsWithBody = exportedFunctionLikes.filter((declaration) => declaration.bodyFingerprint);
|
|
192
|
+
for (const [fingerprint, declarations] of groupBy(
|
|
193
|
+
declarationsWithBody,
|
|
194
|
+
(declaration) => declaration.bodyFingerprint ?? "",
|
|
195
|
+
)) {
|
|
196
|
+
const uniqueNames = new Set(declarations.map((declaration) => declaration.name));
|
|
197
|
+
if (declarations.length < 2 || uniqueNames.size < 2 || fingerprint === "") continue;
|
|
198
|
+
warnings.push({
|
|
199
|
+
rule: "akan.global.duplicate-exported-function-body",
|
|
200
|
+
scope: "global",
|
|
201
|
+
severity: "warning",
|
|
202
|
+
message: `Exported functions/classes share the same implementation body: ${declarations
|
|
203
|
+
.map((declaration) => declaration.name)
|
|
204
|
+
.join(", ")}.`,
|
|
205
|
+
locations: declarations.map(({ file, line }) => ({ file, line })),
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return warnings;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
#scanSingleFileQuality(sourceFile: SourceFileInfo): QualityWarning[] {
|
|
213
|
+
const warnings: QualityWarning[] = [];
|
|
214
|
+
const lineCount = sourceFile.content.split(/\r?\n/).length;
|
|
215
|
+
const recommendedLineLimit = getRecommendedLineLimit(sourceFile.file);
|
|
216
|
+
if (recommendedLineLimit && lineCount > recommendedLineLimit) {
|
|
217
|
+
warnings.push({
|
|
218
|
+
rule: "akan.file.recommended-max-lines",
|
|
219
|
+
scope: "file",
|
|
220
|
+
severity: "warning",
|
|
221
|
+
file: sourceFile.file,
|
|
222
|
+
message: `File has ${lineCount} lines. Recommended limit for this file type is ${recommendedLineLimit} lines.`,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
if (lineCount > MAX_FILE_LINES) {
|
|
226
|
+
warnings.push({
|
|
227
|
+
rule: "akan.file.max-lines",
|
|
228
|
+
scope: "file",
|
|
229
|
+
severity: "warning",
|
|
230
|
+
file: sourceFile.file,
|
|
231
|
+
message: `File has ${lineCount} lines. Keep single files under ${MAX_FILE_LINES} lines.`,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
warnings.push(...getPlaceholderExportWarnings(sourceFile));
|
|
236
|
+
warnings.push(...getDictionaryTextWarnings(sourceFile));
|
|
237
|
+
warnings.push(...getGlobalMutationWarnings(sourceFile));
|
|
238
|
+
|
|
239
|
+
const exportedClassNames = getExportedClassNames(sourceFile.sourceFile);
|
|
240
|
+
if (exportedClassNames.length === 0) return warnings;
|
|
241
|
+
const allowedInterfaceNames = new Set(exportedClassNames.map((name) => `${name}Options`));
|
|
242
|
+
for (const declaration of getTopLevelDeclarations(sourceFile)) {
|
|
243
|
+
if (declaration.kind === "class" && exportedClassNames.includes(declaration.name)) continue;
|
|
244
|
+
if (declaration.kind === "interface" && allowedInterfaceNames.has(declaration.name)) continue;
|
|
245
|
+
warnings.push({
|
|
246
|
+
rule: "akan.file.class-export-global-declaration",
|
|
247
|
+
scope: "file",
|
|
248
|
+
severity: "warning",
|
|
249
|
+
file: sourceFile.file,
|
|
250
|
+
line: declaration.line,
|
|
251
|
+
message: `Class export files should not declare top-level ${declaration.kind} "${declaration.name}". Move helpers to another file and import them.`,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
return warnings;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
#scanConventionQuality(sourceFile: SourceFileInfo): QualityWarning[] {
|
|
258
|
+
const suffix = CONVENTION_SUFFIXES.find((candidate) => sourceFile.file.endsWith(candidate));
|
|
259
|
+
if (!suffix) return [];
|
|
260
|
+
|
|
261
|
+
const modelName = toPascalCase(path.basename(sourceFile.file, suffix));
|
|
262
|
+
const warnings: QualityWarning[] = [];
|
|
263
|
+
for (const declaration of getTopLevelDeclarations(sourceFile)) {
|
|
264
|
+
if (isAllowedConventionDeclaration(suffix, modelName, declaration)) continue;
|
|
265
|
+
warnings.push({
|
|
266
|
+
rule: `akan.convention${suffix.replace(".ts", "")}`,
|
|
267
|
+
scope: "convention",
|
|
268
|
+
severity: "warning",
|
|
269
|
+
file: sourceFile.file,
|
|
270
|
+
line: declaration.line,
|
|
271
|
+
message: `${path.basename(sourceFile.file)} should not declare top-level ${declaration.kind} "${declaration.name}". Allowed declarations: ${getConventionDescription(
|
|
272
|
+
suffix,
|
|
273
|
+
modelName,
|
|
274
|
+
)}.`,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
return warnings;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
#scanLayoutQuality(sourceFile: SourceFileInfo): QualityWarning[] {
|
|
281
|
+
const segments = sourceFile.file.split("/");
|
|
282
|
+
const warnings: QualityWarning[] = [];
|
|
283
|
+
if (segments[0] === "apps" && segments.length === 3 && !APP_ROOT_FILES.has(segments[2])) {
|
|
284
|
+
warnings.push({
|
|
285
|
+
rule: "akan.layout.app-root-file",
|
|
286
|
+
scope: "layout",
|
|
287
|
+
severity: "warning",
|
|
288
|
+
file: sourceFile.file,
|
|
289
|
+
message: `Unexpected app root file "${segments[2]}". Keep application code in conventional app folders.`,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const libRootFile = getLibRootFile(sourceFile.file);
|
|
294
|
+
if (libRootFile && !LIB_ROOT_FILES.has(libRootFile)) {
|
|
295
|
+
warnings.push({
|
|
296
|
+
rule: "akan.layout.lib-root-file",
|
|
297
|
+
scope: "layout",
|
|
298
|
+
severity: "warning",
|
|
299
|
+
file: sourceFile.file,
|
|
300
|
+
message: `Unexpected lib root file "${libRootFile}". Keep direct lib root files limited to generated support facets.`,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const moduleUiWarning = getModuleUiWarning(sourceFile.file);
|
|
305
|
+
if (moduleUiWarning) warnings.push(moduleUiWarning);
|
|
306
|
+
return warnings;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function formatQualityScanResult(result: QualityScanResult) {
|
|
311
|
+
const sections = [
|
|
312
|
+
"Akan Code Quality Scan",
|
|
313
|
+
`workspace: ${result.workspaceRoot}`,
|
|
314
|
+
`scanned files: ${result.scannedFiles}`,
|
|
315
|
+
`warnings: ${result.warnings.length}`,
|
|
316
|
+
"",
|
|
317
|
+
"Warnings:",
|
|
318
|
+
"",
|
|
319
|
+
...formatQualityWarnings(result.warnings),
|
|
320
|
+
"",
|
|
321
|
+
"Suggested quality rules:",
|
|
322
|
+
"",
|
|
323
|
+
...result.suggestedRules.map((rule) => ` - ${rule}`),
|
|
324
|
+
];
|
|
325
|
+
return sections.join("\n");
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function formatQualityWarnings(warnings: QualityWarning[]) {
|
|
329
|
+
if (warnings.length === 0) return ["No warnings found."];
|
|
330
|
+
return warnings.flatMap((warning) => {
|
|
331
|
+
const location = formatQualityLocation(warning.file, warning.line);
|
|
332
|
+
const lines = [`${location} - warning ${warning.rule}: ${warning.message}`];
|
|
333
|
+
if (warning.locations?.length) {
|
|
334
|
+
lines.push(
|
|
335
|
+
...warning.locations.map(({ file, line }) => ` note: related location ${formatQualityLocation(file, line)}`),
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
return lines;
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function formatQualityLocation(file: string | undefined, line: number | undefined) {
|
|
343
|
+
return `${file ?? "<global>"}:${line ?? 1}:1`;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function getExportedFunctionLikes(sourceFile: SourceFileInfo): ExportedFunctionLike[] {
|
|
347
|
+
const declarations: ExportedFunctionLike[] = [];
|
|
348
|
+
for (const statement of sourceFile.sourceFile.statements) {
|
|
349
|
+
if (ts.isFunctionDeclaration(statement) && statement.name && isExported(statement)) {
|
|
350
|
+
declarations.push({
|
|
351
|
+
name: statement.name.text,
|
|
352
|
+
kind: "function",
|
|
353
|
+
file: sourceFile.file,
|
|
354
|
+
line: getLine(sourceFile.sourceFile, statement),
|
|
355
|
+
bodyFingerprint: getBodyFingerprint(sourceFile.sourceFile, statement.body),
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
if (ts.isClassDeclaration(statement) && statement.name && isExported(statement)) {
|
|
359
|
+
declarations.push({
|
|
360
|
+
name: statement.name.text,
|
|
361
|
+
kind: "class",
|
|
362
|
+
file: sourceFile.file,
|
|
363
|
+
line: getLine(sourceFile.sourceFile, statement),
|
|
364
|
+
bodyFingerprint: getBodyFingerprint(sourceFile.sourceFile, statement),
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
if (ts.isVariableStatement(statement) && isExported(statement)) {
|
|
368
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
369
|
+
if (!ts.isIdentifier(declaration.name) || !isFunctionLikeInitializer(declaration.initializer)) continue;
|
|
370
|
+
declarations.push({
|
|
371
|
+
name: declaration.name.text,
|
|
372
|
+
kind: "function-variable",
|
|
373
|
+
file: sourceFile.file,
|
|
374
|
+
line: getLine(sourceFile.sourceFile, declaration),
|
|
375
|
+
bodyFingerprint: getBodyFingerprint(sourceFile.sourceFile, declaration.initializer),
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return declarations;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function getExportedClassNames(sourceFile: ts.SourceFile) {
|
|
384
|
+
return sourceFile.statements
|
|
385
|
+
.filter((statement): statement is ts.ClassDeclaration => ts.isClassDeclaration(statement) && !!statement.name)
|
|
386
|
+
.filter((statement) => isExported(statement))
|
|
387
|
+
.map((statement) => statement.name!.text);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function getPlaceholderExportWarnings(sourceFile: SourceFileInfo): QualityWarning[] {
|
|
391
|
+
if (!sourceFile.file.endsWith("/index.ts") && !sourceFile.file.endsWith("/index.tsx")) return [];
|
|
392
|
+
return getTopLevelDeclarations(sourceFile)
|
|
393
|
+
.filter((declaration) => declaration.exported && PLACEHOLDER_EXPORT_NAMES.has(declaration.name))
|
|
394
|
+
.map((declaration) => ({
|
|
395
|
+
rule: "akan.file.placeholder-export",
|
|
396
|
+
scope: "file",
|
|
397
|
+
severity: "warning",
|
|
398
|
+
file: sourceFile.file,
|
|
399
|
+
line: declaration.line,
|
|
400
|
+
message: `Generated or barrel index file should not export placeholder "${declaration.name}".`,
|
|
401
|
+
}));
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function getDictionaryTextWarnings(sourceFile: SourceFileInfo): QualityWarning[] {
|
|
405
|
+
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
|
+
];
|
|
411
|
+
return stalePatterns.flatMap(({ pattern, label }) =>
|
|
412
|
+
findPatternLines(sourceFile.content, pattern).map((line) => ({
|
|
413
|
+
rule: "akan.file.dictionary-stale-text",
|
|
414
|
+
scope: "file",
|
|
415
|
+
severity: "warning",
|
|
416
|
+
file: sourceFile.file,
|
|
417
|
+
line,
|
|
418
|
+
message: `Dictionary text appears to contain ${label}.`,
|
|
419
|
+
})),
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function getGlobalMutationWarnings(sourceFile: SourceFileInfo): QualityWarning[] {
|
|
424
|
+
const warnings: QualityWarning[] = [];
|
|
425
|
+
for (const statement of sourceFile.sourceFile.statements) {
|
|
426
|
+
if (ts.isModuleDeclaration(statement) && statement.name.getText(sourceFile.sourceFile) === "global") {
|
|
427
|
+
warnings.push({
|
|
428
|
+
rule: "akan.file.global-declaration",
|
|
429
|
+
scope: "file",
|
|
430
|
+
severity: "warning",
|
|
431
|
+
file: sourceFile.file,
|
|
432
|
+
line: getLine(sourceFile.sourceFile, statement),
|
|
433
|
+
message: "Global declarations require an explicit low-level integration allowlist.",
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
if (ts.isInterfaceDeclaration(statement) && statement.name.text === "Window") {
|
|
437
|
+
warnings.push({
|
|
438
|
+
rule: "akan.file.window-augmentation",
|
|
439
|
+
scope: "file",
|
|
440
|
+
severity: "warning",
|
|
441
|
+
file: sourceFile.file,
|
|
442
|
+
line: getLine(sourceFile.sourceFile, statement),
|
|
443
|
+
message: "Window augmentation should be isolated to approved browser integration files.",
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
if (
|
|
447
|
+
ts.isExpressionStatement(statement) &&
|
|
448
|
+
statement.expression.getText(sourceFile.sourceFile).includes(".prototype.")
|
|
449
|
+
) {
|
|
450
|
+
warnings.push({
|
|
451
|
+
rule: "akan.file.prototype-mutation",
|
|
452
|
+
scope: "file",
|
|
453
|
+
severity: "warning",
|
|
454
|
+
file: sourceFile.file,
|
|
455
|
+
line: getLine(sourceFile.sourceFile, statement),
|
|
456
|
+
message: "Prototype mutation should be avoided or isolated to approved low-level integration files.",
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return warnings;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function getTopLevelDeclarations(sourceFile: SourceFileInfo): TopLevelDeclaration[] {
|
|
464
|
+
return sourceFile.sourceFile.statements.flatMap((statement) =>
|
|
465
|
+
getTopLevelDeclaration(sourceFile.sourceFile, statement),
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function getTopLevelDeclaration(sourceFile: ts.SourceFile, statement: ts.Statement): TopLevelDeclaration[] {
|
|
470
|
+
const line = getLine(sourceFile, statement);
|
|
471
|
+
if (ts.isClassDeclaration(statement) && statement.name) {
|
|
472
|
+
return [{ name: statement.name.text, kind: "class", line, exported: isExported(statement), node: statement }];
|
|
473
|
+
}
|
|
474
|
+
if (ts.isFunctionDeclaration(statement) && statement.name) {
|
|
475
|
+
return [{ name: statement.name.text, kind: "function", line, exported: isExported(statement), node: statement }];
|
|
476
|
+
}
|
|
477
|
+
if (ts.isInterfaceDeclaration(statement)) {
|
|
478
|
+
return [{ name: statement.name.text, kind: "interface", line, exported: isExported(statement), node: statement }];
|
|
479
|
+
}
|
|
480
|
+
if (ts.isTypeAliasDeclaration(statement)) {
|
|
481
|
+
return [{ name: statement.name.text, kind: "type", line, exported: isExported(statement), node: statement }];
|
|
482
|
+
}
|
|
483
|
+
if (ts.isEnumDeclaration(statement)) {
|
|
484
|
+
return [{ name: statement.name.text, kind: "enum", line, exported: isExported(statement), node: statement }];
|
|
485
|
+
}
|
|
486
|
+
if (ts.isVariableStatement(statement)) {
|
|
487
|
+
return statement.declarationList.declarations
|
|
488
|
+
.filter((declaration) => ts.isIdentifier(declaration.name))
|
|
489
|
+
.map((declaration) => ({
|
|
490
|
+
name: (declaration.name as ts.Identifier).text,
|
|
491
|
+
kind: "variable",
|
|
492
|
+
line: getLine(sourceFile, declaration),
|
|
493
|
+
exported: isExported(statement),
|
|
494
|
+
node: statement,
|
|
495
|
+
}));
|
|
496
|
+
}
|
|
497
|
+
if (ts.isExportDeclaration(statement)) {
|
|
498
|
+
return [{ name: "export declaration", kind: "export", line, exported: true, node: statement }];
|
|
499
|
+
}
|
|
500
|
+
return [];
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function isAllowedConventionDeclaration(
|
|
504
|
+
suffix: (typeof CONVENTION_SUFFIXES)[number],
|
|
505
|
+
modelName: string,
|
|
506
|
+
declaration: TopLevelDeclaration,
|
|
507
|
+
) {
|
|
508
|
+
if (suffix === ".dictionary.ts") return isExportedConst(declaration) && declaration.name === "dictionary";
|
|
509
|
+
if (suffix === ".constant.ts") return isAllowedConstantDeclaration(modelName, declaration);
|
|
510
|
+
if (suffix === ".document.ts")
|
|
511
|
+
return (
|
|
512
|
+
declaration.kind === "class" && [`${modelName}Filter`, modelName, `${modelName}Model`].includes(declaration.name)
|
|
513
|
+
);
|
|
514
|
+
if (suffix === ".service.ts") return declaration.kind === "class" && declaration.name === `${modelName}Service`;
|
|
515
|
+
if (suffix === ".signal.ts")
|
|
516
|
+
return (
|
|
517
|
+
declaration.kind === "class" &&
|
|
518
|
+
[`${modelName}Internal`, `${modelName}Slice`, `${modelName}Endpoint`].includes(declaration.name)
|
|
519
|
+
);
|
|
520
|
+
if (suffix === ".store.ts") return declaration.kind === "class" && declaration.name === `${modelName}Store`;
|
|
521
|
+
return false;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function isAllowedConstantDeclaration(modelName: string, declaration: TopLevelDeclaration) {
|
|
525
|
+
if (declaration.kind !== "class") return false;
|
|
526
|
+
if (
|
|
527
|
+
[`${modelName}Input`, `${modelName}Object`, modelName, `Light${modelName}`, `${modelName}Insight`].includes(
|
|
528
|
+
declaration.name,
|
|
529
|
+
)
|
|
530
|
+
)
|
|
531
|
+
return true;
|
|
532
|
+
if (!ts.isClassDeclaration(declaration.node)) return false;
|
|
533
|
+
const heritageClause = declaration.node.heritageClauses?.find(
|
|
534
|
+
(clause) => clause.token === ts.SyntaxKind.ExtendsKeyword,
|
|
535
|
+
);
|
|
536
|
+
const expression = heritageClause?.types[0]?.expression;
|
|
537
|
+
return !!expression && expression.getText().startsWith("enumOf(");
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function getConventionDescription(suffix: (typeof CONVENTION_SUFFIXES)[number], modelName: string) {
|
|
541
|
+
if (suffix === ".dictionary.ts") return "export const dictionary";
|
|
542
|
+
if (suffix === ".constant.ts")
|
|
543
|
+
return `${modelName}Input, ${modelName}Object, ${modelName}, Light${modelName}, ${modelName}Insight, or enumOf classes`;
|
|
544
|
+
if (suffix === ".document.ts") return `${modelName}Filter, ${modelName}, or ${modelName}Model`;
|
|
545
|
+
if (suffix === ".service.ts") return `${modelName}Service`;
|
|
546
|
+
if (suffix === ".signal.ts") return `${modelName}Internal, ${modelName}Slice, or ${modelName}Endpoint`;
|
|
547
|
+
return `${modelName}Store`;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function getLibRootFile(file: string) {
|
|
551
|
+
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];
|
|
554
|
+
return null;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function getModuleUiWarning(file: string): QualityWarning | null {
|
|
558
|
+
if (!file.endsWith(".tsx")) return null;
|
|
559
|
+
const moduleInfo = getModuleInfo(file);
|
|
560
|
+
if (!moduleInfo) return null;
|
|
561
|
+
const { moduleName, fileName, kind } = moduleInfo;
|
|
562
|
+
const pascalName = toPascalCase(moduleName.replace(/^_+/, ""));
|
|
563
|
+
const allowedSuffixes =
|
|
564
|
+
kind === "database"
|
|
565
|
+
? ["Template", "Unit", "Util", "View", "Zone"]
|
|
566
|
+
: kind === "service"
|
|
567
|
+
? ["Util", "Zone"]
|
|
568
|
+
: ["Template", "Unit"];
|
|
569
|
+
const allowedFileNames = new Set(allowedSuffixes.map((suffix) => `${pascalName}.${suffix}.tsx`));
|
|
570
|
+
if (allowedFileNames.has(fileName) || fileName.endsWith(".test.tsx") || fileName.endsWith(".spec.tsx")) return null;
|
|
571
|
+
return {
|
|
572
|
+
rule: "akan.layout.module-ui-file",
|
|
573
|
+
scope: "layout",
|
|
574
|
+
severity: "warning",
|
|
575
|
+
file,
|
|
576
|
+
message: `Unexpected ${kind} module UI filename "${fileName}". Expected one of: ${[...allowedFileNames].join(", ")}.`,
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function getModuleInfo(file: string) {
|
|
581
|
+
const segments = file.split("/");
|
|
582
|
+
const libIndex = segments.indexOf("lib");
|
|
583
|
+
if (libIndex < 0) return null;
|
|
584
|
+
const moduleName = segments[libIndex + 1];
|
|
585
|
+
if (moduleName === "__scalar") {
|
|
586
|
+
if (segments.length !== libIndex + 4) return null;
|
|
587
|
+
return { moduleName: segments[libIndex + 2], fileName: segments[libIndex + 3], kind: "scalar" as const };
|
|
588
|
+
}
|
|
589
|
+
if (segments.length !== libIndex + 3) return null;
|
|
590
|
+
const fileName = segments[libIndex + 2];
|
|
591
|
+
if (moduleName.startsWith("__")) {
|
|
592
|
+
const scalarName = moduleName === "__scalar" ? segments[libIndex + 2] : moduleName;
|
|
593
|
+
return { moduleName: scalarName, fileName, kind: "scalar" as const };
|
|
594
|
+
}
|
|
595
|
+
if (moduleName.startsWith("_")) return { moduleName, fileName, kind: "service" as const };
|
|
596
|
+
return { moduleName, fileName, kind: "database" as const };
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function isExportedConst(declaration: TopLevelDeclaration) {
|
|
600
|
+
return (
|
|
601
|
+
declaration.exported &&
|
|
602
|
+
ts.isVariableStatement(declaration.node) &&
|
|
603
|
+
(declaration.node.declarationList.flags & ts.NodeFlags.Const) !== 0
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function isExported(node: ts.Node) {
|
|
608
|
+
return (
|
|
609
|
+
!!ts.getCombinedModifierFlags(node as ts.Declaration) &&
|
|
610
|
+
!!(ts.getCombinedModifierFlags(node as ts.Declaration) & ts.ModifierFlags.Export)
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function isFunctionLikeInitializer(node: ts.Expression | undefined) {
|
|
615
|
+
return !!node && (ts.isArrowFunction(node) || ts.isFunctionExpression(node));
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function getBodyFingerprint(sourceFile: ts.SourceFile, node: ts.Node | undefined) {
|
|
619
|
+
if (!node) return undefined;
|
|
620
|
+
const normalizedBody = node.getText(sourceFile).replace(/\s+/g, " ").trim();
|
|
621
|
+
if (normalizedBody.length < 80) return undefined;
|
|
622
|
+
return createHash("sha256").update(normalizedBody).digest("hex");
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function shouldSkipPath(relativePath: string, isDirectory: boolean, ignoreFilter: ReturnType<typeof ignore>) {
|
|
626
|
+
const ignorePath = isDirectory ? `${relativePath}/` : relativePath;
|
|
627
|
+
return (
|
|
628
|
+
relativePath === ".git" ||
|
|
629
|
+
relativePath.includes("/.git/") ||
|
|
630
|
+
relativePath.includes("/node_modules/") ||
|
|
631
|
+
ignoreFilter.ignores(relativePath) ||
|
|
632
|
+
ignoreFilter.ignores(ignorePath)
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
async function isDirectory(absolutePath: string) {
|
|
637
|
+
try {
|
|
638
|
+
return (await stat(absolutePath)).isDirectory();
|
|
639
|
+
} catch {
|
|
640
|
+
return false;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function getScriptKind(file: string) {
|
|
645
|
+
return file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function getLine(sourceFile: ts.SourceFile, node: ts.Node) {
|
|
649
|
+
return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function getRecommendedLineLimit(file: string) {
|
|
653
|
+
if (file.endsWith(".service.ts")) return 500;
|
|
654
|
+
if (file.endsWith(".Template.tsx") || file.endsWith(".Zone.tsx")) return 800;
|
|
655
|
+
if (file.endsWith(".Util.tsx")) return 1000;
|
|
656
|
+
return null;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function findPatternLines(content: string, pattern: RegExp) {
|
|
660
|
+
return content.split(/\r?\n/).flatMap((line, index) => (pattern.test(line) ? [index + 1] : []));
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function toPascalCase(value: string) {
|
|
664
|
+
return value.replace(/(^|[-_./])([a-zA-Z0-9])/g, (_, __, char: string) => char.toUpperCase()).replace(/[-_./]/g, "");
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function toPosix(value: string) {
|
|
668
|
+
return value.split(path.sep).join("/");
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function groupBy<T>(items: T[], getKey: (item: T) => string) {
|
|
672
|
+
const grouped = new Map<string, T[]>();
|
|
673
|
+
for (const item of items) {
|
|
674
|
+
const key = getKey(item);
|
|
675
|
+
grouped.set(key, [...(grouped.get(key) ?? []), item]);
|
|
676
|
+
}
|
|
677
|
+
return grouped;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function compareWarnings(a: QualityWarning, b: QualityWarning) {
|
|
681
|
+
return (
|
|
682
|
+
a.scope.localeCompare(b.scope) ||
|
|
683
|
+
(a.file ?? "").localeCompare(b.file ?? "") ||
|
|
684
|
+
(a.line ?? 0) - (b.line ?? 0) ||
|
|
685
|
+
a.rule.localeCompare(b.rule)
|
|
686
|
+
);
|
|
687
|
+
}
|