@akanjs/devkit 3.2.0-rc.1 → 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 +52 -1
- package/akanContext.ts +1 -3
- package/akanMcpContract.ts +5 -4
- package/artifact/storeBootstrap.ts +3 -2
- package/codeAgent/skills/akan-scaffold/SKILL.md +2 -1
- package/commandDecorators/command.ts +35 -18
- package/commandDecorators/commandDecorators.test.ts +75 -3
- package/commandDecorators/targetMeta.ts +2 -0
- package/constantFieldNames.test.ts +34 -0
- package/constantFieldNames.ts +18 -0
- package/frontendBuild/buildRouteClient.test.ts +43 -0
- package/frontendBuild/clientEntryDiscovery.test.ts +52 -0
- package/frontendBuild/clientEntryDiscovery.ts +28 -3
- package/frontendBuild/csrArtifactBuilder.test.ts +48 -0
- package/frontendBuild/csrArtifactBuilder.ts +9 -3
- package/frontendBuild/csrDevBundler.ts +12 -2
- package/frontendBuild/csrDevModuleCompiler.test.ts +97 -0
- package/frontendBuild/csrDevModuleCompiler.ts +37 -8
- package/frontendBuild/csrDevPatcher.test.ts +89 -2
- package/frontendBuild/csrDevPatcher.ts +47 -7
- package/frontendBuild/csrDevTypes.ts +16 -0
- package/frontendBuild/cssCompiler.test.ts +63 -1
- package/frontendBuild/devBarrelImports.ts +132 -0
- package/frontendBuild/devRegistryBundler.ts +19 -4
- package/frontendBuild/mcpAppViewBuilder.test.ts +67 -0
- package/frontendBuild/ssrDevBundler.ts +13 -2
- package/package.json +3 -3
- package/preview/PreviewView.ts +2 -0
- package/preview/previewView.test.ts +46 -9
- package/transforms/barrelAnalyzer.ts +46 -1
- package/transforms/barrelImportsPlugin.ts +61 -45
- package/transforms/transforms.test.ts +67 -1
- package/workflow/artifacts.ts +17 -0
- package/workflow/index.ts +1 -0
- package/workflow/plainName.ts +15 -0
- package/workflow/plan.ts +55 -21
- package/workspaceGraph.test.ts +200 -3
- package/workspaceGraph.ts +46 -8
package/workflow/plan.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
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
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,
|
|
@@ -17,11 +18,10 @@ import type {
|
|
|
17
18
|
WorkflowSurfaceMode,
|
|
18
19
|
} from "./types";
|
|
19
20
|
import { addFieldUiPolicyForType } from "./uiPolicy";
|
|
21
|
+
import { workflowStatus } from "./utils";
|
|
20
22
|
|
|
21
23
|
const surfaceModes = new Set<WorkflowSurfaceMode>(["infer", "include", "skip"]);
|
|
22
24
|
|
|
23
|
-
const sysNamePattern = /^[a-z0-9][a-zA-Z0-9_-]*$/;
|
|
24
|
-
const identifierPattern = /^_{0,2}[a-zA-Z][a-zA-Z0-9_-]*$/;
|
|
25
25
|
//? These inputs become path segments, identifiers and command arguments, so none may carry a separator or a dot.
|
|
26
26
|
const nameInputPatterns: Record<string, RegExp> = {
|
|
27
27
|
app: sysNamePattern,
|
|
@@ -35,11 +35,6 @@ const nameInputPatterns: Record<string, RegExp> = {
|
|
|
35
35
|
interaction: identifierPattern,
|
|
36
36
|
};
|
|
37
37
|
|
|
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
38
|
const parseStringList = (value: unknown) => {
|
|
44
39
|
if (Array.isArray(value)) {
|
|
45
40
|
const values = value.filter((item): item is string => typeof item === "string" && item.trim().length > 0);
|
|
@@ -487,7 +482,7 @@ const syssHolding = async (workspace: Workspace, module: string, except: Sys) =>
|
|
|
487
482
|
.map(async (name) => await resolveWorkflowSys(workspace, name)),
|
|
488
483
|
);
|
|
489
484
|
const holding = await Promise.all(
|
|
490
|
-
others.map(async (other) => (other && (await other.
|
|
485
|
+
others.map(async (other) => (other && (await other.getDatabaseModules()).includes(module) ? other : null)),
|
|
491
486
|
);
|
|
492
487
|
return holding.filter((other): other is Sys => other !== null);
|
|
493
488
|
};
|
|
@@ -495,24 +490,53 @@ const syssHolding = async (workspace: Workspace, module: string, except: Sys) =>
|
|
|
495
490
|
const moduleMissing = async (workspace: Workspace, sys: Sys, module: string): Promise<WorkflowDiagnostic | null> => {
|
|
496
491
|
if ((await sys.readdir("lib")).includes(module)) return null;
|
|
497
492
|
const root = getSysRoot(sys);
|
|
498
|
-
const modules =
|
|
499
|
-
const sameName = modules.find((name) => name.
|
|
493
|
+
const modules = await sys.getDatabaseModules();
|
|
494
|
+
const sameName = modules.find((name) => name.toLowerCase() === module.toLowerCase());
|
|
495
|
+
const service = (await sys.getServiceModules()).find(
|
|
496
|
+
(name) => name.replace(/^_/, "").toLowerCase() === module.replace(/^_/, "").toLowerCase(),
|
|
497
|
+
);
|
|
500
498
|
const isScalar = (await sys.getScalarModules()).includes(module);
|
|
501
|
-
const [holder] = sameName || isScalar ? [] : await syssHolding(workspace, module, sys);
|
|
499
|
+
const [holder] = sameName || service || isScalar ? [] : await syssHolding(workspace, module, sys);
|
|
502
500
|
return {
|
|
503
501
|
severity: "error",
|
|
504
502
|
code: "workflow-module-missing",
|
|
505
503
|
input: "module",
|
|
506
504
|
message: [
|
|
507
505
|
`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.`,
|
|
506
|
+
modules.length ? `Its database modules are ${modules.join(", ")}.` : `${root} has no database modules yet.`,
|
|
509
507
|
sameName
|
|
510
508
|
? `Did you mean ${sameName}?`
|
|
511
|
-
:
|
|
512
|
-
? `${root}/lib
|
|
513
|
-
:
|
|
514
|
-
? `${module} is
|
|
515
|
-
:
|
|
509
|
+
: service
|
|
510
|
+
? `${root}/lib/${service} is a service module, which stores no model, and this workflow writes only into a database module.`
|
|
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
|
+
const moduleNotDatabase = async (sys: Sys, spec: WorkflowSpec, module: string): Promise<WorkflowDiagnostic | null> => {
|
|
521
|
+
const databaseModules = await sys.getDatabaseModules();
|
|
522
|
+
if (databaseModules.includes(module)) return null;
|
|
523
|
+
const root = getSysRoot(sys);
|
|
524
|
+
const isServiceModule = module.startsWith("_") && !module.startsWith("__");
|
|
525
|
+
return {
|
|
526
|
+
severity: "error",
|
|
527
|
+
code: "workflow-module-not-database",
|
|
528
|
+
input: "module",
|
|
529
|
+
message: [
|
|
530
|
+
isServiceModule
|
|
531
|
+
? `${module} in ${root} is a service module, which stores no model.`
|
|
532
|
+
: `${root}/lib/${module} is not a database module: it has no ${module}.constant.ts.`,
|
|
533
|
+
`${spec.name} needs a database module, one with lib/<model>/<model>.constant.ts.`,
|
|
534
|
+
databaseModules.length
|
|
535
|
+
? `Its database modules are ${databaseModules.join(", ")}.`
|
|
536
|
+
: `${root} has no database modules yet.`,
|
|
537
|
+
spec.inputs.field
|
|
538
|
+
? "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."
|
|
539
|
+
: "Plan this on one of them, or create the model with the create-module workflow first.",
|
|
516
540
|
].join(" "),
|
|
517
541
|
};
|
|
518
542
|
};
|
|
@@ -539,11 +563,11 @@ export const workflowTargetDiagnostics = async (
|
|
|
539
563
|
const module = planString(inputs, "module");
|
|
540
564
|
const moduleSpec: WorkflowInputSpec | undefined = spec.inputs.module;
|
|
541
565
|
if (!module || !moduleSpec?.mustExist) return [];
|
|
542
|
-
const
|
|
543
|
-
return
|
|
566
|
+
const refused = (await moduleMissing(workspace, sys, module)) ?? (await moduleNotDatabase(sys, spec, module));
|
|
567
|
+
return refused ? [refused] : [];
|
|
544
568
|
};
|
|
545
569
|
|
|
546
|
-
|
|
570
|
+
const inspectPlanTarget = async (
|
|
547
571
|
workspace: Workspace,
|
|
548
572
|
spec: WorkflowSpec,
|
|
549
573
|
plan: WorkflowPlan,
|
|
@@ -586,3 +610,13 @@ export const inspectWorkflowPlan = async (
|
|
|
586
610
|
recommendations: [...plan.recommendations, ...inspected.recommendations],
|
|
587
611
|
};
|
|
588
612
|
};
|
|
613
|
+
|
|
614
|
+
export const inspectWorkflowPlan = async (
|
|
615
|
+
workspace: Workspace,
|
|
616
|
+
spec: WorkflowSpec,
|
|
617
|
+
plan: WorkflowPlan,
|
|
618
|
+
): Promise<WorkflowPlan> => {
|
|
619
|
+
const inspected = await inspectPlanTarget(workspace, spec, plan);
|
|
620
|
+
if (workflowStatus(inspected.diagnostics) === "passed") return inspected;
|
|
621
|
+
return { ...inspected, recommendations: withoutApplyHints(inspected.recommendations) };
|
|
622
|
+
};
|
package/workspaceGraph.test.ts
CHANGED
|
@@ -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 = (
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
|
112
|
-
|
|
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
|
-
|
|
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 &&
|
|
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
|
|
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
|
],
|