@akanjs/devkit 3.0.0-alpha.71 → 3.0.0-alpha.72

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.
Files changed (117) hide show
  1. package/akanApp/BackendImportGraph.test.ts +120 -0
  2. package/akanApp/BackendImportGraph.ts +167 -0
  3. package/akanApp/akanApp.host.test.ts +16 -678
  4. package/akanApp/akanApp.host.ts +65 -562
  5. package/akanApp/devHostPolicy.test.ts +542 -0
  6. package/akanApp/devHostPolicy.ts +412 -0
  7. package/akanApp/index.ts +2 -0
  8. package/akanContext.ts +17 -3
  9. package/akanMcpContract.ts +25 -4
  10. package/artifact/routeSeedIndex.test.ts +1 -0
  11. package/capacitorApp.ts +10 -0
  12. package/cloud/globalConfig.ts +9 -2
  13. package/commandDecorators/command.ts +30 -6
  14. package/commandDecorators/commandBuilder.ts +20 -5
  15. package/commandDecorators/commandDecorators.test.ts +115 -3
  16. package/dependencyScanner.test.ts +99 -0
  17. package/dependencyScanner.ts +27 -24
  18. package/devkitUtils.test.ts +0 -104
  19. package/executors.test.ts +45 -0
  20. package/executors.ts +29 -5
  21. package/fileEditor.ts +19 -19
  22. package/frontendBuild/autoImportSync.test.ts +58 -0
  23. package/frontendBuild/autoImportSync.ts +4 -0
  24. package/frontendBuild/buildRouteClient.test.ts +19 -22
  25. package/frontendBuild/clientEntryDiscovery.ts +2 -2
  26. package/frontendBuild/fontOptimizer.ts +37 -18
  27. package/frontendBuild/index.ts +0 -1
  28. package/frontendBuild/styleContract.ts +14 -20
  29. package/frontendBuild/themeValidator.ts +22 -17
  30. package/incrementalBuilder/devWatchBatch.ts +6 -2
  31. package/incrementalBuilder/incrementalBuilder.proc.ts +6 -2
  32. package/index.ts +0 -5
  33. package/lint/__fixtures__/README.md +40 -0
  34. package/lint/__fixtures__/no-arbitrary-color/bad.tsx +3 -0
  35. package/lint/__fixtures__/no-arbitrary-color/good.tsx +4 -0
  36. package/lint/__fixtures__/no-async-component-in-ui/bad.tsx +4 -0
  37. package/lint/__fixtures__/no-async-component-in-ui/good.tsx +4 -0
  38. package/lint/__fixtures__/no-bang-comment-in-client/bad.tsx +4 -0
  39. package/lint/__fixtures__/no-bang-comment-in-client/fixture.json +1 -0
  40. package/lint/__fixtures__/no-bang-comment-in-client/good.tsx +3 -0
  41. package/lint/__fixtures__/no-daisyui-legacy-class/bad.tsx +7 -0
  42. package/lint/__fixtures__/no-daisyui-legacy-class/good.tsx +5 -0
  43. package/lint/__fixtures__/no-deep-internal-import/bad.tsx +3 -0
  44. package/lint/__fixtures__/no-deep-internal-import/fixture.json +1 -0
  45. package/lint/__fixtures__/no-deep-internal-import/good.tsx +3 -0
  46. package/lint/__fixtures__/no-deprecated-log-level/bad.tsx +4 -0
  47. package/lint/__fixtures__/no-deprecated-log-level/good.tsx +4 -0
  48. package/lint/__fixtures__/no-import-client-functions/bad.tsx +2 -0
  49. package/lint/__fixtures__/no-import-client-functions/good.tsx +4 -0
  50. package/lint/__fixtures__/no-import-client-in-server/bad.tsx +6 -0
  51. package/lint/__fixtures__/no-import-client-in-server/good.tsx +5 -0
  52. package/lint/__fixtures__/no-import-external-library/bad.tsx +4 -0
  53. package/lint/__fixtures__/no-import-external-library/good.tsx +5 -0
  54. package/lint/__fixtures__/no-import-server-in-client/bad.tsx +6 -0
  55. package/lint/__fixtures__/no-import-server-in-client/good.tsx +4 -0
  56. package/lint/__fixtures__/no-init-fetch-in-client/store-file/bad.ts +1 -0
  57. package/lint/__fixtures__/no-init-fetch-in-client/store-file/fixture.json +1 -0
  58. package/lint/__fixtures__/no-init-fetch-in-client/store-file/good.ts +1 -0
  59. package/lint/__fixtures__/no-init-fetch-in-client/use-client/bad.tsx +4 -0
  60. package/lint/__fixtures__/no-init-fetch-in-client/use-client/good.tsx +5 -0
  61. package/lint/__fixtures__/no-inline-color/bad.tsx +3 -0
  62. package/lint/__fixtures__/no-inline-color/good.tsx +3 -0
  63. package/lint/__fixtures__/no-interpolated-arbitrary-class/bad.tsx +3 -0
  64. package/lint/__fixtures__/no-interpolated-arbitrary-class/good.tsx +3 -0
  65. package/lint/__fixtures__/no-js-private-class-method/bad.tsx +3 -0
  66. package/lint/__fixtures__/no-js-private-class-method/good.tsx +3 -0
  67. package/lint/__fixtures__/no-model-type-in-util-zone/bad.tsx +3 -0
  68. package/lint/__fixtures__/no-model-type-in-util-zone/good.tsx +6 -0
  69. package/lint/__fixtures__/no-raw-palette-class/bad.tsx +6 -0
  70. package/lint/__fixtures__/no-raw-palette-class/good.tsx +8 -0
  71. package/lint/__fixtures__/no-redeclare-predefined-endpoint/bad.ts +10 -0
  72. package/lint/__fixtures__/no-redeclare-predefined-endpoint/fixture.json +1 -0
  73. package/lint/__fixtures__/no-redeclare-predefined-endpoint/good.ts +6 -0
  74. package/lint/__fixtures__/no-return-in-store-action/bad.tsx +2 -0
  75. package/lint/__fixtures__/no-return-in-store-action/good.tsx +6 -0
  76. package/lint/__fixtures__/no-throw-raw-error/bad.tsx +4 -0
  77. package/lint/__fixtures__/no-throw-raw-error/good.tsx +4 -0
  78. package/lint/__fixtures__/no-unpublished-form-setter/bad.tsx +3 -0
  79. package/lint/__fixtures__/no-unpublished-form-setter/good.tsx +5 -0
  80. package/lint/__fixtures__/no-use-client-in-server/bad.tsx +3 -0
  81. package/lint/__fixtures__/no-use-client-in-server/good.tsx +2 -0
  82. package/lint/__fixtures__/non-scalar-props-restricted/bad.tsx +5 -0
  83. package/lint/__fixtures__/non-scalar-props-restricted/good.tsx +4 -0
  84. package/lint/gritRules.test.ts +178 -0
  85. package/lint/no-arbitrary-color.grit +1 -1
  86. package/lint/no-bang-comment-in-client.grit +23 -10
  87. package/lint/no-daisyui-legacy-class.grit +2 -2
  88. package/lint/no-inline-color.grit +10 -8
  89. package/lint/no-interpolated-arbitrary-class.grit +3 -3
  90. package/lint/no-raw-palette-class.grit +3 -3
  91. package/lint/non-scalar-props-restricted.grit +16 -7
  92. package/linter.test.ts +80 -0
  93. package/linter.ts +82 -13
  94. package/package.json +3 -3
  95. package/prompter.ts +9 -4
  96. package/qualityScanner.test.ts +64 -0
  97. package/qualityScanner.ts +43 -3
  98. package/recipeScanner.ts +4 -1
  99. package/scanInfo.ts +3 -3
  100. package/ssrScanner.test.ts +301 -0
  101. package/transforms/barrelImportsPlugin.ts +1 -1
  102. package/transforms/transforms.test.ts +5 -5
  103. package/transforms/tsconfigPackageResolver.test.ts +230 -0
  104. package/tsconfig.json +5 -1
  105. package/typeChecker.ts +1 -1
  106. package/types.ts +1 -0
  107. package/ui/ScrollList.tsx +6 -8
  108. package/uploadRelease.ts +2 -2
  109. package/workflow/executor.test.ts +146 -0
  110. package/builder.ts +0 -164
  111. package/extractDeps.ts +0 -86
  112. package/frontendBuild/styleGuard.test.ts +0 -180
  113. package/frontendBuild/styleGuard.ts +0 -339
  114. package/getCredentials.ts +0 -17
  115. package/getModelFileData.ts +0 -62
  116. package/src/capacitorApp.ts +0 -282
  117. package/streamAi.ts +0 -45
