@akanjs/devkit 3.0.0-alpha.3 → 3.0.0-alpha.4

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.
@@ -11,6 +11,7 @@ import {
11
11
  clearRootCapacitorConfigs,
12
12
  formatAndroidReleaseSigningError,
13
13
  getAdbDeviceStateIssues,
14
+ getAndroidLocalServerHost,
14
15
  getMissingAndroidReleaseSigningKeys,
15
16
  isPlaceholderAppId,
16
17
  materializeCapacitorConfig,
@@ -339,6 +340,13 @@ describe("Android signing diagnostics", () => {
339
340
  "Android device abc123 is unauthorized. Confirm USB debugging authorization on the device.",
340
341
  "Android device xyz is offline. Reconnect the device or restart adb.",
341
342
  ]);
343
+ expect(getAndroidLocalServerHost("List of devices attached\nemulator-5554 device\n", "192.168.0.5")).toBe(
344
+ "10.0.2.2",
345
+ );
346
+ expect(getAndroidLocalServerHost("List of devices attached\nabc123 device\n", "192.168.0.5")).toBe("192.168.0.5");
347
+ expect(
348
+ getAndroidLocalServerHost("List of devices attached\nemulator-5554 device\nabc123 device\n", "192.168.0.5"),
349
+ ).toBe("192.168.0.5");
342
350
  });
343
351
  });
344
352
 
package/capacitorApp.ts CHANGED
@@ -24,7 +24,9 @@ interface RunIosConfig extends RunConfig {
24
24
  iosDeviceId?: string;
25
25
  }
26
26
 
27
- interface PrepareConfig extends RunConfig {}
27
+ interface PrepareConfig extends RunConfig {
28
+ iosRunTargetKind?: IosRunTargetKind;
29
+ }
28
30
 
29
31
  type MobileCommandEnv = Record<string, string | undefined>;
30
32
 
@@ -119,10 +121,11 @@ interface MaterializeCapacitorConfigOptions {
119
121
  localServerUrl?: string;
120
122
  localIp?: string;
121
123
  }
124
+ type MobilePlatform = "ios" | "android";
122
125
 
123
126
  export interface LocalDevHostResolution {
124
127
  host: string;
125
- source: "override" | "detected" | "loopback";
128
+ source: "override" | "detected" | "loopback" | "platform";
126
129
  candidates: { name: string; address: string }[];
127
130
  }
128
131
 
@@ -565,6 +568,16 @@ export function getAdbDeviceStateIssues(output: string) {
565
568
  });
566
569
  }
567
570
 
571
+ export function getAndroidLocalServerHost(adbDevicesOutput: string | undefined, fallbackHost: string) {
572
+ const onlineDeviceIds =
573
+ adbDevicesOutput
574
+ ?.split(/\r?\n/)
575
+ .map((line) => line.trim().split(/\s+/))
576
+ .filter(([id, state]) => id && state === "device")
577
+ .map(([id]) => id) ?? [];
578
+ return onlineDeviceIds.length === 1 && onlineDeviceIds[0]?.startsWith("emulator-") ? "10.0.2.2" : fallbackHost;
579
+ }
580
+
568
581
  export const ANDROID_MIN_SDK_VERSION = 26;
