@akanjs/devkit 3.1.2-rc.4 → 3.2.0-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.
Files changed (85) hide show
  1. package/CHANGELOG.md +348 -0
  2. package/abstractDoc.ts +1 -1
  3. package/agentAssets.ts +2 -2
  4. package/akanConfig/akanConfig.ts +2 -2
  5. package/akanMcpContract.ts +1 -1
  6. package/apiReference.test.ts +37 -0
  7. package/apiReference.ts +240 -0
  8. package/applicationBuildReporter.test.ts +25 -3
  9. package/applicationBuildReporter.ts +8 -1
  10. package/applicationBuildRunner.test.ts +34 -14
  11. package/applicationBuildRunner.ts +76 -14
  12. package/artifact/implicitRootLayout.test.ts +42 -0
  13. package/artifact/implicitRootLayout.ts +5 -6
  14. package/artifact/storeBootstrap.ts +24 -0
  15. package/cloud/cloudApi.test.ts +70 -7
  16. package/cloud/cloudApi.ts +10 -4
  17. package/cloud/constants.ts +3 -0
  18. package/cloud/globalConfig.test.ts +36 -0
  19. package/cloud/globalConfig.ts +10 -1
  20. package/codeAgent/skills/akan-debug/SKILL.md +2 -1
  21. package/codeAgent/skills/akan-page/SKILL.md +16 -7
  22. package/codeAgent/skills/akan-service/SKILL.md +48 -19
  23. package/commandDecorators/command.ts +55 -9
  24. package/commandDecorators/commandDecorators.test.ts +131 -2
  25. package/declarationReader.ts +211 -0
  26. package/executors.test.ts +65 -8
  27. package/executors.ts +5 -3
  28. package/frameworkGraph.test.ts +121 -0
  29. package/frameworkGraph.ts +165 -0
  30. package/frontendBuild/allRoutesBuilder.test.ts +84 -0
  31. package/frontendBuild/allRoutesBuilder.ts +32 -12
  32. package/frontendBuild/buildRouteClient.test.ts +192 -2
  33. package/frontendBuild/bundleDefine.ts +4 -0
  34. package/frontendBuild/clientBuildTypes.ts +7 -1
  35. package/frontendBuild/clientEntriesBundler.test.ts +82 -0
  36. package/frontendBuild/clientEntriesBundler.ts +55 -4
  37. package/frontendBuild/csrArtifactBuilder.test.ts +23 -0
  38. package/frontendBuild/csrArtifactBuilder.ts +2 -1
  39. package/frontendBuild/fontOptimizer.test.ts +54 -0
  40. package/frontendBuild/fontOptimizer.ts +5 -4
  41. package/frontendBuild/index.ts +3 -0
  42. package/frontendBuild/routeClientBuilder.ts +6 -2
  43. package/frontendBuild/routeModulePreloads.test.ts +59 -0
  44. package/frontendBuild/routeModulePreloads.ts +52 -0
  45. package/frontendBuild/ssrBaseArtifactBuilder.ts +35 -10
  46. package/frontendBuild/ssrDevBundler.test.ts +26 -0
  47. package/frontendBuild/ssrDevBundler.ts +2 -1
  48. package/frontendBuild/vendorImportScanner.test.ts +71 -0
  49. package/frontendBuild/vendorImportScanner.ts +80 -0
  50. package/frontendBuild/vendorNarrowing.test.ts +103 -0
  51. package/frontendBuild/vendorNarrowing.ts +64 -0
  52. package/frontendBuild/vendorSpecifiers.ts +13 -0
  53. package/gitRevision.ts +6 -2
  54. package/incrementalBuilder/builderGraphPayloads.ts +60 -0
  55. package/incrementalBuilder/incrementalBuilder.proc.test.ts +65 -1
  56. package/incrementalBuilder/incrementalBuilder.proc.ts +5 -36
  57. package/integration/devStability.integration.test.ts +1 -1
  58. package/integration/devStabilityHarness.test.ts +1 -1
  59. package/lint/no-deep-internal-import.grit +1 -1
  60. package/maintainabilityBaseline.ts +7 -2
  61. package/maintainabilityContract.test.ts +183 -0
  62. package/maintainabilityContract.ts +145 -30
  63. package/maintainabilityExceptionScanner.test.ts +13 -2
  64. package/maintainabilityExceptionScanner.ts +16 -6
  65. package/maintainabilityLimits.ts +12 -0
  66. package/maintainabilityReport.test.ts +35 -0
  67. package/maintainabilityReport.ts +29 -8
  68. package/mobile/nativePublicEnv.test.ts +5 -1
  69. package/package.json +3 -3
  70. package/routeJsBudget.test.ts +177 -0
  71. package/routeJsBudget.ts +222 -0
  72. package/structureScanner.test.ts +167 -0
  73. package/structureScanner.ts +164 -0
  74. package/transforms/barrelAnalyzer.ts +10 -3
  75. package/transforms/barrelImportsPlugin.ts +13 -7
  76. package/transforms/serverEnvPlugin.test.ts +1 -1
  77. package/transforms/serverEnvPlugin.ts +1 -1
  78. package/transforms/transforms.test.ts +64 -12
  79. package/transforms/tsconfigPackageResolver.test.ts +12 -0
  80. package/workflow/plan.ts +79 -17
  81. package/workflow/types.ts +1 -0
  82. package/workspaceGraph.test.ts +58 -0
  83. package/workspaceGraph.ts +32 -3
  84. package/workspaceLayout.ts +1 -0
  85. /package/integration/{devStabilityHarness.ts → devStabilityHarness.fixture.ts} +0 -0
