@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 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
- links: { schemes: ["portal-admin"] },
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
- links: { schemes: ["portal-admin"] },
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(
@@ -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 { targets: rawTargets, ...rawMobile } = mobile ?? {};
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
- await this.#app.workspace.spawn("tar", [
65
- "-zcf",
66
- `${this.#app.workspace.workspaceRoot}/releases/builds/${this.#app.name}-release.tar.gz`,
67
- "-C",
68
- buildRoot,
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
- await this.#app.workspace.spawn("tar", [
113
- "-zcf",
114
- `${this.#app.workspace.cwdPath}/releases/sources/${this.#app.name}-source.tar.gz`,
115
- "-C",
116
- sourceRoot,
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> {
@@ -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
- links: { schemes: ["minimal"] },
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("links");
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
- links: _links,
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.#applyLinks();
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.#applyLinks();
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({ name: this.target.name, basePath })};</script>`;
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 #applyLinks() {
981
- const links = this.target.links;
982
- if (!links) return;
983
- const schemes = links.schemes ?? [];
984
- if (schemes.length > 0) {
985
- await this.#setPermissionInIos({
986
- appTransportSecurity: "",
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
- for (const domain of links.associatedDomains ?? []) {
998
- this.app.logger.info(`Configure iOS associated domain manually if needed: ${domain}`);
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
- for (const host of links.androidHosts ?? []) {
1001
- const pathPrefix = resolveMobilePath(this.target, "/");
1002
- this.project.android
1003
- .getAndroidManifest()
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
@@ -27,6 +27,7 @@ export * from "./guideline";
27
27
  export * from "./incrementalBuilder";
28
28
  export * from "./mobile";
29
29
  export * from "./prompter";
30
+ export * from "./qualityScanner";
30
31
  export * from "./scanInfo";
31
32
  export * from "./selectModel";
32
33
  export * from "./spinner";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "2.3.9-rc.9",
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.9-rc.9",
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
+ }