569
582
  export function raiseGradleMinSdkVersion(content: string, floor: number = ANDROID_MIN_SDK_VERSION): string | null {
570
583
  const match = content.match(/minSdkVersion\s*=\s*(\d+)/);
@@ -748,7 +761,7 @@ export class CapacitorApp {
748
761
  async save() {
749
762
  await this.project.commit();
750
763
  }
751
- async #prepareIos({ operation, env, regenerate = false }: PrepareConfig) {
764
+ async #prepareIos({ operation, env, regenerate = false, iosRunTargetKind }: PrepareConfig) {
752
765
  await this.init({ platform: "ios", operation, env, regenerate });
753
766
  await this.#prepareTargetAssets();
754
767
  await this.#prepareExternalFiles("ios");
@@ -757,9 +770,10 @@ export class CapacitorApp {
757
770
  await this.#applyDeepLinks("ios", { operation, env });
758
771
  await this.#flushIosEntitlements();
759
772
  await this.project.commit();
773
+ await this.#setCodeSignEntitlementsInIosIfExists("App/App.entitlements");
760
774
  await this.#generateAssets({ operation, env });
761
775
  this.app.verbose(`syncing iOS`);
762
- await this.#spawnMobile("npx", ["cap", "sync", "ios"], { operation, env });
776
+ await this.#spawnMobile("npx", ["cap", "sync", "ios"], { operation, env }, { iosRunTargetKind });
763
777
  this.app.verbose(`sync completed.`);
764
778
  }
765
779
  async buildIos({ env = "debug", regenerate = false }: { env?: RunConfig["env"]; regenerate?: boolean } = {}) {
@@ -777,14 +791,14 @@ export class CapacitorApp {
777
791
  }
778
792
  async runIos({ operation, env, regenerate = false, noAllowProvisioningUpdates = false, iosDeviceId }: RunIosConfig) {
779
793
  if (operation === "release") await this.prepareWww();
780
- await this.#prepareIos({ operation, env, regenerate });
781
794
  const runTarget = await this.#selectIosRunTarget(iosDeviceId);
795
+ await this.#prepareIos({ operation, env, regenerate, iosRunTargetKind: runTarget.kind });
782
796
  if (runTarget.kind === "simulator") {
783
797
  await this.#spawnMobile(
784
798
  "npx",
785
- ["cap", "run", "ios", "--target", runTarget.id],
799
+ ["cap", "run", "ios", "--target", runTarget.id, "--no-sync"],
786
800
  { operation, env },
787
- { stdio: "inherit" },
801
+ { stdio: "inherit", platform: "ios", iosRunTargetKind: runTarget.kind },
788
802
  );
789
803
  return;
790
804
  }
@@ -878,7 +892,10 @@ export class CapacitorApp {
878
892
  noAllowProvisioningUpdates: boolean;
879
893
  }) {
880
894
  const mobileEnv = sanitizeIosNativeRunEnv(await this.#commandEnv(operation, env));
881
- const configContent = await this.#writeCapacitorConfig({ operation }, mobileEnv);
895
+ const configContent = await this.#writeCapacitorConfig(
896
+ { operation, platform: "ios", iosRunTargetKind: runTarget.kind },
897
+ mobileEnv,
898
+ );
882
899
  await this.#writeRootCapacitorConfig(configContent);
883
900
  const scheme = this.#iosScheme();
884
901
  const command = buildIosNativeRunCommand({
@@ -943,6 +960,7 @@ export class CapacitorApp {
943
960
  await this.#applyAndroidMinSdkVersion();
944
961
  await this.#applyPermissions({ operation, env });
945
962
  await this.#applyDeepLinks("android", { operation, env });
963
+ await this.#disableNativeKeyboardResizeInAndroid();
946
964
  await this.project.commit();
947
965
  await this.#generateAssets({ operation, env });
948
966
  await this.#ensureAndroidAssetsDir();
@@ -1010,6 +1028,24 @@ export class CapacitorApp {
1010
1028
  async openAndroid() {
1011
1029
  await this.#spawnMobile("npx", ["cap", "open", "android"], { operation: "local", env: "local" });
1012
1030
  }
1031
+ async #disableNativeKeyboardResizeInAndroid() {
1032
+ const manifestPath = path.join(this.app.cwdPath, this.androidRootPath, "app/src/main/AndroidManifest.xml");
1033
+ let manifest = await readFile(manifestPath, "utf8");
1034
+ let changed = false;
1035
+ manifest = manifest.replace(/<activity\b[^>]*android:name="\.MainActivity"[^>]*>/, (activityTag) => {
1036
+ if (activityTag.includes("android:windowSoftInputMode=")) {
1037
+ const nextTag = activityTag.replace(
1038
+ /android:windowSoftInputMode="[^"]*"/,
1039
+ 'android:windowSoftInputMode="adjustNothing"',
1040
+ );
1041
+ changed ||= nextTag !== activityTag;
1042
+ return nextTag;
1043
+ }
1044
+ changed = true;
1045
+ return activityTag.replace(/>$/, '\n android:windowSoftInputMode="adjustNothing">');
1046
+ });
1047
+ if (changed) await writeFile(manifestPath, manifest);
1048
+ }
1013
1049
  async #ensureAndroidAssetsDir() {
1014
1050
  await mkdir(path.join(this.app.cwdPath, this.androidAssetsPath), { recursive: true });
1015
1051
  }
@@ -1100,12 +1136,19 @@ export class CapacitorApp {
1100
1136
  if (html.includes("window.__AKAN_MOBILE_TARGET__")) return html;
1101
1137
  return html.replace(/<\/head\s*>/i, `${script}\n</head>`);
1102
1138
  }
1103
- async #writeCapacitorConfig({ operation }: Pick<RunConfig, "operation">, commandEnv: MobileCommandEnv) {
1139
+ async #writeCapacitorConfig(
1140
+ {
1141
+ operation,
1142
+ platform,
1143
+ iosRunTargetKind,
1144
+ }: Pick<RunConfig, "operation"> & { platform?: MobilePlatform; iosRunTargetKind?: IosRunTargetKind },
1145
+ commandEnv: MobileCommandEnv,
1146
+ ) {
1104
1147
  await mkdir(this.targetRoot, { recursive: true });
1105
1148
  let localIp: string | undefined;
1106
1149
  if (operation === "local") {
1107
1150
  const override = commandEnv.AKAN_PUBLIC_CLIENT_HOST ?? process.env.AKAN_PUBLIC_CLIENT_HOST;
1108
- const resolution = selectLocalDevHost(os.networkInterfaces(), { override });
1151
+ const resolution = await this.#resolveLocalDevHost({ override, platform, iosRunTargetKind });
1109
1152
  localIp = resolution.host;
1110
1153
  this.#logDevHostResolution(resolution, commandEnv);
1111
1154
  }
@@ -1118,11 +1161,36 @@ export class CapacitorApp {
1118
1161
  await Bun.write(path.join(this.targetRoot, "capacitor.config.json"), content);
1119
1162
  return content;
1120
1163
  }
1164
+ // An explicit override always wins; an emulator/simulator run reaches the host machine through a
1165
+ // loopback alias (10.0.2.2 / localhost), so LAN detection only applies to physical-device runs.
1166
+ async #resolveLocalDevHost({
1167
+ override,
1168
+ platform,
1169
+ iosRunTargetKind,
1170
+ }: {
1171
+ override?: string;
1172
+ platform?: MobilePlatform;
1173
+ iosRunTargetKind?: IosRunTargetKind;
1174
+ }): Promise<LocalDevHostResolution> {
1175
+ const resolution = selectLocalDevHost(os.networkInterfaces(), { override });
1176
+ if (resolution.source === "override") return resolution;
1177
+ if (platform === "ios" && iosRunTargetKind === "simulator")
1178
+ return { ...resolution, host: "localhost", source: "platform" };
1179
+ if (platform === "android") {
1180
+ try {
1181
+ const host = getAndroidLocalServerHost(await this.#spawn("adb", ["devices"]), resolution.host);
1182
+ return host === resolution.host ? resolution : { ...resolution, host, source: "platform" };
1183
+ } catch {
1184
+ return resolution;
1185
+ }
1186
+ }
1187
+ return resolution;
1188
+ }
1121
1189
  // Surface the live-reload URL a physical device must reach, and warn when auto-detection landed on
1122
1190
  // a likely-unreachable host so a blank WebView is not mistaken for an app bug.
1123
1191
  #logDevHostResolution(resolution: LocalDevHostResolution, commandEnv: MobileCommandEnv) {
1124
1192
  this.app.log(`Mobile live-reload server: ${this.#localCsrUrl(resolution.host, commandEnv)}`);
1125
- if (resolution.source === "override") return;
1193
+ if (resolution.source === "override" || resolution.source === "platform") return;
1126
1194
  const suspicious = resolution.host === "127.0.0.1" || resolution.host.startsWith("169.254.");
1127
1195
  const alternatives = resolution.candidates.filter((candidate) => candidate.address !== resolution.host);
1128
1196
  if (!suspicious && alternatives.length === 0) return;
@@ -1332,20 +1400,29 @@ export class CapacitorApp {
1332
1400
  command: string,
1333
1401
  args: string[] = [],
1334
1402
  { operation, env }: Pick<RunConfig, "operation" | "env">,
1335
- options: Parameters<AppExecutor["spawn"]>[2] = {},
1403
+ options: SpawnMobileOptions = {},
1336
1404
  ) {
1405
+ const { iosRunTargetKind, platform, ...spawnOptions } = options;
1337
1406
  const mobileEnv = { ...(await this.#commandEnv(operation, env)), ...options.env };
1338
- const configContent = await this.#writeCapacitorConfig({ operation }, mobileEnv);
1407
+ const configContent = await this.#writeCapacitorConfig(
1408
+ { operation, platform: platform ?? this.#inferMobilePlatform(args), iosRunTargetKind },
1409
+ mobileEnv,
1410
+ );
1339
1411
  await this.#writeRootCapacitorConfig(configContent);
1340
1412
  try {
1341
1413
  return await this.#spawn(command, args, {
1342
- ...options,
1414
+ ...spawnOptions,
1343
1415
  env: mobileEnv,
1344
1416
  });
1345
1417
  } finally {
1346
1418
  await this.#clearRootCapacitorConfigs();
1347
1419
  }
1348
1420
  }
1421
+ #inferMobilePlatform(args: string[]): MobilePlatform | undefined {
1422
+ if (args.includes("android")) return "android";
1423
+ if (args.includes("ios")) return "ios";
1424
+ return undefined;
1425
+ }
1349
1426
  async addCamera() {
1350
1427
  await this.#setPermissionInIos({
1351
1428
  cameraUsageDescription: "$(PRODUCT_NAME) requires access to the camera to take photos.",
@@ -1446,14 +1523,31 @@ export class CapacitorApp {
1446
1523
  let end = index;
1447
1524
  while (end < lines.length && !/^\s*\};\s*$/.test(lines[end] ?? "")) end++;
1448
1525
  const settings = lines.slice(start, end + 1);
1449
- if (settings.some((setting) => setting.includes("CODE_SIGN_ENTITLEMENTS"))) continue;
1526
+ const configName = lines.slice(end + 1, end + 8).find((setting) => setting.includes("name = ")) ?? "";
1450
1527
  const indent = line.match(/^\s*/)?.[0] ?? "";
1451
- lines.splice(index, 0, `${indent}CODE_SIGN_ENTITLEMENTS = ${entitlementsRelPath};`);
1452
- index++;
1453
- changed = true;
1528
+ const insertSettings = [];
1529
+ if (!settings.some((setting) => setting.includes("CODE_SIGN_ENTITLEMENTS"))) {
1530
+ insertSettings.push(`${indent}CODE_SIGN_ENTITLEMENTS = ${entitlementsRelPath};`);
1531
+ }
1532
+ if (
1533
+ configName.includes("name = Debug;") &&
1534
+ !settings.some((setting) => setting.includes("CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION"))
1535
+ ) {
1536
+ insertSettings.push(`${indent}CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;`);
1537
+ }
1538
+ if (insertSettings.length > 0) {
1539
+ lines.splice(index, 0, ...insertSettings);
1540
+ index += insertSettings.length;
1541
+ changed = true;
1542
+ }
1454
1543
  }
1455
1544
  if (changed) await writeFile(pbxprojPath, lines.join("\n"));
1456
1545
  }
1546
+ async #setCodeSignEntitlementsInIosIfExists(entitlementsRelPath: string) {
1547
+ const entitlementsPath = path.join(this.app.cwdPath, this.iosProjectPath, entitlementsRelPath);
1548
+ if (!(await Bun.file(entitlementsPath).exists())) return;
1549
+ await this.#setCodeSignEntitlementsInIos(entitlementsRelPath);
1550
+ }
1457
1551
  async #setUrlSchemesInAndroid(schemes: string[]) {
