@akanjs/devkit 3.2.0-rc.2 → 3.2.0-rc.3

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.
package/CHANGELOG.md CHANGED
@@ -34,6 +34,17 @@
34
34
  - **`pkgs/akanjs/ARCHITECTURE.md`** pairs a hand-kept walk through the processes and a request with a generated part between markers. `akan sync-architecture` regenerates that part, and a devkit test fails when it is stale, when a channel is gone, when two facets load values from each other, when a message lacks a recognised sender or handler outside a short list whose other end lies beyond the framework source, or when the hand-kept part names a class the source no longer has.
35
35
  - **`AkanIpcMessage` no longer lists `backend-ready`.** The gateway sends it to the dev host, which reads it as a `BuilderMessage`, where it stays; no gateway or child ever handled it.
36
36
 
37
+ - 4d61170: The Akan Graph follows an app's adaptor wiring further. `applyAdaptor(LlmAdaptorRole, ChatCompletionsLlm)` in an app's or lib's `option.ts` draws a `binds` edge from the role, `adaptor::LlmAdaptorRole`, to the implementation it binds, labelled with the app or lib and located at the call, so a method that calls a plugged role reaches the class that answers it. `get_graph` scoped to an app keeps that app's own bindings, and a lib's binding of a role the app does not bind itself; scoped to a lib or a module it keeps every binding of a role it reaches. A Change Map carries every binding of a role it shows, changed or not, so the binding an app runs stays in view; a binding the app removed no longer hides the lib binding it now runs; and Studio's Change Map for one app keeps only the bindings that app runs. An app service holds the fields its module's lib services plug (`...user.services`), so `this.notifier.getMemo()` in an app method links to the `Notifier` a lib's `MemoService` plugs, where it linked to the `memo` model; a service whose own builder declares the field (`notifier: use<…>()`) inherits nothing. A plug imported from a package outside the workspace, `plug(Stripe)` from `stripe-sdk`, or through a lib's re-export of it (`export { Stripe }`, `export { default as Stripe }`, or `export * from "stripe-sdk"` where the lib declares no `Stripe` itself), is `adaptor:stripe-sdk:Stripe` with the package in `detail`, where it linked to a same-name adaptor the app declares or to an unlocated `adaptor:libs/util:Stripe`. Two adaptors with the same class name in one app or lib are two nodes, each with its own location, digest and `plugs`, and a plug reaches the one its import names. A job's `detail` lists only the options its kind honours: `once` on an `initialize` job, `unlocked` on an `interval` job (`Bun.cron` never overlaps a cron job, so `lock: false` changes nothing there); `cron("0 * * * *" as const)` reads `0 * * * *`.
38
+
39
+ Ids and details that change for a saved graph or a Change Map base:
40
+
41
+ - While two adaptors of one owner share a class name, each is keyed by its file, `adaptor:apps/demo:srvkit/clockB.ts#Clock`; an adaptor whose name no other adaptor of its owner shares keeps `adaptor:<owner>:<Name>`. The Change Map treats the two forms of one declaration as one node, so a second same-name adaptor shows as one added node, not a removal of the first.
42
+ - A plug from a non-workspace package is `adaptor:<package>:<Name>` with the package as `detail`, where it was `adaptor::<Name>` or a workspace adaptor of that name.
43
+ - A job's `detail` drops `once` and `unlocked` where the runtime ignores them, and `as const` from a cron expression.
44
+
45
+ - **`binds`** joins `AkanGraphEdgeKind` in `akanjs/common`.
46
+ - **`FileFacts.adaptorBindings`** in `@akanjs/language`: each `applyAdaptor(role, adaptor)` call a file makes; `AkanIndex.setFile` also reads non-test files whose text mentions `applyAdaptor`. **`ServiceClassFact.fields`**: the names a service's builder declares.
47
+
37
48
  - c723713: The Akan Graph keys a guard function by the file that declares it, locates more of a workspace's guards, and stops counting things that are not guards.
38
49
 
39
50
  A guard function a route or layout imports is keyed by its file, as one the page declares already was: `guard:libs/shared:webkit/signedIn.ts#signedIn`. A `canEdit` in `webkit/` and one in `common/` are two nodes, each at its own declaration, and an edit to either marks only that one changed in the Change Map. A guard called through a namespace import (`auth.signedIn()`), or imported under a name a file re-exports (`export { signedIn as hasSession } from "./auth"`), reaches its declaration. A function the guard declares, or a parameter it binds, shadows an import of the same name only inside its own scope, so `[1].forEach((signedIn) => …)` no longer hides a real `signedIn()` beside it. A guard declared after another declarator (`export const ready = true, signedIn = …`) is found, and an overloaded guard's digest covers every signature. Only a file whose text declares or re-exports the name is parsed, so a guard that calls `usePage()` from the generated client no longer parses every page that names it.
