@akanjs/devkit 3.2.0-rc.0 → 3.2.0-rc.10

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 (110) hide show
  1. package/CHANGELOG.md +238 -11
  2. package/agentAssets.ts +1 -1
  3. package/akanApp/akanApp.host.test.ts +127 -0
  4. package/akanApp/akanApp.host.ts +50 -57
  5. package/akanApp/devHostPolicy.test.ts +11 -11
  6. package/akanApp/devHostPolicy.ts +2 -4
  7. package/akanContext.ts +2 -5
  8. package/akanMcpContract.ts +21 -10
  9. package/akanMcpToolError.ts +16 -0
  10. package/apiReport/apiDeclarationPrinter.ts +120 -0
  11. package/apiReport/apiReport.test.ts +497 -0
  12. package/apiReport/apiReport.ts +232 -0
  13. package/apiReport/apiReportDiff.ts +126 -0
  14. package/apiReport/apiReportPolicy.ts +65 -0
  15. package/apiReport/apiTargets.ts +311 -0
  16. package/apiReport/changesetBump.ts +19 -0
  17. package/applicationBuildReporter.test.ts +21 -0
  18. package/applicationBuildReporter.ts +8 -0
  19. package/applicationBuildRunner.test.ts +67 -0
  20. package/applicationBuildRunner.ts +6 -1
  21. package/artifact/implicitRootLayout.test.ts +4 -0
  22. package/artifact/implicitRootLayout.ts +13 -1
  23. package/artifact/storeBootstrap.ts +3 -2
  24. package/bareErrorScanner.ts +126 -0
  25. package/biome.base.json +2 -1
  26. package/codeAgent/skills/akan-debug/SKILL.md +7 -3
  27. package/codeAgent/skills/akan-err/SKILL.md +7 -0
  28. package/codeAgent/skills/akan-mcp/SKILL.md +7 -1
  29. package/codeAgent/skills/akan-scaffold/SKILL.md +3 -2
  30. package/codeAgent/skills/akan-transport/SKILL.md +2 -0
  31. package/codeAgent/skills/akan-validate/SKILL.md +1 -0
  32. package/codeAgent/tools/McpClient.ts +28 -6
  33. package/codeAgent/tools/McpToolPack.ts +1 -1
  34. package/codeAgent/tools/akanCodePlugins.test.ts +13 -1
  35. package/commandDecorators/argMeta.ts +4 -0
  36. package/commandDecorators/cliError.ts +72 -0
  37. package/commandDecorators/command.ts +186 -96
  38. package/commandDecorators/commandDecorators.test.ts +240 -17
  39. package/commandDecorators/exitCodeError.ts +6 -7
  40. package/commandDecorators/index.ts +1 -0
  41. package/commandDecorators/targetMeta.ts +3 -0
  42. package/conventionSet.test.ts +5 -2
  43. package/createTunnel.test.ts +12 -2
  44. package/csrE2e/csrE2eHarness.fixture.ts +1 -1
  45. package/errorCodeReference.test.ts +282 -0
  46. package/errorCodeReference.ts +71 -0
  47. package/errorCodeScan.ts +551 -0
  48. package/frontendBuild/allRoutesBuilder.test.ts +16 -4
  49. package/frontendBuild/allRoutesBuilder.ts +24 -6
  50. package/frontendBuild/buildRouteClient.test.ts +43 -0
  51. package/frontendBuild/bundleDefine.ts +2 -0
  52. package/frontendBuild/clientBuildTypes.ts +5 -2
  53. package/frontendBuild/clientEntryDiscovery.test.ts +80 -0
  54. package/frontendBuild/clientEntryDiscovery.ts +53 -9
  55. package/frontendBuild/csrArtifactBuilder.test.ts +48 -0
  56. package/frontendBuild/csrArtifactBuilder.ts +9 -3
  57. package/frontendBuild/csrDevBundler.ts +12 -2
  58. package/frontendBuild/csrDevModuleCompiler.test.ts +97 -0
  59. package/frontendBuild/csrDevModuleCompiler.ts +37 -8
  60. package/frontendBuild/csrDevPatcher.test.ts +89 -2
  61. package/frontendBuild/csrDevPatcher.ts +47 -7
  62. package/frontendBuild/csrDevTypes.ts +16 -0
  63. package/frontendBuild/cssCompiler.test.ts +63 -1
  64. package/frontendBuild/devBarrelImports.ts +132 -0
  65. package/frontendBuild/devRegistryBundler.ts +19 -4
  66. package/frontendBuild/mcpAppViewBuilder.test.ts +67 -0
  67. package/frontendBuild/nativeCore.test.ts +148 -0
  68. package/frontendBuild/precompressArtifacts.ts +1 -1
  69. package/frontendBuild/ssrBaseArtifactBuilder.test.ts +26 -0
  70. package/frontendBuild/ssrBaseArtifactBuilder.ts +23 -19
  71. package/frontendBuild/ssrDevBundler.ts +13 -2
  72. package/frontendBuild/vendorImportScanner.test.ts +24 -3
  73. package/frontendBuild/vendorImportScanner.ts +25 -10
  74. package/incrementalBuilder/incrementalBuilder.host.test.ts +30 -1
  75. package/incrementalBuilder/incrementalBuilder.host.ts +10 -22
  76. package/lint/__fixtures__/no-import-common-err/bad.tsx +13 -0
  77. package/lint/__fixtures__/no-import-common-err/good.tsx +12 -0
  78. package/lint/no-import-common-err.grit +31 -0
  79. package/lint/no-init-fetch-in-client.grit +1 -1
  80. package/lint/no-web-only-api-outside-webkit.grit +3 -3
  81. package/maintainabilityBaseline.test.ts +49 -0
  82. package/maintainabilityBaseline.ts +73 -8
  83. package/maintainabilityContract.test.ts +302 -1
  84. package/maintainabilityContract.ts +87 -22
  85. package/maintainabilityExceptionScanner.test.ts +14 -0
  86. package/maintainabilityExceptionScanner.ts +5 -1
  87. package/maintainabilityLimits.ts +12 -0
  88. package/maintainabilityReport.ts +20 -3
  89. package/moduleFieldNames.test.ts +56 -0
  90. package/moduleFieldNames.ts +25 -0
  91. package/package.json +3 -3
  92. package/preview/PreviewChrome.ts +3 -3
  93. package/preview/PreviewView.ts +48 -9
  94. package/preview/previewView.test.ts +154 -3
  95. package/routeJsBudget.test.ts +47 -11
  96. package/routeJsBudget.ts +88 -25
  97. package/transforms/barrelAnalyzer.ts +46 -1
  98. package/transforms/barrelImportsPlugin.ts +61 -45
  99. package/transforms/transforms.test.ts +67 -1
  100. package/typecheck/projectTypecheck.ts +5 -1
  101. package/typecheck/typecheck.proc.ts +3 -2
  102. package/typecheck/typecheckFailure.ts +6 -1
  103. package/workflow/artifacts.ts +25 -7
  104. package/workflow/fieldType.ts +92 -0
  105. package/workflow/index.ts +2 -0
  106. package/workflow/plainName.ts +28 -0
  107. package/workflow/plan.ts +67 -22
  108. package/workflow/source.ts +0 -5
  109. package/workspaceGraph.test.ts +200 -3
  110. package/workspaceGraph.ts +46 -8
