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

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 (34) hide show
  1. package/CHANGELOG.md +78 -4
  2. package/akanApp/akanApp.host.test.ts +51 -0
  3. package/akanApp/akanApp.host.ts +3 -3
  4. package/akanContext.ts +2 -3
  5. package/akanMcpContract.ts +1 -1
  6. package/applicationBuildRunner.test.ts +67 -0
  7. package/applicationBuildRunner.ts +1 -0
  8. package/codeAgent/skills/akan-debug/SKILL.md +6 -2
  9. package/codeAgent/skills/akan-transport/SKILL.md +2 -0
  10. package/commandDecorators/argMeta.ts +4 -0
  11. package/commandDecorators/command.ts +51 -33
  12. package/commandDecorators/commandDecorators.test.ts +74 -9
  13. package/frontendBuild/bundleDefine.ts +2 -0
  14. package/frontendBuild/clientBuildTypes.ts +0 -1
  15. package/frontendBuild/clientEntryDiscovery.test.ts +30 -0
  16. package/frontendBuild/nativeCore.test.ts +148 -0
  17. package/frontendBuild/ssrBaseArtifactBuilder.test.ts +26 -0
  18. package/frontendBuild/ssrBaseArtifactBuilder.ts +23 -19
  19. package/frontendBuild/vendorImportScanner.test.ts +24 -3
  20. package/frontendBuild/vendorImportScanner.ts +25 -10
  21. package/incrementalBuilder/incrementalBuilder.host.test.ts +5 -1
  22. package/incrementalBuilder/incrementalBuilder.host.ts +2 -1
  23. package/lint/no-web-only-api-outside-webkit.grit +3 -3
  24. package/{constantFieldNames.test.ts → moduleFieldNames.test.ts} +24 -2
  25. package/moduleFieldNames.ts +25 -0
  26. package/package.json +3 -3
  27. package/preview/PreviewView.ts +32 -11
  28. package/preview/previewView.test.ts +76 -9
  29. package/workflow/index.ts +1 -0
  30. package/workflow/plainName.ts +28 -0
  31. package/workflow/plan.ts +1 -7
  32. package/workspaceGraph.test.ts +84 -0
  33. package/workspaceGraph.ts +40 -5
  34. package/constantFieldNames.ts +0 -18
@@ -0,0 +1,25 @@
1
+ import ts from "typescript";
2
+
3
+ const isFieldCall = (node: ts.Expression): boolean =>
4
+ ts.isCallExpression(node) &&
5
+ (ts.isIdentifier(node.expression)
6
+ ? node.expression.text === "field"
7
+ : ts.isPropertyAccessExpression(node.expression) && isFieldCall(node.expression.expression));
8
+
9
+ const propertyNames = (content: string, declares: (initializer: ts.Expression) => boolean) => {
10
+ const names: string[] = [];
11
+ const visit = (node: ts.Node) => {
12
+ if (ts.isPropertyAssignment(node) && ts.isIdentifier(node.name) && declares(node.initializer))
13
+ names.push(node.name.text);
14
+ ts.forEachChild(node, visit);
15
+ };
16
+ visit(ts.createSourceFile("module.ts", content, ts.ScriptTarget.Latest));
17
+ return names;
18
+ };
19
+
20
+ export const constantFieldNames = (content: string) => propertyNames(content, isFieldCall);
21
+
22
+ export const unlabeledFieldNames = (constantContent: string, dictionaryContent: string) => {
23
+ const labels = new Set(propertyNames(dictionaryContent, () => true));
24
+ return constantFieldNames(constantContent).filter((name) => !labels.has(name));
25
+ };
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.5",
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.5",
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.5",
54
54
  "chalk": "^6.0.1",
55
55
  "commander": "^15.0.0",
56
56
  "dayjs": "^1.11.23",
@@ -77,18 +77,12 @@ export class PreviewView {
77
77
  }: PreviewProbeOptions = {},
78
78
  ): Promise<PreviewProbeResult> {
79
79
  const logs: string[] = [];
80
- const view = PreviewView.#open({ width, height, engine, onConsole: (line) => logs.push(line) });
80
+ const view = await PreviewView.#start(
81
+ () => PreviewView.#open({ width, height, engine, onConsole: (line) => logs.push(line) }),
82
+ engine,
83
+ { startTimeoutMs, timeoutMs },
84
+ );
81
85
  try {
82
- //? A browser that missed its start bound may still come up, so a later page retries it within only its own bound.
83
- const startMs = PreviewView.#stalled.has(engine) ? Math.min(startTimeoutMs, timeoutMs) : startTimeoutMs;
84
- const stalled = new Error(`Timed out starting the ${engine} browser after ${startMs / 1_000} s.`);
85
- await PreviewView.#within(view.navigate("about:blank"), Date.now() + startMs, stalled).then(
86
- () => PreviewView.#stalled.delete(engine),
87
- (error: unknown) => {
88
- if (error === stalled) PreviewView.#stalled.add(engine);
89
- throw error;
90
- },
91
- );
92
86
  const deadline = Date.now() + timeoutMs;
93
87
  const within = <T>(work: Promise<T>, what: string) =>
94
88
  PreviewView.#within(
@@ -120,6 +114,33 @@ export class PreviewView {
120
114
  }
121
115
  }
122
116
 
