@akanjs/devkit 3.1.2-rc.4 → 3.2.0-rc.1

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 (89) hide show
  1. package/CHANGELOG.md +353 -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/createTunnel.test.ts +12 -2
  26. package/declarationReader.ts +211 -0
  27. package/executors.test.ts +65 -8
  28. package/executors.ts +5 -3
  29. package/frameworkGraph.test.ts +121 -0
  30. package/frameworkGraph.ts +165 -0
  31. package/frontendBuild/allRoutesBuilder.test.ts +84 -0
  32. package/frontendBuild/allRoutesBuilder.ts +32 -12
  33. package/frontendBuild/buildRouteClient.test.ts +192 -2
  34. package/frontendBuild/bundleDefine.ts +4 -0
  35. package/frontendBuild/clientBuildTypes.ts +7 -1
  36. package/frontendBuild/clientEntriesBundler.test.ts +82 -0
  37. package/frontendBuild/clientEntriesBundler.ts +55 -4
  38. package/frontendBuild/csrArtifactBuilder.test.ts +23 -0
  39. package/frontendBuild/csrArtifactBuilder.ts +2 -1
  40. package/frontendBuild/fontOptimizer.test.ts +54 -0
  41. package/frontendBuild/fontOptimizer.ts +5 -4
  42. package/frontendBuild/index.ts +3 -0
  43. package/frontendBuild/routeClientBuilder.ts +6 -2
  44. package/frontendBuild/routeModulePreloads.test.ts +59 -0
  45. package/frontendBuild/routeModulePreloads.ts +52 -0
  46. package/frontendBuild/ssrBaseArtifactBuilder.ts +35 -10
  47. package/frontendBuild/ssrDevBundler.test.ts +26 -0
  48. package/frontendBuild/ssrDevBundler.ts +2 -1
  49. package/frontendBuild/vendorImportScanner.test.ts +71 -0
  50. package/frontendBuild/vendorImportScanner.ts +80 -0
  51. package/frontendBuild/vendorNarrowing.test.ts +103 -0
  52. package/frontendBuild/vendorNarrowing.ts +64 -0
  53. package/frontendBuild/vendorSpecifiers.ts +13 -0
  54. package/gitRevision.ts +6 -2
  55. package/incrementalBuilder/builderGraphPayloads.ts +60 -0
  56. package/incrementalBuilder/incrementalBuilder.proc.test.ts +65 -1
  57. package/incrementalBuilder/incrementalBuilder.proc.ts +5 -36
  58. package/integration/devStability.integration.test.ts +1 -1
  59. package/integration/devStabilityHarness.test.ts +1 -1
  60. package/lint/no-deep-internal-import.grit +1 -1
  61. package/maintainabilityBaseline.ts +7 -2
  62. package/maintainabilityContract.test.ts +183 -0
  63. package/maintainabilityContract.ts +145 -30
  64. package/maintainabilityExceptionScanner.test.ts +13 -2
  65. package/maintainabilityExceptionScanner.ts +16 -6
  66. package/maintainabilityLimits.ts +12 -0
  67. package/maintainabilityReport.test.ts +35 -0
  68. package/maintainabilityReport.ts +29 -8
  69. package/mobile/nativePublicEnv.test.ts +5 -1
  70. package/package.json +3 -3
  71. package/preview/PreviewChrome.ts +3 -3
  72. package/preview/PreviewView.ts +26 -8
  73. package/preview/previewView.test.ts +87 -3
  74. package/routeJsBudget.test.ts +177 -0
  75. package/routeJsBudget.ts +222 -0
  76. package/structureScanner.test.ts +167 -0
  77. package/structureScanner.ts +164 -0
  78. package/transforms/barrelAnalyzer.ts +10 -3
  79. package/transforms/barrelImportsPlugin.ts +13 -7
  80. package/transforms/serverEnvPlugin.test.ts +1 -1
  81. package/transforms/serverEnvPlugin.ts +1 -1
  82. package/transforms/transforms.test.ts +64 -12
  83. package/transforms/tsconfigPackageResolver.test.ts +12 -0
  84. package/workflow/plan.ts +79 -17
  85. package/workflow/types.ts +1 -0
  86. package/workspaceGraph.test.ts +58 -0
  87. package/workspaceGraph.ts +32 -3
  88. package/workspaceLayout.ts +1 -0
  89. /package/integration/{devStabilityHarness.ts → devStabilityHarness.fixture.ts} +0 -0
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
+ });
package/workspaceGraph.ts CHANGED
@@ -14,6 +14,7 @@ export interface GraphScope {
14
14
  const indexedPath = /^(apps|libs)\/(?!(?:.*\/)?(?:node_modules|dist|\.akan|env)\/).*\.tsx?$/;
15
15
  const hiddenPath = /(?:^|\/)(?:node_modules|dist|\.akan|env)\//;
16
16
  const unscopedNodeLimit = 600;
17
+ const pageKinds = new Set<AkanGraphNode["kind"]>(["route", "layout"]);
17
18
  const cachedBaseLimit = 4;
18
19
  const slashed = (fileName: string) => fileName.replace(/\\/g, "/");
19
20
 
@@ -103,11 +104,36 @@ export class WorkspaceGraph {
103
104
  .filter((node) => (!owners || owners.has(node.owner ?? "")) && (!module || node.module === module))
104
105
  .map((node) => node.id),
105
106
  );
106
- const edges = graph.edges.filter((edge) => seeds.has(edge.from) || seeds.has(edge.to));
107
+ //? A lib's guard is linked from every app's endpoints; through it, a scope would reach all of them.
108
+ const guards = new Set(graph.nodes.filter((node) => node.kind === "guard").map((node) => node.id));
109
+ const reaches = (id: string) => seeds.has(id) && !guards.has(id);
110
+ const edges = graph.edges.filter((edge) => reaches(edge.from) || reaches(edge.to));
107
111
  const ids = new Set([...seeds, ...edges.flatMap((edge) => [edge.from, edge.to])]);
108
112
  return { version: 1, nodes: graph.nodes.filter((node) => ids.has(node.id)), edges };
109
113
  }