@@ -0,0 +1,301 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import ts from "typescript";
3
+ import type { SourceFileInfo } from "./qualityScanner";
4
+ import { formatSsrBalance, SSR_SERVER_SHARE_TARGET, SsrScanner } from "./ssrScanner";
5
+
6
+ /**
7
+ * `SsrScanner` is a documented gate — `akan quality ssr` prints the server share and AGENTS.md names 50% as
8
+ * the floor — so both halves matter: a rule that stops firing lets the share rot, and a rule that
9
+ * over-reports is what makes developers stop reading the output. Every rule here gets a positive and the
10
+ * negative that must stay quiet.
11
+ */
12
+ const fileOf = (file: string, content: string): SourceFileInfo => ({
13
+ file,
14
+ absolutePath: `/repo/${file}`,
15
+ content,
16
+ sourceFile: ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX),
17
+ });
18
+
19
+ const rulesOf = (...files: SourceFileInfo[]) => new SsrScanner().scan(files).warnings.map((warning) => warning.rule);
20
+
21
+ const scanOne = (file: string, content: string) => rulesOf(fileOf(file, content));
22
+
23
+ const UI = "apps/demo/ui/Thing.tsx";
24
+
25
+ describe("akan.ssr.unnecessary-use-client", () => {
26
+ test("flags a directive with no client-only capability behind it", () => {
27
+ expect(scanOne(UI, `"use client";\nexport const Thing = () => <div />;\n`)).toContain(
28
+ "akan.ssr.unnecessary-use-client",
29
+ );
30
+ });
31
+
32
+ test("a hook, a handler, a store read and a browser global each justify the directive", () => {
33
+ const justified = [
34
+ `export const Thing = () => { const [open] = useState(false); return <div>{open}</div>; };`,
35
+ `export const Thing = () => <button onClick={save} />;`,
36
+ `export const Thing = () => <div>{st.use.task()}</div>;`,
37
+ `export const Thing = () => <div>{window.innerWidth}</div>;`,
38
+ ];
39
+ for (const body of justified)
40
+ expect(scanOne(UI, `"use client";\n${body}\n`)).not.toContain("akan.ssr.unnecessary-use-client");
41
+ });
42
+
43
+ test("a third-party import is a reason the AST cannot rule out", () => {
44
+ expect(scanOne(UI, `"use client";\nimport Chart from "recharts";\nexport const Thing = () => <div />;\n`)).toEqual(
45
+ [],
46
+ );
47
+ });
48
+
49
+ // `usePage`/`getSelf` read request-scoped server context, so they must not read as client evidence.
50
+ test("a server-safe call is not client evidence", () => {
51
+ expect(
52
+ scanOne(
53
+ UI,
54
+ `"use client";\nexport const Thing = () => { const { l } = usePage(); return <div>{l("a")}</div>; };\n`,
55
+ ),
56
+ ).toContain("akan.ssr.unnecessary-use-client");
57
+ });
58
+
59
+ test("importing st or fetch justifies the directive on its own", () => {
60
+ expect(
61
+ scanOne(UI, `"use client";\nimport { st } from "../lib/st";\nexport const Thing = () => <div />;\n`),
62
+ ).toEqual([]);
63
+ });
64
+
65
+ // Zone/Template/Util and the lazy() boundary carry the directive by role, not by accident.
66
+ test("every convention client file is exempt", () => {
67
+ const body = `"use client";\nexport const Card = () => <div />;\n`;
68
+ const exempt = [
69
+ "apps/demo/lib/task/Task.Zone.tsx",
70
+ "apps/demo/lib/task/Task.Template.tsx",
71
+ "apps/demo/lib/task/Task.Util.tsx",
72
+ "apps/demo/ui/Chart/index_.tsx",
73
+ ];
74
+ for (const file of exempt) expect(scanOne(file, body)).toEqual([]);
75
+ });
76
+
77
+ test("a Unit or View in the same module is not exempt", () => {
78
+ const body = `"use client";\nexport const Card = () => <div />;\n`;
79
+ expect(scanOne("apps/demo/lib/task/Task.Unit.tsx", body)).toContain("akan.ssr.unnecessary-use-client");
80
+ expect(scanOne("apps/demo/lib/task/Task.View.tsx", body)).toContain("akan.ssr.unnecessary-use-client");
81
+ });
82
+
83
+ test("a Zone outside a module folder is not exempt", () => {
84
+ expect(scanOne("apps/demo/ui/Task.Zone.tsx", `"use client";\nexport const Zone = () => <div />;\n`)).toContain(
85
+ "akan.ssr.unnecessary-use-client",
86
+ );
87
+ });
88
+
89
+ test("the directive has to be the first statement to count as one", () => {
90
+ expect(
91
+ scanOne(UI, `import { cn } from "akanjs/client";\n"use client";\nexport const Thing = () => <div />;\n`),
92
+ ).toEqual([]);
93
+ });
94
+ });
95
+
96
+ describe("akan.ssr.client-static-component", () => {
97
+ const four = "<div><span /><span /><span /></div>";
98
+
99
+ test("flags a static component once its subtree is worth moving", () => {
100
+ expect(
101
+ scanOne(UI, `"use client";\nimport { st } from "../lib/st";\nexport const Card = () => ${four};\n`),
102
+ ).toContain("akan.ssr.client-static-component");
103
+ });
104
+
105
+ test("stays quiet below the mass floor", () => {
106
+ expect(
107
+ scanOne(UI, `"use client";\nimport { st } from "../lib/st";\nexport const Card = () => <div><span /></div>;\n`),
108
+ ).toEqual([]);
109
+ });
110
+
111
+ test("a component with any client touch is not static", () => {
112
+ expect(
113
+ scanOne(UI, `"use client";\nexport const Card = () => <div onClick={save}><span /><span /><span /></div>;\n`),
114
+ ).not.toContain("akan.ssr.client-static-component");
115
+ });
116
+
117
+ // A vendor component's own tags may be client-only, so its subtree is not server-renderable markup.
118
+ test("a subtree rendering a vendor tag is exempt", () => {
119
+ expect(
120
+ scanOne(
121
+ UI,
122
+ `"use client";\nimport Chart from "recharts";\nexport const Card = () => <div><Chart /><span /><span /></div>;\n`,
123
+ ),
124
+ ).toEqual([]);
125
+ });
126
+
127
+ test("a lowercase binding is not a component", () => {
128
+ expect(scanOne(UI, `"use client";\nimport { st } from "../lib/st";\nexport const card = () => ${four};\n`)).toEqual(
129
+ [],
130
+ );
131
+ });
132
+ });
133
+
134
+ describe("akan.ssr.client-static-markup", () => {
135
+ const ten = `<div><p /><p /><p /><p /><p /><p /><p /><p /><button onClick={save} /></div>`;
136
+
137
+ test("flags a large subtree wrapping one interactive touch", () => {
138
+ expect(scanOne(UI, `"use client";\nexport const Panel = () => ${ten};\n`)).toContain(
139
+ "akan.ssr.client-static-markup",
140
+ );
141
+ });
142
+
143
+ test("stays quiet when the subtree is small enough that splitting buys nothing", () => {
144
+ expect(scanOne(UI, `"use client";\nexport const Panel = () => <div><button onClick={save} /></div>;\n`)).toEqual(
145
+ [],
146
+ );
147
+ });
148
+
149
+ test("stays quiet once the component is interactive throughout", () => {
150
+ const busy = `<div><button onClick={a} /><button onClick={b} /><button onClick={c} /><p /><p /><p /><p /><p /><p /></div>`;
151
+ expect(scanOne(UI, `"use client";\nexport const Panel = () => ${busy};\n`)).not.toContain(
152
+ "akan.ssr.client-static-markup",
153
+ );
154
+ });
155
+ });
156
+
157
+ describe("akan.ssr.client-mount-load", () => {
158
+ const mount = (body: string) =>
159
+ `"use client";\nexport const Thing = () => { useEffect(() => { ${body} }, []); return <div />; };\n`;
160
+
161
+ test("flags a fetch and a generated store load fired from a mount-only effect", () => {
162
+ expect(scanOne(UI, mount("void fetch.initTaskInOrg(orgId);"))).toContain("akan.ssr.client-mount-load");
163
+ expect(scanOne(UI, mount("void st.do.initTaskInOrg();"))).toContain("akan.ssr.client-mount-load");
164
+ });
165
+
166
+ test("useLayoutEffect is the same finding", () => {
167
+ const body = `"use client";\nexport const Thing = () => { useLayoutEffect(() => { void fetch.viewTask(id); }, []); return <div />; };\n`;
168
+ expect(scanOne(UI, body)).toContain("akan.ssr.client-mount-load");
169
+ });
170
+
171
+ // A reactive effect responds to client state and has no server-side equivalent.
172
+ test("a non-empty dependency array is not a mount load", () => {
173
+ const body = `"use client";\nexport const Thing = () => { useEffect(() => { void fetch.viewTask(id); }, [id]); return <div />; };\n`;
174
+ expect(scanOne(UI, body)).not.toContain("akan.ssr.client-mount-load");
175
+ });
176
+
177
+ test("a mount effect that loads nothing is not a finding", () => {
178
+ expect(scanOne(UI, mount("subscribe();"))).not.toContain("akan.ssr.client-mount-load");
179
+ });
180
+
181
+ test("a store action that is not a load is not a finding", () => {
182
+ expect(scanOne(UI, mount("void st.do.openModal();"))).not.toContain("akan.ssr.client-mount-load");
183
+ });
184
+ });
185
+
186
+ describe("akan.ssr.template-client-state", () => {
187
+ const template = "apps/demo/lib/task/Task.Template.tsx";
188
+
189
+ test("flags useState in a Template", () => {
190
+ const body = `"use client";\nexport const General = () => { const [draft, setDraft] = useState(""); return <input value={draft} />; };\n`;
191
+ expect(scanOne(template, body)).toContain("akan.ssr.template-client-state");
192
+ });
193
+
194
+ test("the same useState in a Zone is not a finding", () => {
195
+ const body = `"use client";\nexport const Card = () => { const [open, setOpen] = useState(false); return <div>{open}</div>; };\n`;
196
+ expect(scanOne("apps/demo/lib/task/Task.Zone.tsx", body)).not.toContain("akan.ssr.template-client-state");
197
+ });
198
+
199
+ test("a store-driven Template is clean", () => {
200
+ const body = `"use client";\nexport const General = () => <input value={st.use.taskForm().name} onChange={st.do.setNameOnTask} />;\n`;
201
+ expect(scanOne(template, body)).toEqual([]);
202
+ });
203
+ });
204
+
205
+ describe("akan.ssr.module-missing-server-view", () => {
206
+ const clientMass = `<div><p /><p /><p /><p /><p /><p /><p /><p /><p /><p /><p /></div>`;
207
+
208
+ test("flags a module that renders only from client files", () => {
209
+ const zone = fileOf(
210
+ "apps/demo/lib/task/Task.Zone.tsx",
211
+ `"use client";\nexport const Card = () => ${clientMass};\n`,
212
+ );
213
+ expect(rulesOf(zone)).toContain("akan.ssr.module-missing-server-view");
214
+ });
215
+
216
+ test("one Unit or View in the module answers it", () => {
217
+ const zone = fileOf(
218
+ "apps/demo/lib/task/Task.Zone.tsx",
219
+ `"use client";\nexport const Card = () => ${clientMass};\n`,
220
+ );
221
+ const unit = fileOf("apps/demo/lib/task/Task.Unit.tsx", `export const Card = () => <div />;\n`);
222
+ expect(rulesOf(zone, unit)).not.toContain("akan.ssr.module-missing-server-view");
223
+ });
224
+
225
+ test("a module too small to matter is not a finding", () => {
226
+ const zone = fileOf(
227
+ "apps/demo/lib/task/Task.Zone.tsx",
228
+ `"use client";\nexport const Card = () => <div><p /></div>;\n`,
229
+ );
230
+ expect(rulesOf(zone)).not.toContain("akan.ssr.module-missing-server-view");
231
+ });
232
+
233
+ // `lib/_<service>` folders are service modules, which own no model to render server-side.
234
+ test("a service module is not held to it", () => {
235
+ const zone = fileOf(
236
+ "apps/demo/lib/_upload/Upload.Zone.tsx",
237
+ `"use client";\nexport const Card = () => ${clientMass};\n`,
238
+ );
239
+ expect(rulesOf(zone)).not.toContain("akan.ssr.module-missing-server-view");
240
+ });
241
+ });
242
+
243
+ describe("scan scope", () => {
244
+ test("only apps/libs ui and lib .tsx files are measured", () => {
245
+ const outOfScope = [
246
+ "apps/demo/page/task/_index.tsx",
247
+ "apps/demo/webkit/useThing.tsx",
248
+ "apps/demo/ui/Thing.test.tsx",
249
+ "pkgs/akanjs/ui/Thing.tsx",
250
+ "apps/demo/ui/thing.ts",
251
+ ];
252
+ for (const file of outOfScope)
253
+ expect(new SsrScanner().scan([fileOf(file, `"use client";\nexport const Thing = () => <div />;\n`)])).toEqual({
254
+ warnings: [],
255
+ balance: [],
256
+ });
257
+ });
258
+ });
259
+
260
+ describe("balance", () => {
261
+ test("measures the share in JSX elements and not in files", () => {
262
+ const server = fileOf("apps/demo/ui/Server.tsx", `export const A = () => <div><p /><p /></div>;\n`);
263
+ const client = fileOf("apps/demo/ui/Client.tsx", `"use client";\nexport const B = () => <div onClick={a} />;\n`);
264
+ const [entry] = new SsrScanner().scan([server, client]).balance;
265
+ expect(entry).toEqual({ scope: "apps/demo", serverMass: 3, clientMass: 1, serverShare: 0.75 });
266
+ });
267
+
268
+ test("adds a workspace row only once a second scope exists", () => {
269
+ const demo = fileOf("apps/demo/ui/A.tsx", `export const A = () => <div />;\n`);
270
+ const shared = fileOf("libs/shared/ui/B.tsx", `"use client";\nexport const B = () => <div onClick={a} />;\n`);
271
+ expect(new SsrScanner().scan([demo]).balance.map((entry) => entry.scope)).toEqual(["apps/demo"]);
272
+ expect(new SsrScanner().scan([demo, shared]).balance.map((entry) => entry.scope)).toEqual([
273
+ "apps/demo",
274
+ "libs/shared",
275
+ "workspace",
276
+ ]);
277
+ });
278
+
279
+ // An empty scope reads as fully server-rendered rather than as a division by zero.
280
+ test("a scope with no JSX at all is 100% server", () => {
281
+ const empty = fileOf("apps/demo/ui/Empty.tsx", `export const value = 1;\n`);
282
+ expect(new SsrScanner().scan([empty]).balance[0].serverShare).toBe(1);
283
+ });
284
+ });
285
+
286
+ describe("formatSsrBalance", () => {
287
+ test("flags a scope under the target and leaves one above it unmarked", () => {
288
+ const lines = formatSsrBalance([
289
+ { scope: "apps/low", serverMass: 1, clientMass: 9, serverShare: 0.1 },
290
+ { scope: "apps/high", serverMass: 9, clientMass: 1, serverShare: 0.9 },
291
+ ]);
292
+ expect(lines[0]).toContain("10% server");
293
+ expect(lines[0]).toContain(`below the ${Math.round(SSR_SERVER_SHARE_TARGET * 100)}% target`);
294
+ expect(lines[1]).toContain("90% server");
295
+ expect(lines[1]).not.toContain("below the");
296
+ });
297
+
298
+ test("says so rather than printing an empty table", () => {
299
+ expect(formatSsrBalance([])).toEqual(["No component files found."]);
300
+ });
301
+ });
@@ -223,7 +223,7 @@ const resolveNodePackageExport = async (workspaceRoot: string, specifier: string
223
223
  const subpath = specifier === packageName ? "." : `.${specifier.slice(packageName.length)}`;
224
224
  const exported = resolvePackageExport(pkgJson.exports, subpath);
225
225
  const rel = exported ?? (subpath === "." ? (pkgJson.module ?? pkgJson.main ?? "index.js") : null);
226
- if (!rel || !rel.startsWith(".")) return null;
226
+ if (!rel?.startsWith(".")) return null;
227
227
  const entryFile = await resolveFileCandidate(path.resolve(pkgDir, rel));
228
228
  if (!entryFile) return null;
229
229
  const pkgEntryName = specifier;
@@ -104,7 +104,7 @@ describe("BarrelAnalyzer and rewriteBarrelImports", () => {
104
104
  ["A", { subpath: "@scope/pkg/leaf", originalName: "A" }],
105
105
  ["Bee", { subpath: "@scope/pkg/leaf", originalName: "B" }],
106
106
  ]),
107
- } as BarrelAnalyzer;
107
+ } as unknown as BarrelAnalyzer;
108
108
 