@@ -204,6 +215,8 @@
204
215
  - **The CSR build and its MCP App views** rewrite `akanjs/ui` imports to the components a file names, as the SSR route builds already did, so a page that imports `Link` no longer bundles every component.
205
216
  - **Animations use `@react-spring/web`** instead of the `react-spring` umbrella package, which also installed the native, three.js, konva and zdog renderers.
206
217
 
218
+ - 58b7329: `akan create-scalar`, `remove-scalar`, `create-module`, `create-service`, `create-application`, `create-library`, `install-library` and `create-workspace --app` refuse a name that is not one plain path segment before they touch a file, such as `"../.." is no scalar name; use letters, digits, - and _, starting with a letter`. Before, the name went into the path unchecked: `akan remove-scalar ../.. <app>` deleted `apps/<app>` and exited 0, and the create commands wrote their files outside `lib/`, `apps/` or `libs/`. A scalar, module or service name is checked as a workflow's `module` input already was, an app or lib name as its `app` input; both checks now live in `@akanjs/devkit/workflow/plainName`, with `assertPlainName`.
219
+ - b4b8f85: `akan preview` and the code agent's page preview give a browser that died or was closed the full `startTimeoutMs` (120 s by default) for its next start. Before, after one page had run out of the start bound, a browser that then died while another page waited for it stayed marked as slow to start. The next page started a fresh browser within only its own `timeoutMs` (60 s for `akan preview`, 30 s for the code agent's check).
207
220
  - 686e0b8: `akan preview` and the code agent's page preview bound a page only once the browser is up. Before, a process's first page shared its `timeoutMs` with starting the browser. On a cold machine where Chrome took longer than that to start, the first page failed as `Timed out loading …; the dev server may be rebuilding or stuck.`
208
221
 