1458
1552
  const manifestPath = path.join(this.app.cwdPath, this.androidRootPath, "app/src/main/AndroidManifest.xml");
1459
1553
  let manifest = await readFile(manifestPath, "utf8");
@@ -0,0 +1,27 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdtemp, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { CssCandidateCache } from "./cssCandidateCache";
6
+
7
+ const scan = async (source: string) => {
8
+ const dir = await mkdtemp(path.join(tmpdir(), "css-candidate-"));
9
+ const file = path.join(dir, "probe.tsx");
10
+ await writeFile(file, source);
11
+ return await new CssCandidateCache(path.join(dir, "cache.json")).load().then((c) => c.candidatesFor(file));
12
+ };
13
+
14
+ describe("CssCandidateCache token extraction", () => {
15
+ test("keeps plain utilities, variants and arbitrary values whole", async () => {
16
+ const candidates = await scan(`const c = "w-full hover:bg-muted md:w-1/2 text-[#fff]";`);
17
+ expect(candidates).toEqual(expect.arrayContaining(["w-full", "hover:bg-muted", "md:w-1/2", "text-[#fff]"]));
18
+ });
19
+
20
+ // A candidate that opens with `[` used to be torn into `_td` + `px-3`, so every descendant-variant
21
+ // rule in the framework compiled to nothing and its element rendered unstyled with no diagnostic.
22
+ test("keeps an arbitrary variant that starts with a bracket", async () => {
23
+ const candidates = await scan(`const c = "[&_td]:px-3 [&>*]:gap-2 [&_tbody_tr]:border-t";`);
24
+ expect(candidates).toEqual(expect.arrayContaining(["[&_td]:px-3", "[&>*]:gap-2", "[&_tbody_tr]:border-t"]));
25
+ expect(candidates).not.toContain("_td");
26
+ });
27
+ });
@@ -1,7 +1,12 @@
1
1
  import { stat } from "node:fs/promises";