109
109
  const rewritten = await rewriteBarrelImports(
110
110
  'import DefaultExport, { A, Bee as LocalBee, type Shape, Missing } from "@scope/pkg";\nconsole.log(A);',
@@ -128,7 +128,7 @@ describe("BarrelAnalyzer and rewriteBarrelImports", () => {
128
128
  test("preserves generated client barrel side effects when flattening app client imports", async () => {
129
129
  const analyzer = {
130
130
  analyze: async () => new Map([["st", { subpath: "@apps/demo/lib/st", originalName: "st" }]]),
131
- } as BarrelAnalyzer;
131
+ } as unknown as BarrelAnalyzer;
132
132
 
133
133
  const rewritten = await rewriteBarrelImports(
134
134
  'import { st } from "@apps/demo/client";\nvoid st;\n',
@@ -144,7 +144,7 @@ describe("BarrelAnalyzer and rewriteBarrelImports", () => {
144
144
  test("does not rewrite import-looking code inside template literals", async () => {
145
145
  const analyzer = {
146
146
  analyze: async () => new Map([["AkanApp", { subpath: "akanjs/server/akanApp", originalName: "AkanApp" }]]),
147
- } as BarrelAnalyzer;
147
+ } as unknown as BarrelAnalyzer;
148
148
 
149
149
  const source = [
150
150
  'import { Code } from "@apps/docs/ui";',
@@ -170,7 +170,7 @@ describe("BarrelAnalyzer and rewriteBarrelImports", () => {
170
170
  ["AkanOption", { subpath: "akanjs/server/akanOption", originalName: "AkanOption" }],
171
171
  ["Try", { subpath: "akanjs/server/decorators", originalName: "Try" }],
172
172
  ]),
173
- } as BarrelAnalyzer;
173
+ } as unknown as BarrelAnalyzer;
174
174
 
175
175
  const rewritten = await rewriteBarrelImports(
176
176
  'import { AkanOption, Try } from "akanjs/server";\nexport const option = new AkanOption();\n',
@@ -191,7 +191,7 @@ describe("BarrelAnalyzer and rewriteBarrelImports", () => {
191
191
  const analyzer = {
192
192
  analyze: async () =>
193
193
  new Map([["BottomInset", { subpath: "akanjs/ui/Layout/BottomInset", originalName: "BottomInset" }]]),
194
- } as BarrelAnalyzer;
194
+ } as unknown as BarrelAnalyzer;
195
195
 
196
196
  const rewritten = await rewriteBarrelImports('import { BottomInset } from "akanjs/ui";\n', ["akanjs/ui"], analyzer);
197
197
 
@@ -0,0 +1,230 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { createTsconfigPackageResolver } from "./barrelImportsPlugin";
6
+
7
+ const tempRoots: string[] = [];
8
+
9
+ const makeTempRoot = async () => {
10
+ const root = await mkdtemp(path.join(os.tmpdir(), "akan-devkit-resolver-"));
11
+ tempRoots.push(root);
12
+ return root;
13
+ };
14
+
15
+ const write = async (root: string, relPath: string, content = "export const value = 1;\n") => {
16
+ const filePath = path.join(root, relPath);
17
+ await mkdir(path.dirname(filePath), { recursive: true });
18
+ await writeFile(filePath, content);
19
+ return filePath;
20
+ };
21
+
22
+ const resolverFor = async (root: string, paths: Record<string, string[]>) =>
23
+ await createTsconfigPackageResolver({
24
+ workspace: { workspaceRoot: root },
25
+ getTsConfig: async () => ({ compilerOptions: { paths } }),
26
+ } as never);
27
+
28
+ afterEach(async () => {
29
+ await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
30
+ });
31
+
32
+ describe("createTsconfigPackageResolver — exact tsconfig mapping", () => {
33
+ test("resolves a package barrel and keeps the specifier", async () => {
34
+ const root = await makeTempRoot();
35
+ const entryFile = await write(root, "libs/util/index.ts");
36
+ const resolve = await resolverFor(root, { "@libs/util": ["./libs/util/index.ts"] });
37
+
38
+ expect(await resolve("@libs/util")).toEqual({
39
+ pkgName: "@libs/util",
40
+ entryFile,
41
+ pkgDir: path.join(root, "libs/util"),
42
+ });
43
+ });
44
+
45
+ test("collapses a facet specifier to its parent package", async () => {
46
+ // `@libs/util/server` maps to the sibling file `libs/util/server.ts`, not to a package directory, so a
47
+ // leaf rewritten under the raw specifier would become `@libs/util/server/lib/sig` — a path that does
48
+ // not exist. The parent specifier is what the `@libs/*` wildcard can actually resolve.
49
+ const root = await makeTempRoot();
50
+ const entryFile = await write(root, "libs/util/server.ts");
51
+ const resolve = await resolverFor(root, { "@libs/util/server": ["./libs/util/server.ts"] });
52
+
53
+ expect(await resolve("@libs/util/server")).toEqual({
54
+ pkgName: "@libs/util",
55
+ entryFile,
56
+ pkgDir: path.join(root, "libs/util"),
57
+ });
58
+ });
59
+
60
+ test("keeps the specifier when the entry file name does not match the facet", async () => {
61
+ const root = await makeTempRoot();
62
+ const entryFile = await write(root, "libs/util/clientEntry.ts");
63
+ const resolve = await resolverFor(root, { "@libs/util/client": ["./libs/util/clientEntry.ts"] });
64
+
65
+ expect(await resolve("@libs/util/client")).toEqual({
66
+ pkgName: "@libs/util/client",
67
+ entryFile,
68
+ pkgDir: path.join(root, "libs/util"),
69
+ });
70
+ });
71
+
72
+ test("returns null when the mapped file is missing", async () => {
73
+ const root = await makeTempRoot();
74
+ const resolve = await resolverFor(root, { "@libs/util": ["./libs/util/index.ts"] });
75
+ expect(await resolve("@libs/util")).toBeNull();
76
+ });
77
+
78
+ test("returns null for an empty mapping", async () => {
79
+ const root = await makeTempRoot();
80
+ await write(root, "libs/util/index.ts");
81
+ const resolve = await resolverFor(root, { "@libs/util": [""] });
82
+ expect(await resolve("@libs/util")).toBeNull();
83
+ });
84
+ });
85
+
86
+ describe("createTsconfigPackageResolver — wildcard tsconfig mapping", () => {
87
+ test("prefers the longer prefix", async () => {
88
+ const root = await makeTempRoot();
89
+ const entryFile = await write(root, "custom/ui/index.ts");
90
+ await write(root, "libs/util/ui/index.ts");
91
+ const resolve = await resolverFor(root, {
92
+ "@libs/*": ["./libs/*"],
93
+ "@libs/util/*": ["./custom/*"],
94
+ });
95
+
96
+ expect(await resolve("@libs/util/ui")).toEqual({
97
+ pkgName: "@libs/util/ui",
98
+ entryFile,
99
+ pkgDir: path.join(root, "custom/ui"),
100
+ });
101
+ });
102
+
103
+ test("collapses a sibling facet file to its parent package", async () => {
104
+ const root = await makeTempRoot();
105
+ const entryFile = await write(root, "apps/minimal/client.ts");
106
+ const resolve = await resolverFor(root, { "@apps/*": ["./apps/*"] });
107
+
108
+ expect(await resolve("@apps/minimal/client")).toEqual({
109
+ pkgName: "@apps/minimal",
110
+ entryFile,
111
+ pkgDir: path.join(root, "apps/minimal"),
112
+ });
113
+ });
114
+
115
+ test("resolves a directory barrel and keeps the specifier", async () => {
116
+ const root = await makeTempRoot();
117
+ const entryFile = await write(root, "apps/minimal/index.tsx");
118
+ const resolve = await resolverFor(root, { "@apps/*": ["./apps/*"] });
119
+
120
+ expect(await resolve("@apps/minimal")).toEqual({
121
+ pkgName: "@apps/minimal",
122
+ entryFile,
123
+ pkgDir: path.join(root, "apps/minimal"),
124
+ });
125
+ });
126
+
127
+ test("prefers a sibling facet file over a same-named directory barrel", async () => {
128
+ const root = await makeTempRoot();
129
+ const entryFile = await write(root, "apps/minimal/client.ts");
130
+ await write(root, "apps/minimal/client/index.ts");
131
+ const resolve = await resolverFor(root, { "@apps/*": ["./apps/*"] });
132
+
133
+ expect((await resolve("@apps/minimal/client"))?.entryFile).toBe(entryFile);
134
+ });
135
+
136
+ test("stops at the matched prefix instead of falling through to a shorter one", async () => {
137
+ // Falling through would resolve `@libs/util/ui` against `./libs/*`, silently loading an unrelated
138
+ // directory that happens to sit at the same subpath under a different root.
139
+ const root = await makeTempRoot();
140
+ await write(root, "libs/util/ui/index.ts");
141
+ const resolve = await resolverFor(root, {
142
+ "@libs/*": ["./libs/*"],
143
+ "@libs/util/*": ["./custom/*"],
144
+ });
145
+
146
+ expect(await resolve("@libs/util/ui")).toBeNull();
147
+ });
148
+
149
+ test("tries every replacement of a matched prefix", async () => {
150
+ const root = await makeTempRoot();
151
+ const entryFile = await write(root, "second/util/index.ts");
152
+ const resolve = await resolverFor(root, { "@libs/*": ["./first/*", "./second/*"] });
153
+
154
+ expect((await resolve("@libs/util"))?.entryFile).toBe(entryFile);
155
+ });
156
+ });
157
+
158
+ describe("createTsconfigPackageResolver — node_modules fallback", () => {
159
+ const writePackageJson = async (root: string, pkgName: string, pkgJson: unknown) =>
160
+ await write(root, `node_modules/${pkgName}/package.json`, JSON.stringify(pkgJson));
161
+
162
+ test("resolves an exports subpath and preserves the file path", async () => {
163
+ const root = await makeTempRoot();
164
+ await writePackageJson(root, "akanjs", { exports: { "./ui": { source: "./ui/index.ts" } } });
165
+ const entryFile = await write(root, "node_modules/akanjs/ui/index.ts");
166
+ const resolve = await resolverFor(root, {});
167
+
168
+ expect(await resolve("akanjs/ui")).toEqual({
169
+ pkgName: "akanjs/ui",
170
+ entryFile,
171
+ pkgDir: path.join(root, "node_modules/akanjs/ui"),
172
+ preserveFilePath: true,
173
+ });
174
+ });
175
+
176
+ test("resolves an exports wildcard subpath", async () => {
177
+ const root = await makeTempRoot();
178
+ await writePackageJson(root, "akanjs", { exports: { "./*": "./dist/*.js" } });
179
+ const entryFile = await write(root, "node_modules/akanjs/dist/signal.js");
180
+ const resolve = await resolverFor(root, {});
181
+
182
+ expect((await resolve("akanjs/signal"))?.entryFile).toBe(entryFile);
183
+ });
184
+
185
+ test("resolves a scoped package subpath", async () => {
186
+ // A scoped name is two path segments, so the package boundary cannot be found by splitting on the
187
+ // first slash. Kept vendor-neutral on purpose: `packageExports.test.ts` reads every
188
+ // `"@akanjs/devkit/<x>"` literal under this package as a subpath the monorepo imports.
189
+ const root = await makeTempRoot();
190
+ await writePackageJson(root, "@vendor/kit", { exports: { "./lint": "./lint/index.ts" } });
191
+ const entryFile = await write(root, "node_modules/@vendor/kit/lint/index.ts");
192
+ const resolve = await resolverFor(root, {});
193
+
194
+ expect((await resolve("@vendor/kit/lint"))?.entryFile).toBe(entryFile);
195
+ });
196
+
197
+ test("falls back to module/main when exports resolution declines the specifier", async () => {
198
+ // `main: "index.js"` has no leading `./`, which the exports resolver rejects — the last fallback
199
+ // resolves it against the package directory, and it is the one path that does not preserve the file.
200
+ const root = await makeTempRoot();
201
+ await writePackageJson(root, "plainpkg", { main: "index.js" });
202
+ const entryFile = await write(root, "node_modules/plainpkg/index.js");
203
+ const resolve = await resolverFor(root, {});
204
+
205
+ expect(await resolve("plainpkg")).toEqual({
206
+ pkgName: "plainpkg",
207
+ entryFile,
208
+ pkgDir: path.join(root, "node_modules/plainpkg"),
209
+ });
210
+ });
211
+
212
+ test("returns null when the package is not installed", async () => {
213
+ const root = await makeTempRoot();
214
+ expect(await resolverFor(root, {}).then((resolve) => resolve("missingpkg"))).toBeNull();
215
+ });
216
+
217
+ test("returns null when the package.json is unparseable", async () => {
218
+ const root = await makeTempRoot();
219
+ await write(root, "node_modules/brokenpkg/package.json", "{ not json");
220
+ const resolve = await resolverFor(root, {});
221
+ expect(await resolve("brokenpkg")).toBeNull();
222
+ });
223
+
224
+ test("returns null when the resolved entry file is missing", async () => {
225
+ const root = await makeTempRoot();
226
+ await writePackageJson(root, "ghostpkg", { module: "./dist/index.js" });
227
+ const resolve = await resolverFor(root, {});
228
+ expect(await resolve("ghostpkg")).toBeNull();
229
+ });
230
+ });
package/tsconfig.json CHANGED
@@ -32,6 +32,10 @@
32
32
  "files": [],
33
33
  "include": [
34
34
  "**/*.ts",
35
- "**/*.tsx"
35
+ "**/*.tsx",
36
+ "../../akanjs/server/types/*.d.ts"
37
+ ],
38
+ "exclude": [
39
+ "lint/__fixtures__"
36
40
  ]
37
41
  }
package/typeChecker.ts CHANGED
@@ -208,7 +208,7 @@ export class TypeChecker {
208
208
  try {
209
209
  const { diagnostics } = this.check(filePath);
210
210
  return diagnostics.length === 0;
211
- } catch (error) {
211
+ } catch (_error) {
212
212
  return false;
213
213
  }
214
214
  }
package/types.ts CHANGED
@@ -15,6 +15,7 @@ export interface PackageJson {
15
15
  bun?: {
16
16
  platform?: "node" | "browser" | "bun";
17
17
  };
18
+ publishConfig?: { access?: "public" | "restricted"; registry?: string; tag?: string };
18
19
  [key: string]: unknown;
19
20
  }
20
21
 
package/ui/ScrollList.tsx CHANGED
@@ -83,14 +83,12 @@ export const ScrollList = ({ list, ...props }: ScrollListProps) => {
83
83
  <Text backgroundColor="green">scrolling... + {scrollPos}</Text>
84
84
  </>
85
85
  ) : (
86
- <>
87
- {renderLogs.map((log, index) => (
88
- <>
89
- <Text key={index}>{log}</Text>
90
- <Newline />
91
- </>
92
- ))}
93
- </>
86
+ renderLogs.map((log, index) => (
87
+ <>
88
+ <Text key={index}>{log}</Text>
89
+ <Newline />
90
+ </>
91
+ ))
94
92
  )}
95
93
  </Text>
96
94
  </Box>
package/uploadRelease.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { HttpClient, Logger } from "akanjs/common";
1
+ import { Logger, RestClient } from "akanjs/common";
2
2
 
3
3
  import { Spinner } from "./spinner";
4
4
 
@@ -26,7 +26,7 @@ export const uploadRelease = async (
26
26
  ) => {
27
27
  const logger = new Logger("uploadRelease");
28
28
  const basePath = local ? "http://localhost:8282/backend" : "https://cloud.akanjs.com/backend";
29
- const httpClient = new HttpClient(basePath);
29
+ const httpClient = new RestClient(basePath);
30
30
  const buildPath = `${workspaceRoot}/releases/builds/${appName}-release.tar.gz`;
31
31
  const appBuildPath = `${workspaceRoot}/releases/builds/${appName}-appBuild.zip`;
32
32
  const sourcePath = `${workspaceRoot}/releases/sources/${appName}-source.tar.gz`;