@@ -0,0 +1,164 @@
1
+ import ts from "typescript";
2
+
3
+ export interface FunctionSize {
4
+ name: string;
5
+ line: number;
6
+ lineCount: number;
7
+ }
8
+
9
+ export interface ClassSize {
10
+ name: string;
11
+ line: number;
12
+ memberCount: number;
13
+ }
14
+
15
+ type FunctionNode =
16
+ | ts.FunctionDeclaration
17
+ | ts.MethodDeclaration
18
+ | ts.ConstructorDeclaration
19
+ | ts.GetAccessorDeclaration
20
+ | ts.SetAccessorDeclaration
21
+ | ts.FunctionExpression
22
+ | ts.ArrowFunction;
23
+
24
+ export class StructureScanner {
25
+ measure(sourceFile: ts.SourceFile): { functions: FunctionSize[]; classes: ClassSize[] } {
26
+ const functions: FunctionSize[] = [];
27
+ const classes: ClassSize[] = [];
28
+ const lineOf = (pos: number) => sourceFile.getLineAndCharacterOfPosition(pos).line + 1;
29
+ const visit = (node: ts.Node, owners: string[]) => {
30
+ if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) {
31
+ const name = StructureScanner.#classNameOf(node);
32
+ classes.push({
33
+ name: [...owners, name].join("."),
34
+ line: lineOf(node.getStart(sourceFile)),
35
+ memberCount: StructureScanner.#memberCountOf(node),
36
+ });
37
+ ts.forEachChild(node, (child) => visit(child, [...owners, name]));
38
+ return;
39
+ }
40
+ if (StructureScanner.#isFunction(node)) {
41
+ const name = StructureScanner.#functionNameOf(node);
42
+ const line = lineOf(node.getStart(sourceFile));
43
+ functions.push({
44
+ name: [...owners, name].join("."),
45
+ line,
46
+ lineCount: lineOf(node.getEnd()) - line + 1,
47
+ });
48
+ ts.forEachChild(node, (child) => visit(child, [...owners, name]));
49
+ return;
50
+ }
51
+ if (ts.isObjectLiteralExpression(node)) {
52
+ const name = StructureScanner.#boundNameOf(node);
53
+ ts.forEachChild(node, (child) => visit(child, name === "<anonymous>" ? owners : [...owners, name]));
54
+ return;
55
+ }
56
+ ts.forEachChild(node, (child) => visit(child, owners));
57
+ };
58
+ ts.forEachChild(sourceFile, (child) => visit(child, []));
59
+ return { functions, classes };
60
+ }
61
+
62
+ static #memberCountOf(node: ts.ClassDeclaration | ts.ClassExpression) {
63
+ const methods = new Set<string>();
64
+ return node.members.filter((member) => {
65
+ if (
66
+ ts.isSemicolonClassElement(member) ||
67
+ ts.isIndexSignatureDeclaration(member) ||
68
+ ts.isClassStaticBlockDeclaration(member)
69
+ )
70
+ return false;
71
+ if (!ts.isMethodDeclaration(member) && !ts.isConstructorDeclaration(member)) return true;
72
+ const isStatic = ts.getModifiers(member)?.some((modifier) => modifier.kind === ts.SyntaxKind.StaticKeyword);
73
+ const key = `${isStatic ? "static " : ""}${ts.isConstructorDeclaration(member) ? "constructor" : StructureScanner.#textOf(member.name)}`;
74
+ if (methods.has(key)) return false;
75
+ methods.add(key);
76
+ return true;
77
+ }).length;
78
+ }
79
+
80
+ static #isFunction(node: ts.Node): node is FunctionNode {
81
+ return (
82
+ (ts.isFunctionDeclaration(node) ||
83
+ ts.isMethodDeclaration(node) ||
84
+ ts.isConstructorDeclaration(node) ||
85
+ ts.isGetAccessorDeclaration(node) ||
86
+ ts.isSetAccessorDeclaration(node) ||
87
+ ts.isFunctionExpression(node) ||
88
+ ts.isArrowFunction(node)) &&
89
+ node.body !== undefined
90
+ );
91
+ }
92
+
93
+ static #functionNameOf(node: FunctionNode) {
94
+ if (ts.isConstructorDeclaration(node)) return "constructor";
95
+ if (ts.isGetAccessorDeclaration(node)) return `get ${StructureScanner.#textOf(node.name)}`;
96
+ if (ts.isSetAccessorDeclaration(node)) return `set ${StructureScanner.#textOf(node.name)}`;
97
+ if (node.name) return StructureScanner.#textOf(node.name);
98
+ return ts.isFunctionDeclaration(node) ? "default" : StructureScanner.#boundNameOf(node);
99
+ }
100
+
101
+ static #classNameOf(node: ts.ClassDeclaration | ts.ClassExpression) {
102
+ if (node.name) return node.name.text;
103
+ return ts.isClassDeclaration(node) ? "default" : StructureScanner.#boundNameOf(node);
104
+ }
105
+
106
+ static #boundNameOf(node: ts.Node) {
107
+ let current: ts.Node = StructureScanner.#outerOf(node);
108
+ let call: string | null = null;
109
+ while (ts.isCallExpression(current.parent) || ts.isNewExpression(current.parent)) {
110
+ const callee = current.parent.expression;
111
+ if (current === callee) break;
112
+ call ??= `${ts.isPropertyAccessExpression(callee) ? callee.name.text : ts.isIdentifier(callee) ? callee.text : "call"}()`;
113
+ current = StructureScanner.#outerOf(current.parent);
114
+ }
115
+ const { parent } = current;
116
+ if (
117
+ (ts.isVariableDeclaration(parent) ||
118
+ ts.isPropertyAssignment(parent) ||
119
+ ts.isPropertyDeclaration(parent) ||
120
+ ts.isParameter(parent) ||
121
+ ts.isBindingElement(parent)) &&
122
+ parent.name &&
123
+ !ts.isObjectBindingPattern(parent.name) &&
124
+ !ts.isArrayBindingPattern(parent.name)
125
+ )
126
+ return StructureScanner.#textOf(parent.name);
127
+ if (ts.isBinaryExpression(parent) && parent.operatorToken.kind === ts.SyntaxKind.EqualsToken)
128
+ return ts.isPropertyAccessExpression(parent.left)
129
+ ? parent.left.name.text
130
+ : ts.isIdentifier(parent.left)
131
+ ? parent.left.text
132
+ : "assigned";
133
+ if (ts.isExportAssignment(parent)) return "default";
134
+ if (ts.isJsxExpression(parent))
135
+ return ts.isJsxAttribute(parent.parent)
136
+ ? ts.isIdentifier(parent.parent.name)
137
+ ? parent.parent.name.text
138
+ : parent.parent.name.getText()
139
+ : "children";
140
+ return call ?? "<anonymous>";
141
+ }
142
+
143
+ static #outerOf(node: ts.Node) {
144
+ let current = node;
145
+ while (
146
+ ts.isParenthesizedExpression(current.parent) ||
147
+ ts.isAsExpression(current.parent) ||
148
+ ts.isSatisfiesExpression(current.parent) ||
149
+ ts.isNonNullExpression(current.parent) ||
150
+ ts.isTypeAssertionExpression(current.parent) ||
151
+ (ts.isPropertyAccessExpression(current.parent) && current.parent.expression === current) ||
152
+ (ts.isCallExpression(current.parent) && current.parent.expression === current)
153
+ )
154
+ current = current.parent;
155
+ return current;
156
+ }
157
+
158
+ static #textOf(name: ts.PropertyName | ts.BindingName) {
159
+ if (ts.isIdentifier(name) || ts.isPrivateIdentifier(name) || ts.isStringLiteral(name)) return name.text;
160
+ if (ts.isNumericLiteral(name) || ts.isNoSubstitutionTemplateLiteral(name)) return name.text;
161
+ if (ts.isComputedPropertyName(name)) return `[${name.expression.getText()}]`;
162
+ return "<pattern>";
163
+ }
164
+ }
@@ -5,6 +5,8 @@ import { parseNamedList } from "./moduleSyntax";
5
5
  export interface BarrelExportTarget {
6
6
  subpath: string;
7
7
  originalName: string;
8
+ /** `export * as name from`: an import of the name binds the whole module at `subpath`. */
9
+ namespace?: boolean;
8
10
  }