package/workflow/plan.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  import { capitalize } from "akanjs/common";
2
2
  import type { Sys, Workspace } from "../commandDecorators";
3
- import { workflowPlanApproval } from "./artifacts";
3
+ import { withoutApplyHints, workflowPlanApproval } from "./artifacts";
4
4
  import { resolveWorkflowSys } from "./executor";
5
+ import { fieldTypeSource } from "./fieldType";
5
6
  import { InteractionScaffold } from "./interactionScaffold";
6
7
  import { KeyRename } from "./keyRename";
7
8
  import { MutationScaffold } from "./mutationScaffold";
8
- import { coerceFieldDefault, getSysRoot, moduleSourcePaths } from "./source";
9
+ import { identifierPattern, sysNamePattern } from "./plainName";
10
+ import { coerceFieldDefault, getSysRoot, moduleSourcePaths, normalizeFieldType } from "./source";
9
11
  import type {
10
12
  WorkflowDiagnostic,
11
13
  WorkflowInputSpec,
@@ -17,11 +19,10 @@ import type {
17
19
  WorkflowSurfaceMode,
18
20
  } from "./types";
19
21
  import { addFieldUiPolicyForType } from "./uiPolicy";
22
+ import { workflowStatus } from "./utils";
20
23
 
21
24
  const surfaceModes = new Set<WorkflowSurfaceMode>(["infer", "include", "skip"]);
22
25
 
23
- const sysNamePattern = /^[a-z0-9][a-zA-Z0-9_-]*$/;
24
- const identifierPattern = /^_{0,2}[a-zA-Z][a-zA-Z0-9_-]*$/;
25
26
  //? These inputs become path segments, identifiers and command arguments, so none may carry a separator or a dot.
26
27
  const nameInputPatterns: Record<string, RegExp> = {
27
28
  app: sysNamePattern,
@@ -35,11 +36,6 @@ const nameInputPatterns: Record<string, RegExp> = {
35
36
  interaction: identifierPattern,
36
37
  };
37
38
 
38
- export const isWorkflowSysName = (value: unknown): value is string =>
39
- typeof value === "string" && sysNamePattern.test(value);
40
- export const isWorkflowModuleName = (value: unknown): value is string =>
41
- typeof value === "string" && identifierPattern.test(value);
42
-
43
39
  const parseStringList = (value: unknown) => {
44
40
  if (Array.isArray(value)) {
45
41
  const values = value.filter((item): item is string => typeof item === "string" && item.trim().length > 0);
@@ -487,7 +483,7 @@ const syssHolding = async (workspace: Workspace, module: string, except: Sys) =>
487
483
  .map(async (name) => await resolveWorkflowSys(workspace, name)),
488
484
  );
489
485
  const holding = await Promise.all(
490
- others.map(async (other) => (other && (await other.readdir("lib")).includes(module) ? other : null)),
486
+ others.map(async (other) => (other && (await other.getDatabaseModules()).includes(module) ? other : null)),
491
487
  );
492
488
  return holding.filter((other): other is Sys => other !== null);
493
489
  };
@@ -495,28 +491,64 @@ const syssHolding = async (workspace: Workspace, module: string, except: Sys) =>
495
491
  const moduleMissing = async (workspace: Workspace, sys: Sys, module: string): Promise<WorkflowDiagnostic | null> => {
496
492
  if ((await sys.readdir("lib")).includes(module)) return null;
497
493
  const root = getSysRoot(sys);
498
- const modules = [...(await sys.getDatabaseModules()), ...(await sys.getServiceModules())];
499
- const sameName = modules.find((name) => name.replace(/^_/, "").toLowerCase() === module.toLowerCase());
494
+ const modules = await sys.getDatabaseModules();
495
+ const sameName = modules.find((name) => name.toLowerCase() === module.toLowerCase());
496
+ const service = (await sys.getServiceModules()).find(
497
+ (name) => name.replace(/^_/, "").toLowerCase() === module.replace(/^_/, "").toLowerCase(),
498
+ );
500
499
  const isScalar = (await sys.getScalarModules()).includes(module);
501
- const [holder] = sameName || isScalar ? [] : await syssHolding(workspace, module, sys);
500
+ const [holder] = sameName || service || isScalar ? [] : await syssHolding(workspace, module, sys);
502
501
  return {
503
502
  severity: "error",
504
503
  code: "workflow-module-missing",
505
504
  input: "module",
506
505
  message: [
507
506
  `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.`,
507
+ modules.length ? `Its database modules are ${modules.join(", ")}.` : `${root} has no database modules yet.`,
509
508
  sameName
510
509
  ? `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.`,
510
+ : service
511
+ ? `${root}/lib/${service} is a service module, which stores no model, and this workflow writes only into a database module.`
512
+ : isScalar
513
+ ? `${root}/lib/__scalar/${module} is a scalar, and this workflow writes only into a module under ${root}/lib.`
514
+ : holder
515
+ ? `${module} is in ${getSysRoot(holder)}, so plan this with --app ${holder.name}.`
516
+ : `If ${module} is a new model, create it with the create-module workflow first, then plan this again.`,
517
+ ].join(" "),
518
+ };
519
+ };
520
+
521
+ const moduleNotDatabase = async (sys: Sys, spec: WorkflowSpec, module: string): Promise<WorkflowDiagnostic | null> => {
522
+ const databaseModules = await sys.getDatabaseModules();
523
+ if (databaseModules.includes(module)) return null;
524
+ const root = getSysRoot(sys);
525
+ const isServiceModule = module.startsWith("_") && !module.startsWith("__");
526
+ return {
527
+ severity: "error",
528
+ code: "workflow-module-not-database",
529
+ input: "module",
530
+ message: [
531
+ isServiceModule
532
+ ? `${module} in ${root} is a service module, which stores no model.`
533
+ : `${root}/lib/${module} is not a database module: it has no ${module}.constant.ts.`,
534
+ `${spec.name} needs a database module, one with lib/<model>/<model>.constant.ts.`,
535
+ databaseModules.length
536
+ ? `Its database modules are ${databaseModules.join(", ")}.`
537
+ : `${root} has no database modules yet.`,
538
+ spec.inputs.field
539
+ ? "Add the field to one of them, or create its model with the create-module workflow first; a value several models embed is a scalar, made with create-scalar and added to a model as a field of that type."
540
+ : "Plan this on one of them, or create the model with the create-module workflow first.",
516
541
  ].join(" "),
517
542
  };
518
543
  };
519
544
 
545
+ const fieldTypeUnknown = async (sys: Sys, spec: WorkflowSpec, module: string, type: string | null) => {
546
+ if (spec.name !== "add-field" || !type || ["enum", "number", "numeric", "upload"].includes(type.toLowerCase()))
547
+ return null;
548
+ const constant = await sys.readFile(moduleSourcePaths(module).constant);
549
+ return (await fieldTypeSource(sys, module, normalizeFieldType(type), constant)).diagnostic ?? null;
550
+ };
551
+
520
552
  export const workflowTargetDiagnostics = async (
521
553
  workspace: Workspace,
522
554
  spec: WorkflowSpec,
@@ -539,11 +571,14 @@ export const workflowTargetDiagnostics = async (
539
571
  const module = planString(inputs, "module");
540
572
  const moduleSpec: WorkflowInputSpec | undefined = spec.inputs.module;
541
573
  if (!module || !moduleSpec?.mustExist) return [];
542
- const missing = await moduleMissing(workspace, sys, module);
543
- return missing ? [missing] : [];
574
+ const refused =
575
+ (await moduleMissing(workspace, sys, module)) ??
576
+ (await moduleNotDatabase(sys, spec, module)) ??
577
+ (await fieldTypeUnknown(sys, spec, module, planString(inputs, "type")));
578
+ return refused ? [refused] : [];
544
579
  };
545
580
 
546
- export const inspectWorkflowPlan = async (
581
+ const inspectPlanTarget = async (
547
582
  workspace: Workspace,
548
583
  spec: WorkflowSpec,
549
584
  plan: WorkflowPlan,
@@ -586,3 +621,13 @@ export const inspectWorkflowPlan = async (
586
621
  recommendations: [...plan.recommendations, ...inspected.recommendations],
587
622
  };
588
623
  };
624
+
625
+ export const inspectWorkflowPlan = async (
626
+ workspace: Workspace,
627
+ spec: WorkflowSpec,
628
+ plan: WorkflowPlan,
629
+ ): Promise<WorkflowPlan> => {
630
+ const inspected = await inspectPlanTarget(workspace, spec, plan);
631
+ if (workflowStatus(inspected.diagnostics) === "passed") return inspected;
632
+ return { ...inspected, recommendations: withoutApplyHints(inspected.recommendations) };
633
+ };
@@ -238,11 +238,6 @@ export const ensureImportDeclaration = (content: string, moduleSpecifier: string
238
238
 
239
239
  export const ensureBaseImport = (content: string, name: string) => ensureNamedImport(content, "akanjs/base", name);
240
240
 
241
- export const ensureBaseTypeImport = (content: string, typeName: string) => {
242
- if (typeName !== "Int" && typeName !== "Float") return content;
243
- return ensureBaseImport(content, typeName);
244
- };
245
-
246
241
  export type FieldDefaultValue = string | number | boolean | null;
247
242
 
248
243
  const numericDefault = (typeName: "Int" | "Float", rawDefault: FieldDefaultValue): string | null => {
@@ -1,7 +1,17 @@
1
- import { describe, expect, it } from "bun:test";
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
2
5
  import type { AkanChangeMap, AkanGraphEdge, AkanGraphNode } from "@akanjs/language";
6
+ import { AkanGraphDiff } from "@akanjs/language";
7
+ import type { Workspace } from "./commandDecorators";
3
8
  import { WorkspaceGraph } from "./workspaceGraph";
4
9
 
10
+ const roots: string[] = [];
11
+ afterEach(async () => {
12
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
13
+ });
14
+
5
15
  const node = (id: string, owner: string | null, change?: AkanGraphNode["change"]): AkanGraphNode => ({
6
16
  id,
7
17
  kind: id.split(":")[0] as AkanGraphNode["kind"],
@@ -13,10 +23,15 @@ const node = (id: string, owner: string | null, change?: AkanGraphNode["change"]
13
23
  at: null,
14
24
  ...(change ? { change } : {}),
15
25
  });
16
- const edge = (from: string, to: string, change?: AkanGraphEdge["change"]): AkanGraphEdge => ({
26
+ const edge = (
27
+ from: string,
28
+ to: string,
29
+ change?: AkanGraphEdge["change"],
30
+ kind: AkanGraphEdge["kind"] = "calls",
31
+ ): AkanGraphEdge => ({
17
32
  from,
18
33
  to,
19
- kind: "calls",
34
+ kind,
20
35
  label: null,
21
36
  count: 1,
22
37
  at: null,
@@ -102,6 +117,60 @@ describe("WorkspaceGraph.answer", () => {
102
117
  });
103
118
  });
104
119
 
120
+ describe("WorkspaceGraph.answer past the node limit", () => {
121
+ const changeMapOf = (nodes: AkanGraphNode[], edges: AkanGraphEdge[]): AkanChangeMap => ({
122
+ version: 1,
123
+ nodes,
124
+ edges,
125
+ since: "0123456789ab",
126
+ summary: { added: 0, removed: 0, changed: nodes.filter((item) => item.change).length },
127
+ unmapped: [],
128
+ });
129
+
130
+ it("drops the layout that only wraps a changed route, and keeps the route", () => {
131
+ const fillers = Array.from({ length: 599 }, (_, idx) => node(`method:apps/demo:memo.m${idx}`, "apps/demo"));
132
+ const answer = WorkspaceGraph.answer(
133
+ {
134
+ version: 1,
135
+ nodes: [
136
+ node("layout:apps/demo:/", "apps/demo"),
137
+ node("route:apps/demo:/memo", "apps/demo", "changed"),
138
+ ...fillers,
139
+ ],
140
+ edges: [edge("layout:apps/demo:/", "route:apps/demo:/memo", undefined, "wraps")],
141
+ },
142
+ {},
143
+ );
144
+ if ("diagnostics" in answer) throw new Error("expected an answer");
145
+ expect(answer.nodes.filter((item) => item.kind !== "method").map(({ id }) => id)).toEqual([
146
+ "route:apps/demo:/memo",
147
+ ]);
148
+ expect(answer.edges).toEqual([]);
149
+ });
150
+
151
+ it("answers a Change Map whole once its unchanged pages are set aside", () => {
152
+ const routes = Array.from({ length: 10 }, (_, idx) => node(`route:apps/demo:/r${idx}`, "apps/demo"));
153
+ const changed = Array.from({ length: 595 }, (_, idx) =>
154
+ node(`method:apps/demo:memo.m${idx}`, "apps/demo", "changed"),
155
+ );
156
+ const answer = WorkspaceGraph.answerChanges(changeMapOf([...routes, ...changed], []), {});
157
+ if ("diagnostics" in answer) throw new Error("expected an answer");
158
+ expect(answer.nodes).toHaveLength(595);
159
+ expect(answer.summary).toEqual({ added: 0, removed: 0, changed: 595 });
160
+ });
161
+
162
+ it("counts the changed pages it keeps when it names the owners to scope to", () => {
163
+ const routes = Array.from({ length: 650 }, (_, idx) => node(`route:apps/demo:/r${idx}`, "apps/demo", "changed"));
164
+ const answer = WorkspaceGraph.answerChanges(
165
+ changeMapOf([...routes, node("method:libs/shared:file.touch", "libs/shared", "changed")], []),
166
+ {},
167
+ );
168
+ if (!("owners" in answer)) throw new Error("expected a scope request");
169
+ expect(answer.diagnostics.map(({ message }) => message).join("\n")).toContain("651 nodes");
170
+ expect(answer.owners).toEqual({ "apps/demo": 650, "libs/shared": 1 });
171
+ });
172
+ });
173
+
105
174
  describe("WorkspaceGraph.scope", () => {
106
175
  it("keeps a lib's guard without pulling in every app endpoint that uses it", () => {
107
176
  const guard = "guard:libs/shared:Admin";
@@ -118,4 +187,132 @@ describe("WorkspaceGraph.scope", () => {
118
187
  expect(scoped.nodes.map(({ id }) => id)).toEqual([guard, own]);
119
188
  expect(scoped.edges).toEqual([edge(own, guard)]);
120
189
  });
190
+
191
+ it("follows a role the scope's method calls to the implementation its app binds", () => {
192
+ const role = "adaptor::LlmAdaptorRole";
193
+ const binds = (to: string, label: string): AkanGraphEdge => ({ ...edge(role, to), kind: "binds", label });
194
+ const graph = {
195
+ version: 1 as const,
196
+ nodes: [
197
+ { ...node("method:apps/sceny:plan.draft", "apps/sceny"), module: "plan" },
198
+ { ...node("method:apps/angelo:chat.reply", "apps/angelo"), module: "chat" },
199
+ { ...node("method:libs/genai:prompt.run", "libs/genai"), module: "prompt" },
200
+ node(role, null),
201
+ node("adaptor::AnthropicLlm", null),
202
+ node("adaptor:apps/angelo:ChatCompletionsLlm", "apps/angelo"),
203
+ node("adaptor:libs/genai:GenaiLlm", "libs/genai"),
204
+ ],
205
+ edges: [
206
+ edge("method:apps/sceny:plan.draft", role),
207
+ edge("method:apps/angelo:chat.reply", role),
208
+ edge("method:libs/genai:prompt.run", role),
209
+ binds("adaptor::AnthropicLlm", "apps/sceny"),
210
+ binds("adaptor:apps/angelo:ChatCompletionsLlm", "apps/angelo"),
211
+ binds("adaptor:libs/genai:GenaiLlm", "libs/genai"),
212
+ ],
213
+ };
214
+ const bound = (scope: { app?: string; module?: string }) =>
215
+ WorkspaceGraph.scope(graph, scope)
216
+ .edges.filter(({ kind }) => kind === "binds")
217
+ .map(({ to, label }) => `${label} ${to}`);
218
+ expect(bound({ app: "sceny" })).toEqual(["apps/sceny adaptor::AnthropicLlm"]);
219
+ expect(WorkspaceGraph.scope(graph, { app: "sceny" }).nodes.map(({ id }) => id)).toContain("adaptor::AnthropicLlm");
220
+ expect(bound({ app: "angelo" })).toEqual(["apps/angelo adaptor:apps/angelo:ChatCompletionsLlm"]);
221
+ expect(bound({ app: "genai" })).toEqual([
222
+ "apps/sceny adaptor::AnthropicLlm",
223
+ "apps/angelo adaptor:apps/angelo:ChatCompletionsLlm",
224
+ "libs/genai adaptor:libs/genai:GenaiLlm",
225
+ ]);
226
+ expect(bound({ module: "plan" })).toHaveLength(3);
227
+ expect(
228
+ WorkspaceGraph.visibleTo(graph, "sceny")
229
+ .edges.filter(({ kind }) => kind === "binds")
230
+ .map(({ label }) => label),
231
+ ).toEqual(["apps/sceny"]);
232
+ });
233
+
234
+ it("scopes a Change Map to the binding the app runs, whether or not that binding changed", () => {
235
+ const role = "adaptor::LlmAdaptorRole";
236
+ const versioned = (id: string, owner: string | null, digest: string, module: string | null = null) => ({
237
+ ...node(id, owner),
238
+ module,
239
+ digest,
240
+ at: { file: `${owner ?? "akanjs"}/x.ts`, line: 1, column: 1 },
241
+ });
242
+ const binds = (to: string, label: string): AkanGraphEdge => ({ ...edge(role, to), kind: "binds", label });
243
+ const graphOf = (ownBinding: boolean, method: string, lib: string) => ({
244
+ version: 1 as const,
245
+ nodes: [
246
+ versioned("method:apps/sceny:plan.draft", "apps/sceny", method, "plan"),
247
+ node(role, null),
248
+ node("adaptor::AnthropicLlm", null),
249
+ versioned("adaptor:libs/genai:GenaiLlm", "libs/genai", lib),
250
+ ],
251
+ edges: [
252
+ edge("method:apps/sceny:plan.draft", role),
253
+ ...(ownBinding ? [binds("adaptor::AnthropicLlm", "apps/sceny")] : []),
254
+ binds("adaptor:libs/genai:GenaiLlm", "libs/genai"),
255
+ ],
256
+ });
257
+ const scopedBinds = (base: ReturnType<typeof graphOf>, head: ReturnType<typeof graphOf>) => {
258
+ const changes = { ...AkanGraphDiff.between(base, head), since: "0123456789ab", unmapped: [] };
259
+ const answer = WorkspaceGraph.answerChanges(changes, { app: "sceny" });
260
+ if (!("edges" in answer)) throw new Error("expected an answer");
261
+ return answer.edges
262
+ .filter(({ kind }) => kind === "binds")
263
+ .map(({ label, to, change }) => `${change ?? "unchanged"} ${label} ${to}`);
264
+ };
265
+ expect(scopedBinds(graphOf(true, "1", "1"), graphOf(true, "2", "2"))).toEqual([
266
+ "unchanged apps/sceny adaptor::AnthropicLlm",
267
+ ]);
268
+ expect(scopedBinds(graphOf(true, "1", "1"), graphOf(false, "1", "2"))).toEqual([
269
+ "unchanged libs/genai adaptor:libs/genai:GenaiLlm",
270
+ "removed apps/sceny adaptor::AnthropicLlm",
271
+ ]);
272
+ });
273
+ });
274
+
275
+ describe("WorkspaceGraph.changesAt", () => {
276
+ it("maps an edit to a factory in one lib onto the guards it makes in another, and lists no file as unmapped", async () => {
277
+ const root = await mkdtemp(path.join(tmpdir(), "akan-graph-"));
278
+ roots.push(root);
279
+ const git = (...args: string[]) => {
280
+ const result = Bun.spawnSync(
281
+ ["git", "-c", "user.name=akan", "-c", "user.email=akan@example.com", "-c", "commit.gpgsign=false", ...args],
282
+ { cwd: root, env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" } },
283
+ );
284
+ expect(result.exitCode).toBe(0);
285
+ return result.stdout.toString().trim();
286
+ };
287
+ const write = async (file: string, text: string) => {
288
+ await mkdir(path.dirname(path.join(root, file)), { recursive: true });
289
+ await writeFile(path.join(root, file), text);
290
+ };
291
+ const factory = (body: string) => `import type { Guard } from "akanjs/signal";
292
+ export const resourceGuard = (field: string) =>
293
+ class implements Guard {
294
+ canPass() {
295
+ return ${body};
296
+ }
297
+ };
298
+ `;
299
+ git("init", "-q", "-b", "main");
300
+ await write("libs/util/srvkit/resource.ts", factory("!!field"));
301
+ await write(
302
+ "apps/demo/srvkit/guards.ts",
303
+ `import { resourceGuard } from "@libs/util/srvkit";\nexport const Member = resourceGuard("member");\n`,
304
+ );
305
+ git("add", "-A");
306
+ git("commit", "-q", "--no-verify", "-m", "base");
307
+ await write("libs/util/srvkit/resource.ts", factory("field.length > 0"));
308
+ const changes = await WorkspaceGraph.changesAt(
309
+ { workspaceRoot: root } as unknown as Workspace,
310
+ git("rev-parse", "HEAD"),
311
+ );
312
+ expect(changes.nodes.filter((node) => node.change).map(({ id, change }) => `${change} ${id}`)).toEqual([
313
+ "changed guard:apps/demo:Member",
314
+ ]);
315
+ expect(changes.unmapped).toEqual([]);
316
+ expect(changes.nodes.some((node) => "sources" in node)).toBe(false);
317
+ });
121
318
  });
package/workspaceGraph.ts CHANGED
@@ -1,6 +1,13 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { type AkanChangeMap, type AkanGraph, AkanGraphDiff, type AkanGraphNode, AkanIndex } from "@akanjs/language";
3
+ import {
4
+ type AkanChangeMap,
5
+ type AkanGraph,
6
+ AkanGraphDiff,
7
+ type AkanGraphEdge,
8
+ type AkanGraphNode,
9
+ AkanIndex,
10
+ } from "@akanjs/language";
4
11
  import ts from "typescript";
5
12
  import type { Workspace } from "./commandDecorators";
6
13
  import { GitRevision } from "./gitRevision";
@@ -64,12 +71,14 @@ export class WorkspaceGraph {
64
71
  .filter((node) => node.change)
65
72
  .flatMap((node) => [
66
73
  node.at?.file,
74
+ ...(node.sources ?? []),
67
75
  ...(node.fields ?? []).filter((field) => field.change).map(({ at }) => at.file),
68
76
  ]),
69
77
  ...changes.edges.filter((edge) => edge.change).map((edge) => edge.at?.file),
70
78
  ]);
71
79
  const unmapped = files.filter(({ file }) => !mapped.has(file) && !hiddenPath.test(file));
72
- return { ...changes, since: sha.slice(0, 12), unmapped };
80
+ const nodes = changes.nodes.map(({ sources: _sources, ...node }) => node);
81
+ return { ...changes, nodes, since: sha.slice(0, 12), unmapped };
73
82
  }
74
83
 
75
84
  static #baseAt(root: string, sha: string) {
@@ -107,9 +116,30 @@ export class WorkspaceGraph {
107
116
  //? A lib's guard is linked from every app's endpoints; through it, a scope would reach all of them.
108
117
  const guards = new Set(graph.nodes.filter((node) => node.kind === "guard").map((node) => node.id));
109
118
  const reaches = (id: string) => seeds.has(id) && !guards.has(id);
110
- const edges = graph.edges.filter((edge) => reaches(edge.from) || reaches(edge.to));
111
- const ids = new Set([...seeds, ...edges.flatMap((edge) => [edge.from, edge.to])]);
112
- return { version: 1, nodes: graph.nodes.filter((node) => ids.has(node.id)), edges };
119
+ const edges = graph.edges.filter((edge) => edge.kind !== "binds" && (reaches(edge.from) || reaches(edge.to)));
120
+ const kept = new Set([...seeds, ...edges.flatMap((edge) => [edge.from, edge.to])]);
121
+ const isLib =
122
+ !!app &&
123
+ graph.nodes.some((node) => node.owner === `libs/${app}`) &&
124
+ !graph.nodes.some((node) => node.owner === `apps/${app}`);
125
+ const applicable = WorkspaceGraph.#bindingsOf(graph, app && !isLib ? `apps/${app}` : null);
126
+ const isOwn = (edge: AkanGraphEdge) => !module && !!owners?.has(edge.label ?? "");
127
+ const roles = new Set(
128
+ applicable.filter((edge) => isOwn(edge) || kept.has(edge.from) || reaches(edge.to)).map((edge) => edge.from),
129
+ );
130
+ const bindings = applicable.filter((edge) => roles.has(edge.from));
131
+ const ids = new Set([...kept, ...bindings.flatMap((edge) => [edge.from, edge.to])]);
132
+ return { version: 1, nodes: graph.nodes.filter((node) => ids.has(node.id)), edges: [...edges, ...bindings] };
133
+ }
134
+
135
+ //? An app runs a role as its own `applyAdaptor` binds it, else as a lib's does; a lib's code runs under every app's.
136
+ static #bindingsOf(graph: AkanGraph, appOwner: string | null) {
137
+ const binds = graph.edges.filter((edge) => edge.kind === "binds");
138
+ if (!appOwner) return binds;
139
+ const own = new Set(
140
+ binds.filter((edge) => edge.label === appOwner && edge.change !== "removed").map((edge) => edge.from),
141
+ );
142
+ return binds.filter((edge) => edge.label === appOwner || (!edge.label?.startsWith("apps/") && !own.has(edge.from)));
113
143
  }
114
144
 
115
145
  //? Pages are many and most only wrap one another, so a graph too large to answer whole drops the pages that neither
@@ -139,7 +169,14 @@ export class WorkspaceGraph {
139
169
  const isVisible = ({ owner }: AkanGraphNode) => !owner || owner === appOwner || owner.startsWith("libs/");
140
170
  const nodes = graph.nodes.filter(isVisible);
141
171
  const ids = new Set(nodes.map((node) => node.id));
142
- return { version: 1, nodes, edges: graph.edges.filter((edge) => ids.has(edge.from) && ids.has(edge.to)) };
172
+ const bindings = new Set(WorkspaceGraph.#bindingsOf(graph, appOwner));
173
+ return {
174
+ version: 1,
175
+ nodes,
176
+ edges: graph.edges.filter(
177
+ (edge) => ids.has(edge.from) && ids.has(edge.to) && (edge.kind !== "binds" || bindings.has(edge)),
178
+ ),
179
+ };
143
180
  }
144
181
 
145
182
  static changesVisibleTo(changes: AkanChangeMap, app: string): AkanChangeMap {
@@ -188,15 +225,16 @@ export class WorkspaceGraph {
188
225
  if (scope.app || scope.module || graph.nodes.length <= unscopedNodeLimit) return WorkspaceGraph.scope(graph, scope);
189
226
  const unscoped = WorkspaceGraph.#withoutPages(graph);
190
227
  if (unscoped.nodes.length <= unscopedNodeLimit) return unscoped;
228
+ const kept = new Set(unscoped.nodes.map((node) => node.id));
191
229
  const owners = new Map<string, number>();
192
230
  for (const node of counted)
193
- if (node.owner && !pageKinds.has(node.kind)) owners.set(node.owner, (owners.get(node.owner) ?? 0) + 1);
231
+ if (node.owner && kept.has(node.id)) owners.set(node.owner, (owners.get(node.owner) ?? 0) + 1);
194
232
  return {
195
233
  diagnostics: [
196
234
  {
197
235
  severity: "error",
198
236
  code: "graph-scope-required",
199
- message: `The graph has ${unscoped.nodes.length} nodes besides its pages. Pass app, module or both to scope it.`,
237
+ message: `The graph has ${unscoped.nodes.length} nodes once unchanged pages are set aside. Pass app, module or both to scope it.`,
200
238
  input: "app",
201
239
  },
202
240
  ],