2
2
 
3
- /** Every identifier-ish token in a source file is a potential tailwind class. */
4
- const CANDIDATE_RE = /-?[\w@][\w:/.-]*(?:\[[^\]]+\][\w:/.-]*)*/g;
3
+ /**
4
+ * Every identifier-ish token in a source file is a potential tailwind class. The leading alternation
5
+ * matters: an arbitrary variant starts with `[` (`[&_td]:px-3`, `[&>*]:gap-2`), and a pattern anchored
6
+ * on a word character tears it into `_td` plus `px-3` instead — both meaningless, so the rule compiles
7
+ * to nothing and the element silently renders unstyled.
8
+ */
9
+ const CANDIDATE_RE = /-?(?:[\w@]|\[[^\]]+\])[\w:/.-]*(?:\[[^\]]+\][\w:/.-]*)*/g;
5
10
 
6
11
  interface CachedFile {
7
12
  mtimeMs: number;
@@ -27,7 +32,7 @@ interface CacheFile {
27
32
  */
28
33
  export class CssCandidateCache {
29
34
  /** Bump when the token regex or the entry shape changes, so stale extractions are not reused. */
30
- static readonly #version = 1;
35
+ static readonly #version = 2;
31
36
  readonly #path: string;
32
37
  readonly #entries = new Map<string, CachedFile>();
33
38
  #dirty = false;
@@ -285,14 +285,9 @@ export class DevResourceProbe {
285
285
  "rscWorkerRecycleCount",
286
286
  "httpFullSsrCount",
287
287
  ];
288
- // `rssBytes` is the replica's own; the RSC worker is a separate process reporting under
289
- // `rscWorker*`. These used to be the same field, because the worker's report shadowed the
290
- // replica's — so this line printed the worker's RSS labelled as the child's.
291
- const toMb = (bytes: unknown) => (Number(bytes ?? 0) / 1024 / 1024).toFixed(0);
288
+ const rssMb = (Number(child.rssBytes ?? 0) / 1024 / 1024).toFixed(0);
292
289
  const parts = keys.map((key) => `${key}=${child[key] ?? "?"}`);
293
- console.info(
294
- `[metrics ${label}] replicaRss=${toMb(child.rssBytes)}MB rscWorkerRss=${toMb(child.rscWorkerRssBytes)}MB ${parts.join(" ")}`,
295
- );
290
+ console.info(`[metrics ${label}] rsc rss=${rssMb}MB ${parts.join(" ")}`);
296
291
  }
297
292
 
298
293
  async #waitForLog(pattern: RegExp, timeoutMs: number): Promise<boolean> {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "3.0.0-alpha.3",
3
+ "version": "3.0.0-alpha.4",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -44,7 +44,7 @@
44
44
  "@langchain/openai": "^1.4.6",
45
45
  "@tailwindcss/node": "^4.3.0",
46
46
  "@trapezedev/project": "^7.1.4",
47
- "akanjs": "3.0.0-alpha.3",
47
+ "akanjs": "3.0.0-alpha.4",
48
48
  "chalk": "^5.6.2",
49
49
  "commander": "^14.0.3",
50
50
  "dayjs": "^1.11.20",
@@ -3,7 +3,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { AbstractDoc } from "./abstractDoc";
6
- import { AkanQualityScanner, type QualityScanResult } from "./qualityScanner";
6
+ import { AkanQualityScanner } from "./qualityScanner";
7
7
 
8
8
  const tempRoots: string[] = [];
9
9
 
@@ -44,142 +44,3 @@ describe("AkanQualityScanner abstract rule", () => {
44
44
  expect(warnings[0]?.fix).toContain("akan compact");
45
45
  });
46
46
  });