209
222
  Starting the browser now has its own bound of 120 s, `startTimeoutMs` on `PreviewView.probe`. Running out of it reads `Timed out starting the <engine> browser after 120 s.`, where the engine is `chrome` or `webkit`. A browser that misses its bound may still come up late, so later pages in that process try again, but each waits only its own `timeoutMs` for the browser. Once the browser answers, the full bound applies again. So a browser that never starts holds the first page for 120 s, where before it held it for `timeoutMs` (60 s for `akan preview`, 30 s for the code agent's check).
@@ -327,6 +340,7 @@
327
340
  - Updated dependencies [61fbd7d]
328
341
  - Updated dependencies [b3f85fa]
329
342
  - Updated dependencies [a707354]
343
+ - Updated dependencies [4d61170]
330
344
  - Updated dependencies [c723713]
331
345
  - Updated dependencies [0351ad4]
332
346
  - Updated dependencies [ba417c6]
@@ -381,7 +395,9 @@
381
395
  - Updated dependencies [5e08722]
382
396
  - Updated dependencies [df32c46]
383
397
  - Updated dependencies [5f809e8]
398
+ - Updated dependencies [76705dd]
384
399
  - Updated dependencies [206bb69]
400
+ - Updated dependencies [e4c7e5c]
385
401
  - Updated dependencies [6291fec]
386
402
  - Updated dependencies [0c480c6]
387
403
  - Updated dependencies [d9eaacc]
@@ -324,7 +324,7 @@ export const readonlyMcpTools: McpToolDefinition[] = [
324
324
  {
325
325
  name: "get_graph",
326
326
  description:
327
- "Read-only Akan Graph from source, no dev server needed: models with their fields and relations, slices and endpoints with the guards they name (each guard keyed by the app or lib that declares it, with its scope and the comment that says what it checks), the service methods and queries each calls, the store actions that fetch them, and the components and routes that use them. A module's jobs (internal: interval, cron, timeout, initialize, destroy, process) read their schedule and server or operation modes and call service methods like endpoints do; each adaptor a service plugs (an adapt() class, or an akanjs role) is a node, a method that calls it links to it, and an adaptor links to the adaptors it plugs itself. Each page/ file is a route (its URL pattern after the locale) or a layout, layouts wrap what they contain, and a route or layout that runs .guard() reads `guard` and links each function its guard calls; a layout's guard gates every route it wraps. A graph too large to answer unscoped drops first the routes and layouts that neither changed nor use a change; app or module brings them back. Every node and edge carries its source location. Pass app and/or module to scope a large workspace. Pass since (a git revision such as main, or HEAD for uncommitted work) for the Change Map instead: what changed since HEAD forked from it, working tree included, with each node, field and edge marked added, removed or changed, the unchanged neighbours they call or are called by, and the changed files no node covers, each marked the same way.",
327
+ "Read-only Akan Graph from source, no dev server needed: models with their fields and relations, slices and endpoints with the guards they name (each guard keyed by the app or lib that declares it, with its scope and the comment that says what it checks), the service methods and queries each calls, the store actions that fetch them, and the components and routes that use them. A module's jobs (internal: interval, cron, timeout, initialize, destroy, process) read their schedule and server or operation modes and call service methods like endpoints do; each adaptor a service plugs (an adapt() class, or an akanjs role) is a node, a method that calls it links to it (an app service holds the fields its module's lib services plug, unless it declares the field itself), an adaptor links to the adaptors it plugs itself, a class from a package outside the workspace is adaptor:<package>:<Name>, and a role an app binds with applyAdaptor(Role, Impl) links to that implementation (binds, labelled with the app; a scope keeps its app's bindings). Each page/ file is a route (its URL pattern after the locale) or a layout, layouts wrap what they contain, and a route or layout that runs .guard() reads `guard` and links each function its guard calls; a layout's guard gates every route it wraps. A graph too large to answer unscoped drops first the routes and layouts that neither changed nor use a change; app or module brings them back. Every node and edge carries its source location. Pass app and/or module to scope a large workspace. Pass since (a git revision such as main, or HEAD for uncommitted work) for the Change Map instead: what changed since HEAD forked from it, working tree included, with each node, field and edge marked added, removed or changed, the unchanged neighbours they call or are called by, and the changed files no node covers, each marked the same way.",
328
328
  inputSchema: { type: "object", properties: { app: stringProperty, module: stringProperty, since: stringProperty } },
329
329
  },
330
330
  {
@@ -2,6 +2,7 @@ import { afterAll, describe, expect, test } from "bun:test";
2
2
  import { mkdtemp, rm } from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
+ import { createTsconfigPackageResolver } from "../transforms/barrelImportsPlugin";
5
6
  import { GraphClientEntryDiscovery } from "./clientEntryDiscovery";
6
7
 
7
8
  const dirs: string[] = [];
@@ -122,6 +123,35 @@ describe("GraphClientEntryDiscovery", () => {
122
123
  expect(await discovery.discover([file("M5.tsx")])).toEqual([file("C.tsx")]);
123
124
  });
124
125
 
126
+ test("a root layout importing System by its file under a tsconfig wildcard reaches System's client entries", async () => {
127
+ const file = await tempDir();
128
+ const workspaceRoot = path.dirname(file("x"));
129
+ await Bun.write(
130
+ file("pkgs/akanjs/ui/System/index.tsx"),
131
+ 'import SSR from "./SSR";\nexport const System = { SSR };\n',
132
+ );
133
+ await Bun.write(
134
+ file("pkgs/akanjs/ui/System/SSR.tsx"),
135
+ 'import { ClientWrapper } from "./Client";\nexport default ClientWrapper;\n',
136
+ );
137
+ await Bun.write(
138
+ file("pkgs/akanjs/ui/System/Client.tsx"),
139
+ '"use client";\nexport const ClientWrapper = () => null;\n',
140
+ );
141
+ await Bun.write(
142
+ file("root/__root_layout.tsx"),
143
+ 'import { System } from "akanjs/ui/System/index.tsx";\nexport default System;\n',
144
+ );
145
+ const resolvePackage = await createTsconfigPackageResolver({
146
+ workspace: { workspaceRoot },
147
+ getTsConfig: async () => ({ compilerOptions: { paths: { "akanjs/ui/*": ["./pkgs/akanjs/ui/*"] } } }),
148
+ } as never);
149
+ const discovery = new GraphClientEntryDiscovery({ barrelImports: [] }, resolvePackage);
150
+ expect(await discovery.discover([file("root/__root_layout.tsx")])).toEqual([
151
+ file("pkgs/akanjs/ui/System/Client.tsx"),
152
+ ]);
153
+ });
154
+
125
155
  test("a file walked inside an import cycle keeps no partial result for a walk that starts at it", async () => {
126
156
  const file = await tempDir();
127
157
  await Bun.write(file("A.tsx"), 'import "./B";\nimport "./C";\n');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "3.2.0-rc.2",
3
+ "version": "3.2.0-rc.3",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -41,7 +41,7 @@
41
41
  "./*": "./*.ts"
42
42
  },
43
43
  "dependencies": {
44
- "@akanjs/language": "3.2.0-rc.2",
44
+ "@akanjs/language": "3.2.0-rc.3",
45
45
  "@earendil-works/pi-agent-core": "1.3.0",
46
46
  "@earendil-works/pi-ai": "1.3.0",
47
47
  "@earendil-works/pi-codemode": "1.3.0",
@@ -50,7 +50,7 @@
50
50
  "@earendil-works/pi-tui": "1.3.0",
51
51
  "@inquirer/prompts": "^8.7.3",
52
52
  "@tailwindcss/node": "^4.3.3",
53
- "akanjs": "3.2.0-rc.2",
53
+ "akanjs": "3.2.0-rc.3",
54
54
  "chalk": "^6.0.1",
55
55
  "commander": "^15.0.0",
56
56
  "dayjs": "^1.11.23",
@@ -80,12 +80,14 @@ export class PreviewView {
80
80
  const view = PreviewView.#open({ width, height, engine, onConsole: (line) => logs.push(line) });
81
81
  try {
82
82
  //? A browser that missed its start bound may still come up, so a later page retries it within only its own bound.
83
+ //? One that died or was closed instead is spawned afresh by the next view, which gets the full bound.
83
84
  const startMs = PreviewView.#stalled.has(engine) ? Math.min(startTimeoutMs, timeoutMs) : startTimeoutMs;
84
85
  const stalled = new Error(`Timed out starting the ${engine} browser after ${startMs / 1_000} s.`);
85
86
  await PreviewView.#within(view.navigate("about:blank"), Date.now() + startMs, stalled).then(
86
87
  () => PreviewView.#stalled.delete(engine),
87
88
  (error: unknown) => {
88
89
  if (error === stalled) PreviewView.#stalled.add(engine);
90
+ else PreviewView.#stalled.delete(engine);
89
91
  throw error;
90
92
  },
91
93
  );
@@ -14,7 +14,12 @@ const slowChromeRuns = PreviewView.isAvailable("chrome") && process.platform !==
14
14
  const withSlowChrome = async (body: string, startSeconds = 3) => {
15
15
  const dir = await mkdtemp(path.join(tmpdir(), "akan-slow-chrome-"));
16
16
  const chrome = path.join(dir, "chrome");
17
- await writeFile(chrome, `#!/bin/sh\nsleep ${startSeconds}\nexec "${PreviewChrome.executable()}" "$@"\n`);
17
+ const chromeLog = path.join(dir, "chrome.log");
18
+ //? Bun.WebView sends the browser's stderr to /dev/null, which would hide why it died.
19
+ await writeFile(
20
+ chrome,
21
+ `#!/bin/sh\nexec 2>>"${chromeLog}"\nsleep ${startSeconds}\nexec "${PreviewChrome.executable()}" "$@"\n`,
22
+ );
18
23
  await chmod(chrome, 0o755);
19
24
  const script = `const { PreviewView } = await import(${JSON.stringify(path.join(import.meta.dir, "PreviewView.ts"))});\n${body}`;
20
25
  //? Chrome is spawned once per process, so only a fresh process takes the slow executable.
@@ -30,7 +35,10 @@ const withSlowChrome = async (body: string, startSeconds = 3) => {
30
35
  new Response(child.stderr).text(),
31
36
  child.exited,
32
37
  ]);
33
- return { code, out: out.trim(), err };
38
+ const chromeErr = await Bun.file(chromeLog)
39
+ .text()
40
+ .catch(() => "");
41
+ return { code, out: out.trim(), log: `${err}\nChrome's stderr:\n${chromeErr}` };
34
42
  } finally {
35
43
  child.kill();
36
44
  await rm(dir, { recursive: true, force: true });
@@ -112,12 +120,12 @@ describe("PreviewView", () => {
112
120
  async () => {
113
121
  const server = serve("<!doctype html><title>Up</title><body>up</body>");
114
122
  try {
115
- const { code, out, err } = await withSlowChrome(
123
+ const { code, out, log } = await withSlowChrome(
116
124
  `const probe = await PreviewView.probe("http://localhost:${server.port}/", { engine: "chrome", timeoutMs: 1_500 });
117
125
  console.log(probe.text);`,
118
126
  );
119
- expect(out || err).toBe("up");
120
- expect(code).toBe(0);
127
+ expect(out, log).toBe("up");
128
+ expect(code, log).toBe(0);
121
129
  } finally {
122
130
  server.stop(true);
123
131
  }
@@ -128,7 +136,7 @@ console.log(probe.text);`,
128
136
  test.skipIf(!slowChromeRuns)(
129
137
  "gives a later page only its own bound for a browser that missed its start, and recovers once it is up",
130
138
  async () => {
131
- const { code, out, err } = await withSlowChrome(
139
+ const { code, out, log } = await withSlowChrome(
132
140
  `const open = () =>
133
141
  PreviewView.probe("about:blank", { engine: "chrome", startTimeoutMs: 1_500, timeoutMs: 1_000 }).then(
134
142
  () => "opened",
@@ -137,7 +145,7 @@ console.log(probe.text);`,
137
145
  console.log(await open());
138
146
  console.log(await open());
139
147
  let late = await open();
140
- for (let tries = 0; late !== "opened" && tries < 100; tries++) late = await open();
148
+ for (let tries = 0; late.startsWith("Timed out starting") && tries < 100; tries++) late = await open();
141
149
  console.log(late);
142
150
  Bun.WebView.closeAll();
143
151
  await Bun.sleep(500);
@@ -145,7 +153,7 @@ console.log(await open());
145
153
  Bun.WebView.closeAll();`,
146
154
  6,
147
155
  );
148
- expect(out || err).toBe(
156
+ expect(out, log).toBe(
149
157
  [
150
158
  "Timed out starting the chrome browser after 1.5 s.",
151
159
  "Timed out starting the chrome browser after 1 s.",
@@ -153,7 +161,36 @@ Bun.WebView.closeAll();`,
153
161
  "Timed out starting the chrome browser after 1.5 s.",
154
162
  ].join("\n"),
155
163
  );
156
- expect(code).toBe(0);
164
+ expect(code, log).toBe(0);
165
+ },
166
+ browserTestMs,
167
+ );
168
+
169
+ test.skipIf(!slowChromeRuns)(
170
+ "gives a browser killed after it missed its start the full bound for its next start",
171
+ async () => {
172
+ const { code, out, log } = await withSlowChrome(
173
+ `const open = () =>
174
+ PreviewView.probe("about:blank", { engine: "chrome", startTimeoutMs: 1_500, timeoutMs: 1_000 }).then(
175
+ () => "opened",
176
+ (error) => error.message,
177
+ );
178
+ console.log(await open());
179
+ const killed = open();
180
+ Bun.WebView.closeAll();
181
+ console.error(await killed);
182
+ await Bun.sleep(500);
183
+ console.log(await open());
184
+ Bun.WebView.closeAll();`,
185
+ 6,
186
+ );
187
+ expect(out, log).toBe(
188
+ [
189
+ "Timed out starting the chrome browser after 1.5 s.",
190
+ "Timed out starting the chrome browser after 1.5 s.",
191
+ ].join("\n"),
192
+ );
193
+ expect(code, log).toBe(0);
157
194
  },
158
195
  browserTestMs,
159
196
  );
package/workflow/index.ts CHANGED
@@ -6,6 +6,7 @@ export * from "./keyRename";
6
6
  export * from "./moduleIndex";
7
7
  export * from "./mutationScaffold";
8
8
  export * from "./mutationSource";
9
+ export * from "./plainName";
9
10
  export * from "./plan";
10
11
  export * from "./render";
11
12
  export * from "./rolloutGate";
@@ -0,0 +1,15 @@
1
+ export const sysNamePattern = /^[a-z0-9][a-zA-Z0-9_-]*$/;
2
+ export const identifierPattern = /^_{0,2}[a-zA-Z][a-zA-Z0-9_-]*$/;
3
+
4
+ export const isWorkflowSysName = (value: unknown): value is string =>
5
+ typeof value === "string" && sysNamePattern.test(value);
6
+ export const isWorkflowModuleName = (value: unknown): value is string =>
7
+ typeof value === "string" && identifierPattern.test(value);
8
+
9
+ export const assertPlainName = (kind: "app" | "lib" | "module" | "service" | "scalar", name: string) => {
10
+ const isSys = kind === "app" || kind === "lib";
11
+ if (isSys ? isWorkflowSysName(name) : isWorkflowModuleName(name)) return;
12
+ throw new Error(
13
+ `${JSON.stringify(name)} is no ${kind} name; use letters, digits, - and _, starting with ${isSys ? "a lowercase letter or digit" : "a letter"}`,
14
+ );
15
+ };
package/workflow/plan.ts CHANGED
@@ -5,6 +5,7 @@ import { resolveWorkflowSys } from "./executor";
5
5
  import { InteractionScaffold } from "./interactionScaffold";
6
6
  import { KeyRename } from "./keyRename";
7
7
  import { MutationScaffold } from "./mutationScaffold";
8
+ import { identifierPattern, sysNamePattern } from "./plainName";
8
9
  import { coerceFieldDefault, getSysRoot, moduleSourcePaths } from "./source";
9
10
  import type {
10
11
  WorkflowDiagnostic,
@@ -21,8 +22,6 @@ import { workflowStatus } from "./utils";
21
22
 
22
23
  const surfaceModes = new Set<WorkflowSurfaceMode>(["infer", "include", "skip"]);
23
24
 
24
- const sysNamePattern = /^[a-z0-9][a-zA-Z0-9_-]*$/;
25
- const identifierPattern = /^_{0,2}[a-zA-Z][a-zA-Z0-9_-]*$/;
26
25
  //? These inputs become path segments, identifiers and command arguments, so none may carry a separator or a dot.
27
26
  const nameInputPatterns: Record<string, RegExp> = {
28
27
  app: sysNamePattern,
@@ -36,11 +35,6 @@ const nameInputPatterns: Record<string, RegExp> = {
36
35
  interaction: identifierPattern,
37
36
  };
38
37
 
39
- export const isWorkflowSysName = (value: unknown): value is string =>
40
- typeof value === "string" && sysNamePattern.test(value);
41
- export const isWorkflowModuleName = (value: unknown): value is string =>
42
- typeof value === "string" && identifierPattern.test(value);
43
-
44
38
  const parseStringList = (value: unknown) => {
45
39
  if (Array.isArray(value)) {
46
40
  const values = value.filter((item): item is string => typeof item === "string" && item.trim().length > 0);
@@ -3,6 +3,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import path from "node:path";
5
5
  import type { AkanChangeMap, AkanGraphEdge, AkanGraphNode } from "@akanjs/language";
6
+ import { AkanGraphDiff } from "@akanjs/language";
6
7
  import type { Workspace } from "./commandDecorators";
7
8
  import { WorkspaceGraph } from "./workspaceGraph";
8
9
 
@@ -186,6 +187,89 @@ describe("WorkspaceGraph.scope", () => {
186
187
  expect(scoped.nodes.map(({ id }) => id)).toEqual([guard, own]);
187
188
  expect(scoped.edges).toEqual([edge(own, guard)]);
188
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
+ });
189
273
  });
190
274
 
191
275
  describe("WorkspaceGraph.changesAt", () => {
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";
@@ -109,9 +116,30 @@ export class WorkspaceGraph {
109
116
  //? A lib's guard is linked from every app's endpoints; through it, a scope would reach all of them.
110
117
  const guards = new Set(graph.nodes.filter((node) => node.kind === "guard").map((node) => node.id));
111
118
  const reaches = (id: string) => seeds.has(id) && !guards.has(id);
112
- const edges = graph.edges.filter((edge) => reaches(edge.from) || reaches(edge.to));
113
- const ids = new Set([...seeds, ...edges.flatMap((edge) => [edge.from, edge.to])]);
114
- 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)));
115
143
  }
116
144
 
117
145
  //? Pages are many and most only wrap one another, so a graph too large to answer whole drops the pages that neither
@@ -141,7 +169,14 @@ export class WorkspaceGraph {
141
169
  const isVisible = ({ owner }: AkanGraphNode) => !owner || owner === appOwner || owner.startsWith("libs/");
142
170
  const nodes = graph.nodes.filter(isVisible);
143
171
  const ids = new Set(nodes.map((node) => node.id));
144
- 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
+ };
145
180
  }
146
181
 
147
182
  static changesVisibleTo(changes: AkanChangeMap, app: string): AkanChangeMap {