110
114
 
115
+ //? Pages are many and most only wrap one another, so a graph too large to answer whole drops the pages that neither
116
+ //? changed nor use a change first, and the route-only guards they leave unlinked.
117
+ static #withoutPages(graph: AkanGraph): AkanGraph {
118
+ const changed = new Set(graph.nodes.filter((node) => node.change).map((node) => node.id));
119
+ const touching = new Set(
120
+ graph.edges
121
+ .filter((edge) => edge.kind !== "wraps" && (changed.has(edge.from) || changed.has(edge.to)))
122
+ .flatMap((edge) => [edge.from, edge.to]),
123
+ );
124
+ const dropped = new Set(
125
+ graph.nodes
126
+ .filter((node) => pageKinds.has(node.kind) && !node.change && !touching.has(node.id))
127
+ .map((node) => node.id),
128
+ );
129
+ const edges = graph.edges.filter((edge) => !dropped.has(edge.from) && !dropped.has(edge.to));
130
+ const linked = new Set(edges.flatMap((edge) => [edge.from, edge.to]));
131
+ const isRouteOnly = (node: AkanGraphNode) =>
132
+ node.kind === "guard" && node.detail.includes("route") && !node.change && !linked.has(node.id);
133
+ const nodes = graph.nodes.filter((node) => !dropped.has(node.id) && !isRouteOnly(node));
134
+ return { version: 1, nodes, edges };
135
+ }
136
+
111
137
  static visibleTo(graph: AkanGraph, app: string): AkanGraph {
112
138
  const appOwner = `apps/${app}`;
113
139
  const isVisible = ({ owner }: AkanGraphNode) => !owner || owner === appOwner || owner.startsWith("libs/");
@@ -160,14 +186,17 @@ export class WorkspaceGraph {
160
186
 
161
187
  static answer(graph: AkanGraph, scope: GraphScope, counted = graph.nodes) {
162
188
  if (scope.app || scope.module || graph.nodes.length <= unscopedNodeLimit) return WorkspaceGraph.scope(graph, scope);
189
+ const unscoped = WorkspaceGraph.#withoutPages(graph);
190
+ if (unscoped.nodes.length <= unscopedNodeLimit) return unscoped;
163
191
  const owners = new Map<string, number>();
164
- for (const node of counted) if (node.owner) owners.set(node.owner, (owners.get(node.owner) ?? 0) + 1);
192
+ for (const node of counted)
193
+ if (node.owner && !pageKinds.has(node.kind)) owners.set(node.owner, (owners.get(node.owner) ?? 0) + 1);
165
194
  return {
166
195
  diagnostics: [
167
196
  {
168
197
  severity: "error",
169
198
  code: "graph-scope-required",
170
- message: `The graph has ${graph.nodes.length} nodes. Pass app, module or both to scope it.`,
199
+ message: `The graph has ${unscoped.nodes.length} nodes besides its pages. Pass app, module or both to scope it.`,
171
200
  input: "app",
172
201
  },
173
202
  ],
@@ -8,6 +8,7 @@ export const appRootAllowedFiles = new Set([
8
8
  "CLAUDE.md",
9
9
  "akan.app.json",
10
10
  "akan.baseline.json",
11
+ "akan.budget.json",
11
12
  "akan.config.ts",
12
13
  "client.ts",
13
14
  "main.ts",