47
-
48
- const staticMarkup = (elementNum: number) =>
49
- Array.from({ length: elementNum }, (_, idx) => ` <p className="text-sm">row ${idx}</p>`).join("\n");
50
-
51
- const rulesOf = (result: QualityScanResult, rule: string) => result.warnings.filter((warning) => warning.rule === rule);
52
-
53
- describe("AkanQualityScanner ssr rules", () => {
54
- test("flags a client file that uses no client-only capability", async () => {
55
- const root = await makeWorkspace({
56
- "apps/demo/ui/Plain.tsx": `"use client";\nexport const Plain = () => <div>plain</div>;\n`,
57
- "apps/demo/ui/Interactive.tsx": `"use client";\nexport const Interactive = () => <button onClick={() => null}>go</button>;\n`,
58
- "apps/demo/ui/Hooked.tsx": `"use client";\nimport { useState } from "react";\nexport const Hooked = () => {\n const [open] = useState(false);\n return <div>{open ? "y" : "n"}</div>;\n};\n`,
59
- });
60
-
61
- const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.ssr.unnecessary-use-client");
62
-
63
- expect(warnings).toHaveLength(1);
64
- expect(warnings[0]?.file).toBe("apps/demo/ui/Plain.tsx");
65
- });
66
-
67
- test("keeps the directive on a third-party wrapper and on an index_ boundary", async () => {
68
- const root = await makeWorkspace({
69
- "apps/demo/ui/Chart.tsx": `"use client";\nimport { Bar } from "react-chartjs-2";\nexport const Chart = () => <Bar data={{}} />;\n`,
70
- "apps/demo/ui/Lazy/index_.tsx": `"use client";\nexport { Inner } from "./Inner";\n`,
71
- });
72
-
73
- expect(rulesOf(await new AkanQualityScanner().scan(root), "akan.ssr.unnecessary-use-client")).toHaveLength(0);
74
- });
75
-
76
- test("flags a static component and a mostly-static component inside a client file", async () => {
77
- const root = await makeWorkspace({
78
- "apps/demo/ui/Panels.tsx": [
79
- `"use client";`,
80
- `import { useState } from "react";`,
81
- `export const StaticPanel = () => (`,
82
- ` <section>`,
83
- staticMarkup(5),
84
- ` </section>`,
85
- `);`,
86
- `export const MixedPanel = () => {`,
87
- ` const [open, setOpen] = useState(false);`,
88
- ` return (`,
89
- ` <section>`,
90
- staticMarkup(12),
91
- ` <span>{open ? "open" : "shut"}</span>`,
92
- ` </section>`,
93
- ` );`,
94
- `};`,
95
- "",
96
- ].join("\n"),
97
- });
98
-
99
- const result = await new AkanQualityScanner().scan(root);
100
- const staticWarnings = rulesOf(result, "akan.ssr.client-static-component");
101
- const mixedWarnings = rulesOf(result, "akan.ssr.client-static-markup");
102
-
103
- expect(staticWarnings).toHaveLength(1);
104
- expect(staticWarnings[0]?.message).toContain("StaticPanel");
105
- expect(staticWarnings[0]?.fix).toContain("server file");
106
- expect(mixedWarnings).toHaveLength(1);
107
- expect(mixedWarnings[0]?.message).toContain("MixedPanel");
108
- });
109
-
110
- test("flags a mount-only load but not a reactive one", async () => {
111
- const root = await makeWorkspace({
112
- "apps/demo/lib/post/Post.Zone.tsx": [
113
- `"use client";`,
114
- `import { useEffect } from "react";`,
115
- `export const List = ({ tag }: { tag: string }) => {`,
116
- ` useEffect(() => {`,
117
- ` void st.do.initPostInPublic();`,
118
- ` }, []);`,
119
- ` useEffect(() => {`,
120
- ` void st.do.getPostListInTag(tag);`,
121
- ` }, [tag]);`,
122
- ` return <div>{tag}</div>;`,
123
- `};`,
124
- "",
125
- ].join("\n"),
126
- });
127
-
128
- const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.ssr.client-mount-load");
129
-
130
- expect(warnings).toHaveLength(1);
131
- expect(warnings[0]?.message).toContain("st.do.initPostInPublic");
132
- expect(warnings[0]?.fix).toContain("init/view");
133
- });
134
-
135
- test("flags useState in a Template", async () => {
136
- const root = await makeWorkspace({
137
- "apps/demo/lib/post/Post.Template.tsx": `"use client";\nimport { useState } from "react";\nexport const General = () => {\n const [draft, setDraft] = useState("");\n return <input value={draft} onChange={(e) => setDraft(e.target.value)} />;\n};\n`,
138
- });
139
-
140
- const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.ssr.template-client-state");
141
-
142
- expect(warnings).toHaveLength(1);
143
- expect(warnings[0]?.fix).toContain("st.do.setFieldOnX");
144
- });
145
-
146
- test("flags a module that renders only from client files", async () => {
147
- const root = await makeWorkspace({
148
- "apps/demo/lib/post/Post.Zone.tsx": [
149
- `"use client";`,
150
- `import { useState } from "react";`,
151
- `export const Card = () => {`,
152
- ` const [open] = useState(false);`,
153
- ` return (`,
154
- ` <section>`,
155
- staticMarkup(14),
156
- ` <span>{open ? "open" : "shut"}</span>`,
157
- ` </section>`,
158
- ` );`,
159
- `};`,
160
- "",
161
- ].join("\n"),
162
- "libs/shared/lib/user/User.Zone.tsx": `"use client";\nimport { st } from "@libs/shared/client";\nexport const Self = () => <User.View.General user={st.use.self()} />;\n`,
163
- "libs/shared/lib/user/User.View.tsx": `export const General = ({ name }: { name: string }) => <div>{name}</div>;\n`,
164
- });
165
-
166
- const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.ssr.module-missing-server-view");
167
-
168
- expect(warnings).toHaveLength(1);
169
- expect(warnings[0]?.message).toContain("apps/demo/lib/post");
170
- });
171
-
172
- test("measures the server render share per scope and for the workspace", async () => {
173
- const root = await makeWorkspace({
174
- "apps/demo/ui/Server.tsx": `export const Server = () => (\n <section>\n <p>a</p>\n <p>b</p>\n </section>\n);\n`,
175
- "libs/shared/ui/Client.tsx": `"use client";\nexport const Client = () => <button onClick={() => null}>go</button>;\n`,
176
- });
177
-
178
- const { ssrBalance } = await new AkanQualityScanner().scan(root);
179
-
180
- expect(ssrBalance.map((entry) => entry.scope)).toEqual(["apps/demo", "libs/shared", "workspace"]);
181
- expect(ssrBalance[0]).toMatchObject({ serverMass: 3, clientMass: 0, serverShare: 1 });
182
- expect(ssrBalance[1]).toMatchObject({ serverMass: 0, clientMass: 1 });
183
- expect(ssrBalance[2]).toMatchObject({ scope: "workspace", serverMass: 3, clientMass: 1 });
184
- });
185
- });
package/qualityScanner.ts CHANGED
@@ -4,10 +4,9 @@ import path from "node:path";
4
4
  import ignore from "ignore";