117
+ static async #start(
118
+ open: () => WebViewLike,
119
+ engine: PreviewEngine,
120
+ { startTimeoutMs, timeoutMs }: { startTimeoutMs: number; timeoutMs: number },
121
+ ) {
122
+ for (let attempt = 1; ; attempt++) {
123
+ //? A browser that missed its start bound may still come up, so a later page retries it within only its own bound.
124
+ //? One that died or was closed instead is spawned afresh by the next view, which gets the full bound.
125
+ const startMs = PreviewView.#stalled.has(engine) ? Math.min(startTimeoutMs, timeoutMs) : startTimeoutMs;
126
+ const stalled = new Error(`Timed out starting the ${engine} browser after ${startMs / 1_000} s.`);
127
+ let view: WebViewLike | undefined;
128
+ try {
129
+ view = open();
130
+ await PreviewView.#within(view.navigate("about:blank"), Date.now() + startMs, stalled);
131
+ PreviewView.#stalled.delete(engine);
132
+ return view;
133
+ } catch (error) {
134
+ await view?.close();
135
+ if (error === stalled) PreviewView.#stalled.add(engine);
136
+ else PreviewView.#stalled.delete(engine);
137
+ if (error === stalled || attempt > 1) throw error;
138
+ //? Bun reaps a browser that died only on a later timer turn; until then `new Bun.WebView()` throws "Failed to spawn Chrome".
139
+ await Bun.sleep(100);
140
+ }
141
+ }
142
+ }
143
+
123
144
  static async #load(
124
145
  view: WebViewLike,
125
146
  url: string,
@@ -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,66 @@ 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.log(await killed);
182
+ Bun.WebView.closeAll();`,
183
+ 6,
184
+ );
185
+ expect(out, log).toBe(
186
+ [
187
+ "Timed out starting the chrome browser after 1.5 s.",
188
+ "Timed out starting the chrome browser after 1.5 s.",
189
+ ].join("\n"),
190
+ );
191
+ expect(code, log).toBe(0);
192
+ },
193
+ browserTestMs,
194
+ );
195
+
196
+ test.skipIf(!slowChromeRuns)(
197
+ "starts a new browser for the page right after one that the browser died on",
198
+ async () => {
199
+ const { code, out, log } = await withSlowChrome(
200
+ `const { promise: loading, resolve } = Promise.withResolvers();
201
+ const server = Bun.serve({
202
+ port: 0,
203
+ fetch: (req) =>
204
+ new URL(req.url).pathname === "/hang"
205
+ ? (resolve(), new Promise(() => undefined))
206
+ : new Response("<!doctype html><body>up</body>", { headers: { "content-type": "text/html" } }),
207
+ });
208
+ const open = (path) =>
209
+ PreviewView.probe(\`http://localhost:\${server.port}\${path}\`, { engine: "chrome" }).then(
210
+ (probe) => probe.text,
211
+ (error) => error.message,
212
+ );
213
+ const killed = open("/hang");
214
+ await loading;
215
+ Bun.WebView.closeAll();
216
+ console.error(await killed);
217
+ console.log(await open("/"));
218
+ server.stop(true);
219
+ Bun.WebView.closeAll();`,
220
+ 0,
221
+ );
222
+ expect(out, log).toBe("up");
223
+ expect(code, log).toBe(0);
157
224
  },
158
225
  browserTestMs,
159
226
  );
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,28 @@
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
+ const packageNamePattern = /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
4
+
5
+ export type PlainNameKind = "app" | "lib" | "module" | "service" | "scalar";
6
+
7
+ export const isWorkflowSysName = (value: unknown): value is string =>
8
+ typeof value === "string" && sysNamePattern.test(value);
9
+ export const isWorkflowModuleName = (value: unknown): value is string =>
10
+ typeof value === "string" && identifierPattern.test(value);
11
+
12
+ export const plainNameProblem = (kind: PlainNameKind, name: string) => {
13
+ const isSys = kind === "app" || kind === "lib";
14
+ if (isSys ? isWorkflowSysName(name) : isWorkflowModuleName(name)) return null;
15
+ return `${JSON.stringify(name)} is no ${kind} name; use letters, digits, - and _, starting with ${isSys ? "a lowercase letter or digit" : "a letter"}`;
16
+ };
17
+
18
+ export const assertPlainName = (kind: PlainNameKind, name: string) => {
19
+ const problem = plainNameProblem(kind, name);
20
+ if (problem) throw new Error(problem);
21
+ };
22
+
23
+ export const assertPackageName = (name: string) => {
24
+ if (packageNamePattern.test(name)) return;
25
+ throw new Error(
26
+ `${JSON.stringify(name)} is no package name; use name or @scope/name, each of lowercase letters, digits, -, . and _, starting with a letter or digit`,
27
+ );
28
+ };
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 {
@@ -1,18 +0,0 @@
1
- import ts from "typescript";
2
-
3
- const isFieldCall = (node: ts.Expression): boolean =>
4
- ts.isCallExpression(node) &&
5
- (ts.isIdentifier(node.expression)
6
- ? node.expression.text === "field"
7
- : ts.isPropertyAccessExpression(node.expression) && isFieldCall(node.expression.expression));
8
-
9
- export const constantFieldNames = (content: string) => {
10
- const names: string[] = [];
11
- const visit = (node: ts.Node) => {
12
- if (ts.isPropertyAssignment(node) && ts.isIdentifier(node.name) && isFieldCall(node.initializer))
13
- names.push(node.name.text);
14
- ts.forEachChild(node, visit);
15
- };
16
- visit(ts.createSourceFile("constant.ts", content, ts.ScriptTarget.Latest));
17
- return names;
18
- };