9
11
 
10
12
  export type BarrelExportMap = Map<string, BarrelExportTarget>;
@@ -100,9 +102,14 @@ export class BarrelAnalyzer {
100
102
  if (!isRelative(spec)) continue;
101
103
 
102
104
  if (star) {
103
- // A namespace re-export cannot be flattened into subpath imports, nor is it a local declaration.
104
- if (nsAs) authoritative.delete(nsAs);
105
- else starSpecs.push(spec);
105
+ if (!nsAs) starSpecs.push(spec);
106
+ else if (authoritative.has(nsAs)) {
107
+ attributed.add(nsAs);
108
+ const targetAbs = await this.#resolveRel(absFile, spec);
109
+ const targetSubpath = targetAbs ? this.#subpathFor(pkg, targetAbs) : null;
110
+ if (targetSubpath && !map.has(nsAs))
111
+ map.set(nsAs, { subpath: targetSubpath, originalName: nsAs, namespace: true });
112
+ }
106
113
  continue;
107
114
  }
108
115
 
@@ -1,6 +1,7 @@
1
1
  import path from "node:path";
2
2
  import type { BunPlugin } from "bun";
3
3
  import ts from "typescript";
4
+ import { StoreBootstrap } from "../artifact/storeBootstrap";
4
5
  import type { App } from "../commandDecorators";
5
6
  import { BarrelAnalyzer, type BarrelExportMap, type PackageEntry } from "./barrelAnalyzer";
6
7
  import { loaderFor, type NamedItem, parseNamedList } from "./moduleSyntax";
@@ -101,8 +102,9 @@ export const createTsconfigPackageResolver = async (
101
102
  if (!repl) continue;
102
103
  const replPath = repl.endsWith("/*") ? repl.slice(0, -1) : repl;
103
104
  const candidate = path.resolve(app.workspace.workspaceRoot, replPath + suffix);
104
- // A sibling file (`apps/minimal/client.ts`) is a facet barrel too: parent specifier, as above.
105
- for (const ext of CANDIDATE_EXTS) {
105
+ // A sibling file (`apps/minimal/client.ts`), or the file a specifier names with its extension
106
+ // (`akanjs/ui/System/index.tsx`), is a facet barrel too: parent specifier, as above.
107
+ for (const ext of CANDIDATE_EXTS.includes(path.extname(candidate)) ? ["", ...CANDIDATE_EXTS] : CANDIDATE_EXTS) {
106
108
  const file = `${candidate}${ext}`;
107
109
  if (await Bun.file(file).exists()) {
108
110
  const lastSlash = pkgName.lastIndexOf("/");
@@ -339,6 +341,7 @@ const rewriteSingleStatement = (stmt: ImportStatement, map: BarrelExportMap): st
339
341
 
340
342
  const remaining: NamedItem[] = [];
341
343
  const rewrites = new Map<string, NamedItem[]>();
344
+ const namespaceLines: string[] = [];
342
345
  for (const item of clause.named) {
343
346
  if (item.isType) {
344
347
  remaining.push(item);
@@ -349,15 +352,19 @@ const rewriteSingleStatement = (stmt: ImportStatement, map: BarrelExportMap): st
349
352
  remaining.push(item);
350
353
  continue;
351
354
  }
355
+ if (target.namespace) {
356
+ namespaceLines.push(`import * as ${item.local} from "${target.subpath}";`);
357
+ continue;
358
+ }
352
359
  const list = rewrites.get(target.subpath) ?? [];
353
360
  list.push({ imported: target.originalName, local: item.local, isType: false });
354
361
  rewrites.set(target.subpath, list);
355
362
  }
356
363
 
357
- if (rewrites.size === 0) return null;
364
+ if (rewrites.size === 0 && namespaceLines.length === 0) return null;
358
365
 
359
- const lines: string[] = [];
360
- if (shouldPreserveBarrelSideEffects(stmt.specifier)) lines.push(`import "${stmt.specifier}";`);
366
+ //? An app or lib client barrel's import-time side effects are its client runtime and store, not its module UI.
367
+ const lines: string[] = [...(StoreBootstrap.ofClientBarrel(stmt.specifier, map) ?? [])];
361
368
 
362
369
  if (clause.defaultImport || remaining.length > 0) {
363
370
  const parts: string[] = [];
@@ -369,12 +376,11 @@ const rewriteSingleStatement = (stmt: ImportStatement, map: BarrelExportMap): st
369
376
  for (const [subpath, items] of rewrites) {
370
377
  lines.push(`import { ${items.map(serializeNamedItem).join(", ")} } from "${subpath}";`);
371
378
  }
379
+ lines.push(...namespaceLines);
372
380
 
373
381
  return lines.join("\n");
374
382
  };
375
383
 
376
- const shouldPreserveBarrelSideEffects = (specifier: string): boolean => /^@(apps|libs)\/[^/]+\/client$/.test(specifier);
377
-
378
384
  const serializeNamedItem = (item: NamedItem): string => {
379
385
  const prefix = item.isType ? "type " : "";
380
386
  if (item.imported === item.local) return `${prefix}${item.imported}`;
@@ -86,7 +86,7 @@ describe("createServerEnvPlugin", () => {
86
86
  "env/env.server.develop.ts is not in this build: `akan build` bundles the server env of AKAN_PUBLIC_ENV=main alone",
87
87
  );
88
88
  expect(proc.stderr.toString()).toContain(
89
- "To run develop, build for it: AKAN_PUBLIC_ENV=develop in the workspace's .env, which outranks the shell, or --env develop on a desktop or mobile build.",
89
+ "To run develop, build for it with `--env develop` (`akan build` or a desktop or mobile build), or AKAN_PUBLIC_ENV=develop in the shell or the workspace's .env.",
90
90
  );
91
91
  });
92
92
 
@@ -54,7 +54,7 @@ export const createServerEnvPlugin = ({
54
54
  build.onLoad({ filter }, async (args) => {
55
55
  const name = path.basename(args.path, ".ts").slice("env.server.".length);
56
56
  const { exports } = new Bun.Transpiler({ loader: "ts" }).scan(await Bun.file(args.path).text());
57
- const message = `env/env.server.${name}.ts is not in this build: \`akan build\` bundles the server env of AKAN_PUBLIC_ENV=${environment} alone, the environment its image runs. To run ${name}, build for it: AKAN_PUBLIC_ENV=${name} in the workspace's .env, which outranks the shell, or --env ${name} on a desktop or mobile build.`;
57
+ const message = `env/env.server.${name}.ts is not in this build: \`akan build\` bundles the server env of AKAN_PUBLIC_ENV=${environment} alone, the environment its image runs. To run ${name}, build for it with \`--env ${name}\` (\`akan build\` or a desktop or mobile build), or AKAN_PUBLIC_ENV=${name} in the shell or the workspace's .env.`;
58
58
  return {
59
59
  loader: "js",
60
60
  contents: [
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
  import { ApplicationBuildReporter } from "../applicationBuildReporter";
4
4
  import { tempDirs, writeText as write } from "../testHelpers";
5
5
  import { BarrelAnalyzer, type BarrelExportTarget } from "./barrelAnalyzer";
6
- import { rewriteBarrelImports } from "./barrelImportsPlugin";
6
+ import { createTsconfigPackageResolver, rewriteBarrelImports } from "./barrelImportsPlugin";
7
7
  import {
8
8
  hasUseClientDirective,
9
9
  scanUseClientExports,
@@ -140,7 +140,7 @@ describe('export * in a "use client" module', () => {
140
140
  });
141
141
 
142
142
  describe("BarrelAnalyzer and rewriteBarrelImports", () => {
143
- test("analyzes local, named, aliased, and star re-exports while skipping type and namespace exports", async () => {
143
+ test("analyzes local, named, aliased, star and namespace re-exports while skipping type exports", async () => {
144
144
  const root = await makeTempRoot();
145
145
  const pkgDir = path.join(root, "pkg");
146
146
  await write(
@@ -149,6 +149,7 @@ describe("BarrelAnalyzer and rewriteBarrelImports", () => {
149
149
  'export { A, B as Bee, type TypeOnly } from "./leaf";',
150
150
  'export * from "./star";',
151
151
  'export * as ns from "./namespace";',
152
+ 'export type * as typeNs from "./namespace";',
152
153
  "export const Local = 1;",
153
154
  "export type LocalType = string;",
154
155
  "",
@@ -171,7 +172,9 @@ describe("BarrelAnalyzer and rewriteBarrelImports", () => {
171
172
  expect(map?.get("Star")).toEqual({ subpath: "@scope/pkg/star", originalName: "Star" });
172
173
  expect(map?.get("Local")).toEqual({ subpath: "@scope/pkg", originalName: "Local" });
173
174
  expect(map?.has("TypeOnly")).toBe(false);
174
- expect(map?.has("ns")).toBe(false);
175
+ expect(map?.get("ns")).toEqual({ subpath: "@scope/pkg/namespace", originalName: "ns", namespace: true });
176
+ expect(map?.has("typeNs")).toBe(false);
177
+ expect(map?.has("Hidden")).toBe(false);
175
178
  });
176
179
 
177
180
  test("a name a module exports itself wins over the same name its star re-exports bring, as ES decides", async () => {
@@ -284,17 +287,66 @@ describe("BarrelAnalyzer and rewriteBarrelImports", () => {
284
287
  expect(await rewriteBarrelImports('import { A } from "@other/pkg";', ["@scope/pkg"], analyzer)).toBeNull();
285
288
  });
286
289
 
287
- test("preserves generated client barrel side effects when flattening app client imports", async () => {
288
- const analyzer = fakeAnalyzer([["st", { subpath: "@apps/demo/lib/st", originalName: "st" }]]);
290
+ test("keeps a client barrel's runtime and store, never its module UI, when flattening an app or lib import", async () => {
291
+ const root = await makeTempRoot();
292
+ for (const sys of ["apps/demo", "libs/kit"]) {
293
+ await write(
294
+ path.join(root, sys, "client.ts"),
295
+ [
296
+ 'export * as cnst from "./lib/cnst";',
297
+ 'export { msg, Err, usePage, fetch, sig } from "./lib/useClient";',
298
+ 'export { st, RootStore } from "./lib/st";',
299
+ 'export * as store from "./lib/st";',
300
+ 'export { Memo } from "./lib/memo";',
301
+ "",
302
+ ].join("\n"),
303
+ );
304
+ await write(path.join(root, sys, "lib/cnst.ts"), "export const Sample = 1;\n");
305
+ await write(path.join(root, sys, "lib/useClient.ts"), "export const { msg, Err, usePage, fetch, sig } = {};\n");
306
+ await write(path.join(root, sys, "lib/st.ts"), "export class RootStore {}\nexport const st = {};\n");
307
+ await write(path.join(root, sys, "lib/memo/index.ts"), "export const Memo = {};\n");
308
+ }
309
+ await write(path.join(root, "libs/hand/client.ts"), 'export { thing } from "./lib/thing";\n');
310
+ await write(path.join(root, "libs/hand/lib/thing.ts"), "export const thing = 1;\n");
311
+ const analyzer = new BarrelAnalyzer({
312
+ resolvePackage: await createTsconfigPackageResolver({
313
+ workspace: { workspaceRoot: root },
314
+ getTsConfig: async () => ({ compilerOptions: { paths: { "@apps/*": ["./apps/*"], "@libs/*": ["./libs/*"] } } }),
315
+ } as never),
316
+ });
317
+ const barrels = ["@apps/demo/client", "@libs/kit/client", "@libs/hand/client"];
318
+ const rewrite = async (source: string) => await rewriteBarrelImports(source, barrels, analyzer);
289
319
 
290
- const rewritten = await rewriteBarrelImports(
291
- 'import { st } from "@apps/demo/client";\nvoid st;\n',
292
- ["@apps/demo/client"],
293
- analyzer,
320
+ expect(await rewrite('import { Memo, st } from "@apps/demo/client";\n')).toBe(
321
+ [
322
+ 'import "@apps/demo/lib/useClient";',
323
+ 'import "@apps/demo/lib/st";',
324
+ 'import { Memo } from "@apps/demo/lib/memo";',
325
+ 'import { st } from "@apps/demo/lib/st";',
326
+ "",
327
+ ].join("\n"),
294
328
  );
295
-
296
- expect(rewritten).toBe(
297
- ['import "@apps/demo/client";', 'import { st } from "@apps/demo/lib/st";', "void st;\n"].join("\n"),
329
+ expect(await rewrite('import { cnst as c, st, store, type cnst as T } from "@apps/demo/client";\n')).toBe(
330
+ [
331
+ 'import "@apps/demo/lib/useClient";',
332
+ 'import "@apps/demo/lib/st";',
333
+ 'import { type cnst as T } from "@apps/demo/client";',
334
+ 'import { st } from "@apps/demo/lib/st";',
335
+ 'import * as c from "@apps/demo/lib/cnst";',
336
+ 'import * as store from "@apps/demo/lib/st";',
337
+ "",
338
+ ].join("\n"),
339
+ );
340
+ expect(await rewrite('import { RootStore as kit } from "@libs/kit/client";\n')).toBe(
341
+ [
342
+ 'import "@libs/kit/lib/useClient";',
343
+ 'import "@libs/kit/lib/st";',
344
+ 'import { RootStore as kit } from "@libs/kit/lib/st";',
345
+ "",
346
+ ].join("\n"),
347
+ );
348
+ expect(await rewrite('import { thing } from "@libs/hand/client";\n')).toBe(
349
+ ['import "@libs/hand/client";', 'import { thing } from "@libs/hand/lib/thing";', ""].join("\n"),
298
350
  );
299
351
  });
300
352
 
@@ -99,6 +99,18 @@ describe("createTsconfigPackageResolver — wildcard tsconfig mapping", () => {
99
99
  });
100
100
  });
101
101
 
102
+ test("resolves a specifier that names its file with an extension, as the generated root layout imports System", async () => {
103
+ const root = await makeTempRoot();
104
+ const entryFile = await write(root, "pkgs/akanjs/ui/System/index.tsx");
105
+ const resolve = await resolverFor(root, { "akanjs/ui/*": ["./pkgs/akanjs/ui/*"] });
106
+
107
+ expect(await resolve("akanjs/ui/System/index.tsx")).toEqual({
108
+ pkgName: "akanjs/ui/System",
109
+ entryFile,
110
+ pkgDir: path.join(root, "pkgs/akanjs/ui/System"),
111
+ });
112
+ });
113
+
102
114
  test("resolves a directory barrel and keeps the specifier", async () => {
103
115
  const root = await makeTempRoot();
104
116
  const entryFile = await write(root, "apps/minimal/index.tsx");
package/workflow/plan.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  import { capitalize } from "akanjs/common";
2
- import type { Workspace } from "../commandDecorators";
2
+ import type { Sys, Workspace } from "../commandDecorators";
3
3
  import { workflowPlanApproval } from "./artifacts";
4
4
  import { resolveWorkflowSys } from "./executor";
5
5
  import { InteractionScaffold } from "./interactionScaffold";
6
6
  import { KeyRename } from "./keyRename";
7
7
  import { MutationScaffold } from "./mutationScaffold";
8
- import { coerceFieldDefault, moduleSourcePaths } from "./source";
8
+ import { coerceFieldDefault, getSysRoot, moduleSourcePaths } from "./source";
9
9
  import type {
10
10
  WorkflowDiagnostic,
11
11
  WorkflowInputSpec,
@@ -479,25 +479,87 @@ const inspectRenameKeyPlan = async (workspace: Workspace, plan: WorkflowPlan): P
479
479
  };
480
480
  };
481
481
 
482
- export const inspectWorkflowPlan = async (workspace: Workspace, plan: WorkflowPlan): Promise<WorkflowPlan> => {
483
- if (plan.workflow === "rename-key") return await inspectRenameKeyPlan(workspace, plan);
484
- if (plan.workflow !== "add-mutation" && plan.workflow !== "create-interaction") return plan;
485
- const app = planString(plan.inputs, "app");
486
- if (!app) return plan;
482
+ const syssHolding = async (workspace: Workspace, module: string, except: Sys) => {
483
+ const [apps, libs] = await workspace.getSyss();
484
+ const others = await Promise.all(
485
+ [...apps, ...libs]
486
+ .filter((name) => name !== except.name)
487
+ .map(async (name) => await resolveWorkflowSys(workspace, name)),
488
+ );
489
+ const holding = await Promise.all(
490
+ others.map(async (other) => (other && (await other.readdir("lib")).includes(module) ? other : null)),
491
+ );
492
+ return holding.filter((other): other is Sys => other !== null);
493
+ };
494
+
495
+ const moduleMissing = async (workspace: Workspace, sys: Sys, module: string): Promise<WorkflowDiagnostic | null> => {
496
+ if ((await sys.readdir("lib")).includes(module)) return null;
497
+ const root = getSysRoot(sys);
498
+ const modules = [...(await sys.getDatabaseModules()), ...(await sys.getServiceModules())];
499
+ const sameName = modules.find((name) => name.replace(/^_/, "").toLowerCase() === module.toLowerCase());
500
+ const isScalar = (await sys.getScalarModules()).includes(module);
501
+ const [holder] = sameName || isScalar ? [] : await syssHolding(workspace, module, sys);
502
+ return {
503
+ severity: "error",
504
+ code: "workflow-module-missing",
505
+ input: "module",
506
+ message: [
507
+ `No module named ${module} in ${root}: ${root}/lib/${module} does not exist.`,
508
+ modules.length ? `Its modules are ${modules.join(", ")}.` : `${root} has no modules yet.`,
509
+ sameName
510
+ ? `Did you mean ${sameName}?`
511
+ : isScalar
512
+ ? `${root}/lib/__scalar/${module} is a scalar, and this workflow writes only into a module under ${root}/lib.`
513
+ : holder
514
+ ? `${module} is in ${getSysRoot(holder)}, so plan this with --app ${holder.name}.`
515
+ : `If ${module} is a new model, create it with the create-module workflow first, then plan this again.`,
516
+ ].join(" "),
517
+ };
518
+ };
519
+
520
+ export const workflowTargetDiagnostics = async (
521
+ workspace: Workspace,
522
+ spec: WorkflowSpec,
523
+ inputs: Record<string, WorkflowInputValue>,
524
+ ): Promise<WorkflowDiagnostic[]> => {
525
+ const app = planString(inputs, "app");
526
+ if (!app) return [];
487
527
  const sys = await resolveWorkflowSys(workspace, app).catch(() => null);
488
- if (!sys)
528
+ if (!sys) {
529
+ const [apps, libs] = await workspace.getSyss();
530
+ return [
531
+ {
532
+ severity: "error",
533
+ code: "workflow-target-missing",
534
+ input: "app",
535
+ message: `No app or library named ${app}. Apps: ${apps.join(", ") || "none"}. Libraries: ${libs.join(", ") || "none"}.`,
536
+ },
537
+ ];
538
+ }
539
+ const module = planString(inputs, "module");
540
+ const moduleSpec: WorkflowInputSpec | undefined = spec.inputs.module;
541
+ if (!module || !moduleSpec?.mustExist) return [];
542
+ const missing = await moduleMissing(workspace, sys, module);
543
+ return missing ? [missing] : [];
544
+ };
545
+
546
+ export const inspectWorkflowPlan = async (
547
+ workspace: Workspace,
548
+ spec: WorkflowSpec,
549
+ plan: WorkflowPlan,
550
+ ): Promise<WorkflowPlan> => {
551
+ const targetDiagnostics = await workflowTargetDiagnostics(workspace, spec, plan.inputs);
552
+ if (targetDiagnostics.length)
489
553
  return {
490
554
  ...plan,
491
- diagnostics: [
492
- ...plan.diagnostics,
493
- {
494
- severity: "error",
495
- code: "workflow-target-missing",
496
- input: "app",
497
- message: `No app or library named ${app}.`,
498
- },
499
- ],
555
+ predictedChanges: [],
556
+ diagnostics: [...plan.diagnostics, ...targetDiagnostics],
557
+ recommendations: [],
500
558
  };
559
+ if (plan.workflow === "rename-key") return await inspectRenameKeyPlan(workspace, plan);
560
+ if (plan.workflow !== "add-mutation" && plan.workflow !== "create-interaction") return plan;
561
+ const sys = await resolveWorkflowSys(workspace, planString(plan.inputs, "app")).catch(() => null);
562
+ if (!sys) return plan;
501
563
  const inspected =
502
564
  plan.workflow === "add-mutation"
503
565
  ? await MutationScaffold.build(
package/workflow/types.ts CHANGED
@@ -74,6 +74,7 @@ export interface WorkflowInputSpec {
74
74
  required?: boolean;
75
75
  description: string;
76
76
  allowedValues?: readonly string[];
77
+ mustExist?: boolean;
77
78
  }
78
79
 
79
80
  export interface WorkflowStep {
@@ -61,3 +61,61 @@ describe("WorkspaceGraph.changesVisibleTo", () => {
61
61
  expect(visible.since).toBe("0123456789ab");
62
62
  });
63
63
  });
64
+
65
+ describe("WorkspaceGraph.answer", () => {
66
+ it("answers a small graph whole, and drops untouched pages and their route-only guards from one past the limit", () => {
67
+ const fillers = Array.from({ length: 597 }, (_, idx) => node(`method:apps/demo:memo.m${idx}`, "apps/demo"));
68
+ const guard = { ...node("guard:libs/shared:signedIn", "libs/shared"), detail: ["route"] };
69
+ const graph = {
70
+ version: 1 as const,
71
+ nodes: [
72
+ node("layout:apps/demo:/", "apps/demo"),
73
+ node("route:apps/demo:/memo", "apps/demo"),
74
+ node("route:apps/demo:/peek", "apps/demo"),
75
+ node("endpoint:apps/demo:memo.pin", "apps/demo", "changed"),
76
+ guard,
77
+ ...fillers,
78
+ ],
79
+ edges: [
80
+ edge("layout:apps/demo:/", "route:apps/demo:/memo"),
81
+ edge("layout:apps/demo:/", "route:apps/demo:/peek"),
82
+ edge("layout:apps/demo:/", "guard:libs/shared:signedIn"),
83
+ edge("route:apps/demo:/peek", "endpoint:apps/demo:memo.pin"),
84
+ ],
85
+ };
86
+ const small = WorkspaceGraph.answer({ ...graph, nodes: graph.nodes.slice(0, 5) }, {});
87
+ if ("diagnostics" in small) throw new Error("expected an answer");
88
+ expect(small.nodes).toHaveLength(5);
89
+ const unscoped = WorkspaceGraph.answer(graph, {});
90
+ if ("diagnostics" in unscoped) throw new Error("expected an answer");
91
+ expect(unscoped.nodes.slice(0, 2).map(({ id }) => id)).toEqual([
92
+ "route:apps/demo:/peek",
93
+ "endpoint:apps/demo:memo.pin",
94
+ ]);
95
+ expect(unscoped.nodes).toHaveLength(599);
96
+ expect(unscoped.edges.map(({ from, to }) => `${from} -> ${to}`)).toEqual([
97
+ "route:apps/demo:/peek -> endpoint:apps/demo:memo.pin",
98
+ ]);
99
+ const scoped = WorkspaceGraph.answer(graph, { app: "demo" });
100
+ if ("diagnostics" in scoped) throw new Error("expected an answer");
101
+ expect(scoped.nodes).toHaveLength(602);
102
+ });
103
+ });
104
+
105
+ describe("WorkspaceGraph.scope", () => {
106
+ it("keeps a lib's guard without pulling in every app endpoint that uses it", () => {
107
+ const guard = "guard:libs/shared:Admin";
108
+ const own = "endpoint:libs/shared:file.touch";
109
+ const other = "endpoint:apps/demo:memo.pin";
110
+ const scoped = WorkspaceGraph.scope(
111
+ {
112
+ version: 1,
113
+ nodes: [node(guard, "libs/shared"), node(own, "libs/shared"), node(other, "apps/demo")],
114
+ edges: [edge(own, guard), edge(other, guard)],
115
+ },
116
+ { app: "shared" },
117
+ );
118
+ expect(scoped.nodes.map(({ id }) => id)).toEqual([guard, own]);
119
+ expect(scoped.edges).toEqual([edge(own, guard)]);
120
+ });
121
+ });