5
5
  import ts from "typescript";
6
6
  import { AbstractDoc } from "./abstractDoc";
7
- import { formatSsrBalance, type SsrBalanceEntry, SsrScanner } from "./ssrScanner";
8
7
 
9
8
  type QualitySeverity = "warning";
10
- type QualityScope = "global" | "file" | "convention" | "layout" | "ssr";
9
+ type QualityScope = "global" | "file" | "convention" | "layout";
11
10
 
12
11
  export interface QualityWarning {
13
12
  rule: string;
@@ -24,11 +23,10 @@ export interface QualityScanResult {
24
23
  workspaceRoot: string;
25
24
  scannedFiles: number;
26
25
  warnings: QualityWarning[];
27
- ssrBalance: SsrBalanceEntry[];
28
26
  suggestedRules: string[];
29
27
  }
30
28
 
31
- export interface SourceFileInfo {
29
+ interface SourceFileInfo {
32
30
  file: string;
33
31
  absolutePath: string;
34
32
  content: string;
@@ -171,18 +169,6 @@ const RULE_FIXES: Record<string, string> = {
171
169
  "Move the file into a domain module folder under lib/; keep lib root limited to generated support facets.",
172
170
  "akan.layout.module-ui-file":
173
171
  "Rename the file to an allowed module UI name, or move it to ui/ if it is not a module component.",
174
- "akan.ssr.unnecessary-use-client":
175
- 'Delete the "use client" directive so the file renders on the server. If it exists only to wrap one client child, drop the wrapper and use the child directly.',
176
- "akan.ssr.client-static-component":
177
- "Move the component to a server file — a <Model>.Unit.tsx / <Model>.View.tsx for a module, or a ui/ file with no directive — and reference it from the client file.",
178
- "akan.ssr.client-static-markup":
179
- "Keep the interactive element in the client component and hoist the static subtree into a server component, then accept it as `children` or render it through a Unit/View reference.",
180
- "akan.ssr.client-mount-load":
181
- "Load the data in the route with `fetch.initX(...)` / `fetch.viewX(...)` and pass the init/view object down as a prop; the client store hydrates from it and the effect goes away.",
182
- "akan.ssr.module-missing-server-view":
183
- "Add a <Model>.Unit.tsx for list/card rendering and a <Model>.View.tsx for the detail surface, then have the Zone delegate to them.",
184
- "akan.ssr.template-client-state":
185
- "Bind the field to the store instead: `value={xForm.field}` with `onChange={st.do.setFieldOnX}`.",
186
172
  };
187
173
 
188
174
  function getRuleFix(rule: string): string | undefined {
@@ -204,7 +190,6 @@ export class AkanQualityScanner {
204
190
  .filter((file) => AbstractDoc.isAbstractPath(file))
205
191
  .map((file) => this.#readTextFile(workspaceRoot, file)),
206
192
  );
207
- const ssr = new SsrScanner().scan(sourceFiles);
208
193
  const warnings = [
209
194
  ...this.#scanGlobalQuality(sourceFiles),
210
195
  ...sourceFiles.flatMap((sourceFile) => this.#scanSingleFileQuality(sourceFile)),
@@ -212,7 +197,6 @@ export class AkanQualityScanner {
212
197
  ...sourceFiles.flatMap((sourceFile) => this.#scanConventionQuality(sourceFile)),
213
198
  ...sourceFiles.flatMap((sourceFile) => this.#scanLayoutQuality(sourceFile)),
214
199
  ...abstractFiles.flatMap((abstractFile) => this.#scanAbstractQuality(abstractFile)),
215
- ...ssr.warnings,
216
200
  ];
217
201
 
218
202
  return {
@@ -221,7 +205,6 @@ export class AkanQualityScanner {
221
205
  warnings: warnings
222
206
  .map((warning) => ({ ...warning, fix: warning.fix ?? getRuleFix(warning.rule) }))
223
207
  .sort(compareWarnings),
224
- ssrBalance: ssr.balance,
225
208
  suggestedRules: SUGGESTED_RULES,
226
209
  };
227
210
  }
@@ -487,10 +470,6 @@ export function formatQualityScanResult(result: QualityScanResult) {
487
470
  "",
488
471
  ...formatQualityWarnings(result.warnings),
489
472
  "",
490
- "SSR balance (component files, JSX elements rendered per side):",
491
- "",
492
- ...formatSsrBalance(result.ssrBalance),
493
- "",
494
473
  "Suggested quality rules:",
495
474
  "",
496
475
  ...result.suggestedRules.map((rule) => ` - ${rule}`),
@@ -498,24 +477,6 @@ export function formatQualityScanResult(result: QualityScanResult) {
498
477
  return sections.join("\n");
499
478
  }
500
479
 
501
- export function formatSsrScanResult(result: QualityScanResult) {
502
- const sections = [
503
- "Akan SSR Balance Scan",
504
- `workspace: ${result.workspaceRoot}`,
505
- `scanned files: ${result.scannedFiles}`,
506
- `ssr warnings: ${result.warnings.length}`,
507
- "",
508
- "Server render share (component files, JSX elements rendered per side):",
509
- "",
510
- ...formatSsrBalance(result.ssrBalance),
511
- "",
512
- "Warnings:",
513
- "",
514
- ...formatQualityWarnings(result.warnings),
515
- ];
516
- return sections.join("\n");
517
- }
518
-
519
480
  export function formatQualityWarnings(warnings: QualityWarning[]) {
520
481
  if (warnings.length === 0) return ["No warnings found."];
521
482
  return warnings.flatMap((warning) => {
package/spinner.ts CHANGED
@@ -2,26 +2,6 @@ import ora, { type Ora } from "ora";
2
2
 
3
3
  export class Spinner {
4
4
  static padding = 12;
5
- /**
6
- * XXX: `discardStdin` must stay off. It makes ora put the terminal into raw mode for as long as the
7
- * spinner runs, and a Bun child spawned in that window snapshots the raw termios and writes it back
8
- * when it exits — long after the spinner restored the terminal. `akan start` spawns the builder and
9
- * the backend under the "Preparing backend..." spinner, so the first builder recycle (a config or
10
- * runtime-metadata change) SIGTERMs that child and silently turns the developer's terminal raw:
11
- * `isig` goes off, Ctrl+C stops producing SIGINT at all, and the dev server looks unkillable.
12
- */
13
- static oraOptions = { discardStdin: false } as const;
14
- /**
15
- * ora sizes its clear loop as `ceil(lineWidth / stream.columns)`, so a tty that reports **0** columns
16
- * makes it `Infinity` and `clear()` never returns. An unsized pty does exactly that (`isTTY: true`,
17
- * `columns: 0`) — CI runners, `expect`/`script` harnesses, some detached panes. Measured: 750MB of
18
- * cursor moves and 8.7GB RSS inside a minute, in a loop that no longer reaches the point where SIGINT
19
- * or SIGTERM could be handled, so only SIGKILL ends it. A terminal with no width has nothing to
20
- * animate anyway; fall back to plain lines.
21
- */
22
- static canAnimate(stream: NodeJS.WriteStream = process.stderr): boolean {
23
- return !stream.isTTY || stream.columns > 0;
24
- }
25
5
  spinner: Ora;
26
6
  stopWatch: NodeJS.Timeout | null = null;
27
7
  startAt: Date = new Date();
@@ -32,10 +12,10 @@ export class Spinner {
32
12
  Spinner.padding = Math.max(Spinner.padding, prefix.length);
33
13
  this.prefix = prefix;
34
14
  this.message = message;
35
- this.spinner = ora({ ...Spinner.oraOptions, text: message });
15
+ this.spinner = ora(message);
36
16
  this.spinner.prefixText = prefix.padStart(Spinner.padding, " ");
37
17
  this.spinner.indent = indent;
38
- this.enableSpin = enableSpin && Spinner.canAnimate();
18
+ this.enableSpin = enableSpin;
39
19
  }
40
20
  start() {
41
21
  this.startAt = new Date();