@akanjs/devkit 3.1.2-rc.1 → 3.1.2-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/akanApp/akanApp.host.ts +5 -3
- package/akanMcpContract.ts +8 -0
- package/codeAgent/skills/akan-scaffold/SKILL.md +1 -0
- package/incrementalBuilder/builderRequestRouter.ts +1 -4
- package/incrementalBuilder/incrementalBuilder.host.test.ts +7 -0
- package/incrementalBuilder/incrementalBuilder.host.ts +7 -4
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +63 -0
- package/incrementalBuilder/incrementalBuilder.proc.ts +27 -2
- package/package.json +3 -3
- package/workflow/executor.ts +11 -0
- package/workflow/index.ts +1 -0
- package/workflow/keyRename.ts +78 -0
- package/workflow/plan.ts +22 -0
- package/workflow/types.ts +7 -0
- package/workspaceGraph.ts +73 -0
package/akanApp/akanApp.host.ts
CHANGED
|
@@ -50,7 +50,7 @@ import {
|
|
|
50
50
|
UNAVAILABLE_REDIS_HOST,
|
|
51
51
|
} from "./devHostPolicy";
|
|
52
52
|
|
|
53
|
-
const backendMsgTypeSet = new Set<BuilderMessage["type"]>(["build-route", "build-csr"]);
|
|
53
|
+
const backendMsgTypeSet = new Set<BuilderMessage["type"]>(["build-route", "build-csr", "build-graph"]);
|
|
54
54
|
|
|
55
55
|
const asMib = (bytes: number) => Math.round(bytes / 1024 / 1024);
|
|
56
56
|
|
|
@@ -504,7 +504,7 @@ export class AkanAppHost {
|
|
|
504
504
|
await this.#handleInvalidate(message);
|
|
505
505
|
return;
|
|
506
506
|
}
|
|
507
|
-
if (message.type === "build-route-res" || message.type === "build-csr-res") {
|
|
507
|
+
if (message.type === "build-route-res" || message.type === "build-csr-res" || message.type === "build-graph-res") {
|
|
508
508
|
const answer = this.#builderRequests.settle(message);
|
|
509
509
|
if (!answer) {
|
|
510
510
|
this.logger.verbose(`[builder] dropped a ${message.type} no live backend is waiting for (id=${message.id})`);
|
|
@@ -925,6 +925,8 @@ export class AkanAppHost {
|
|
|
925
925
|
this.#sendToBackend({ type: "build-route-res", id: message.id, ok: false, error });
|
|
926
926
|
else if (message.type === "build-csr")
|
|
927
927
|
this.#sendToBackend({ type: "build-csr-res", id: message.id, ok: false, error });
|
|
928
|
+
else if (message.type === "build-graph")
|
|
929
|
+
this.#sendToBackend({ type: "build-graph-res", id: message.id, ok: false, error });
|
|
928
930
|
else return false;
|
|
929
931
|
return true;
|
|
930
932
|
}
|
|
@@ -1308,7 +1310,7 @@ export class AkanAppHost {
|
|
|
1308
1310
|
this.logger.verbose(`[csr] armed dev CSR rebuilds (${message.reason})`);
|
|
1309
1311
|
}
|
|
1310
1312
|
// Renumbered per backend generation; the failure replies below use that backend's own `message.id`.
|
|
1311
|
-
if (message.type === "build-route" || message.type === "build-csr") {
|
|
1313
|
+
if (message.type === "build-route" || message.type === "build-csr" || message.type === "build-graph") {
|
|
1312
1314
|
const outgoing = this.#builderRequests.issue(message);
|
|
1313
1315
|
if (this.#builder?.send(outgoing)) return;
|
|
1314
1316
|
this.#builderRequests.withdraw(outgoing.id);
|
package/akanMcpContract.ts
CHANGED
|
@@ -188,6 +188,8 @@ export const defaultWorkflowPlanPath = (workflow: string, inputs: WorkflowPlanIn
|
|
|
188
188
|
slugPart(inputs.surface),
|
|
189
189
|
slugPart(inputs.mutation),
|
|
190
190
|
slugPart(inputs.slice),
|
|
191
|
+
slugPart(inputs.query),
|
|
192
|
+
slugPart(inputs.to),
|
|
191
193
|
]
|
|
192
194
|
.filter(Boolean)
|
|
193
195
|
.join("-");
|
|
@@ -319,6 +321,12 @@ export const readonlyMcpTools: McpToolDefinition[] = [
|
|
|
319
321
|
"Legacy broad module context. Pass app in monorepos; prefer inspect_akan_context.moduleContext or fieldInsertionContext for typed add-field evidence.",
|
|
320
322
|
inputSchema: { type: "object", properties: { app: stringProperty, module: stringProperty }, required: ["module"] },
|
|
321
323
|
},
|
|
324
|
+
{
|
|
325
|
+
name: "get_graph",
|
|
326
|
+
description:
|
|
327
|
+
"Read-only Akan Graph from source, no dev server needed: models with their fields and relations, slices and endpoints with guards, the service methods and queries each calls, the store actions that fetch them, and the components and pages that use them. Every node and edge carries its source location. Pass app and/or module to scope a large workspace.",
|
|
328
|
+
inputSchema: { type: "object", properties: { app: stringProperty, module: stringProperty } },
|
|
329
|
+
},
|
|
322
330
|
{
|
|
323
331
|
name: "get_guideline",
|
|
324
332
|
description: "Return an Akan agent guideline resource by name.",
|
|
@@ -32,6 +32,7 @@ Then pick the workflow that matches the intent:
|
|
|
32
32
|
| a new field with a closed set of values | `add-enum-field` |
|
|
33
33
|
| a new state-changing action | `add-mutation` |
|
|
34
34
|
| a filtered list, tab, or reusable query | `add-slice` |
|
|
35
|
+
| a slice or query key renamed, with every name derived from it | `rename-key` |
|
|
35
36
|
| a View / Unit / Template / Zone / Util for an existing module | `create-ui` |
|
|
36
37
|
|
|
37
38
|
## The chain
|
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
3
|
-
type BuilderRequest = BuilderReq | BuilderCsrReq;
|
|
4
|
-
type BuilderResponse = BuilderRes | BuilderCsrRes;
|
|
1
|
+
import type { BuilderRequest, BuilderResponse } from "akanjs/server";
|
|
5
2
|
|
|
6
3
|
// `BuilderRpc` numbers from 1 per backend process while the builder outlives backends, so ids collide across a
|
|
7
4
|
// restart; host-owned ids keep a dead generation's answer from settling the new backend's request.
|
|
@@ -108,6 +108,7 @@ describe("IncrementalBuilderHost", () => {
|
|
|
108
108
|
const { spawns, messages, host } = startRecordingHost();
|
|
109
109
|
expect(host.send({ type: "build-route", id: 1, routeId: "a", seeds: [], knownEntries: [] })).toBe(true);
|
|
110
110
|
expect(host.send({ type: "build-csr", id: 2, reason: "device webview" })).toBe(true);
|
|
111
|
+
expect(host.send({ type: "build-graph", id: 4 })).toBe(true);
|
|
111
112
|
// Answered before the exit, so this one must not be failed again afterwards.
|
|
112
113
|
expect(host.send({ type: "build-route", id: 3, routeId: "c", seeds: [], knownEntries: [] })).toBe(true);
|
|
113
114
|
spawns[0]?.options.ipc?.({ type: "build-route-res", id: 3, ok: true, data: { routeId: "c" } });
|
|
@@ -129,6 +130,12 @@ describe("IncrementalBuilderHost", () => {
|
|
|
129
130
|
ok: false,
|
|
130
131
|
error: "builder exited to release bundler memory before answering; reload to retry",
|
|
131
132
|
},
|
|
133
|
+
{
|
|
134
|
+
type: "build-graph-res",
|
|
135
|
+
id: 4,
|
|
136
|
+
ok: false,
|
|
137
|
+
error: "builder exited to release bundler memory before answering; reload to retry",
|
|
138
|
+
},
|
|
132
139
|
]);
|
|
133
140
|
|
|
134
141
|
// The replacement owes nothing, so its own exit stays quiet.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { Logger } from "akanjs/common";
|
|
4
|
-
import type { BuilderMessage } from "akanjs/server";
|
|
4
|
+
import type { BuilderMessage, BuilderRequest } from "akanjs/server";
|
|
5
5
|
import { CSR_DEV_DIRNAME, CSR_DEV_PATCHING_MARKER, SSR_DEV_DIRNAME } from "akanjs/server/hmr/csrDevManifest";
|
|
6
6
|
import { MemoryLimit } from "akanjs/server/memoryLimit";
|
|
7
7
|
import type { App } from "../commandDecorators";
|
|
@@ -9,6 +9,7 @@ import type { App } from "../commandDecorators";
|
|
|
9
9
|
const builderMsgTypeSet = new Set<BuilderMessage["type"]>([
|
|
10
10
|
"build-route-res",
|
|
11
11
|
"build-csr-res",
|
|
12
|
+
"build-graph-res",
|
|
12
13
|
"builder-ready",
|
|
13
14
|
"invalidate",
|
|
14
15
|
"css-updated",
|
|
@@ -73,7 +74,7 @@ export class IncrementalBuilderHost {
|
|
|
73
74
|
#manualStop = false;
|
|
74
75
|
// Nothing else answers a request whose builder exits holding it: a crash or kill sends nothing, and a drain races
|
|
75
76
|
// its own exit, so an unanswered page request would spin forever.
|
|
76
|
-
readonly #inFlight = new Map<number, "
|
|
77
|
+
readonly #inFlight = new Map<number, BuilderRequest["type"]>();
|
|
77
78
|
#startOptions: IncrementalBuilderStartOptions = {};
|
|
78
79
|
constructor({
|
|
79
80
|
app,
|
|
@@ -129,7 +130,8 @@ export class IncrementalBuilderHost {
|
|
|
129
130
|
ipc: (msg: BuilderMessage) => {
|
|
130
131
|
if (this.#proc !== proc) return;
|
|
131
132
|
if (!msg || typeof msg !== "object") return;
|
|
132
|
-
if (msg.type === "build-route-res" || msg.type === "build-csr-res"
|
|
133
|
+
if (msg.type === "build-route-res" || msg.type === "build-csr-res" || msg.type === "build-graph-res")
|
|
134
|
+
this.#inFlight.delete(msg.id);
|
|
133
135
|
if (builderMsgTypeSet.has(msg.type)) this.#onMessage(msg);
|
|
134
136
|
if (msg.type === "builder-ready" && !this.ready) {
|
|
135
137
|
this.ready = true;
|
|
@@ -269,7 +271,8 @@ export class IncrementalBuilderHost {
|
|
|
269
271
|
}
|
|
270
272
|
try {
|
|
271
273
|
this.#proc.send(message);
|
|
272
|
-
if (message.type === "build-route" || message.type === "build-csr"
|
|
274
|
+
if (message.type === "build-route" || message.type === "build-csr" || message.type === "build-graph")
|
|
275
|
+
this.#inFlight.set(message.id, message.type);
|
|
273
276
|
return true;
|
|
274
277
|
} catch (error) {
|
|
275
278
|
this.logger.warn(
|
|
@@ -129,6 +129,69 @@ process.send({ type: "build-batch-result", data: { generation, errors, artifact:
|
|
|
129
129
|
expect(messages.some((message) => message.type === "build-status")).toBe(false);
|
|
130
130
|
}, 30_000);
|
|
131
131
|
|
|
132
|
+
test("draws the app's graph on request, with the libs it reaches and no other app", async () => {
|
|
133
|
+
const { root } = track(await createTempApp("demo"));
|
|
134
|
+
const appDir = path.join(root, "apps/demo");
|
|
135
|
+
await writeText(
|
|
136
|
+
path.join(root, "pkgs/@akanjs/devkit/incrementalBuilder/buildBatch.proc.ts"),
|
|
137
|
+
`const { generation } = JSON.parse(process.argv[2]);
|
|
138
|
+
process.send({ type: "build-batch-result", data: { generation, errors: {}, artifact: {}, optimizedFonts: { css: "", files: [] } } });
|
|
139
|
+
`,
|
|
140
|
+
);
|
|
141
|
+
await writeText(path.join(appDir, "page/_index.tsx"), "export default () => null;\n");
|
|
142
|
+
const moduleFiles = (owner: string, name: string) => {
|
|
143
|
+
const model = `${name[0].toUpperCase()}${name.slice(1)}`;
|
|
144
|
+
return [
|
|
145
|
+
writeText(
|
|
146
|
+
path.join(root, owner, `lib/${name}/${name}.constant.ts`),
|
|
147
|
+
`export class ${model} extends via((field) => ({ name: field(String) })) {}\n`,
|
|
148
|
+
),
|
|
149
|
+
writeText(
|
|
150
|
+
path.join(root, owner, `lib/${name}/${name}.service.ts`),
|
|
151
|
+
`export class ${model}Service extends serve(db.${name}, () => ({})) {}\n`,
|
|
152
|
+
),
|
|
153
|
+
];
|
|
154
|
+
};
|
|
155
|
+
await Promise.all([
|
|
156
|
+
...moduleFiles("apps/demo", "memo"),
|
|
157
|
+
...moduleFiles("apps/other", "note"),
|
|
158
|
+
...moduleFiles("libs/shared", "file"),
|
|
159
|
+
]);
|
|
160
|
+
const messages: BuilderMessage[] = [];
|
|
161
|
+
const proc = Bun.spawn(["bun", path.join(import.meta.dir, "incrementalBuilder.proc.ts")], {
|
|
162
|
+
cwd: appDir,
|
|
163
|
+
env: {
|
|
164
|
+
...process.env,
|
|
165
|
+
AKAN_WORKSPACE_ROOT: root,
|
|
166
|
+
AKAN_PUBLIC_APP_NAME: "demo",
|
|
167
|
+
AKAN_PUBLIC_REPO_NAME: "repo",
|
|
168
|
+
AKAN_PUBLIC_SERVE_DOMAIN: "localhost",
|
|
169
|
+
AKAN_PUBLIC_ENV: "local",
|
|
170
|
+
AKAN_WATCH: "0",
|
|
171
|
+
},
|
|
172
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
173
|
+
serialization: "advanced",
|
|
174
|
+
ipc: (message: BuilderMessage) => {
|
|
175
|
+
messages.push(message);
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
try {
|
|
179
|
+
await until(proc, "builder-ready", () => messages.some((message) => message.type === "builder-ready"));
|
|
180
|
+
proc.send({ type: "build-graph", id: 5 } satisfies BuilderMessage);
|
|
181
|
+
await until(proc, "build-graph-res", () => messages.some((message) => message.type === "build-graph-res"));
|
|
182
|
+
} finally {
|
|
183
|
+
proc.kill();
|
|
184
|
+
await proc.exited;
|
|
185
|
+
}
|
|
186
|
+
const answer = messages.find((message) => message.type === "build-graph-res");
|
|
187
|
+
if (answer?.type !== "build-graph-res" || !answer.ok) throw new Error(`no graph: ${JSON.stringify(answer)}`);
|
|
188
|
+
expect(answer.data.workspaceRoot).toBe(root);
|
|
189
|
+
expect(answer.data.graph.nodes.map((node) => node.id).sort()).toEqual([
|
|
190
|
+
"model:apps/demo:memo",
|
|
191
|
+
"model:libs/shared:file",
|
|
192
|
+
]);
|
|
193
|
+
}, 30_000);
|
|
194
|
+
|
|
132
195
|
test("answers a second shell request in the same generation without building again", async () => {
|
|
133
196
|
const { root } = track(await createTempApp("demo"));
|
|
134
197
|
const appDir = path.join(root, "apps/demo");
|
|
@@ -18,13 +18,16 @@ import { RouteClientBuilder } from "@akanjs/devkit/frontendBuild/routeClientBuil
|
|
|
18
18
|
import { ServerGraphFile } from "@akanjs/devkit/frontendBuild/serverGraphFile";
|
|
19
19
|
import { SsrDevBundler } from "@akanjs/devkit/frontendBuild/ssrDevBundler";
|
|
20
20
|
import { WatchRootResolver } from "@akanjs/devkit/frontendBuild/watchRootResolver";
|
|
21
|
+
import { WorkspaceGraph } from "@akanjs/devkit/workspaceGraph";
|
|
21
22
|
import { Logger } from "akanjs/common";
|
|
22
23
|
import type {
|
|
23
24
|
BaseBuildArtifact,
|
|
24
25
|
BuilderCsrReq,
|
|
26
|
+
BuilderGraphReq,
|
|
25
27
|
BuilderMessage,
|
|
26
28
|
BuilderReq,
|
|
27
29
|
BuilderRes,
|
|
30
|
+
BuildGraphPayload,
|
|
28
31
|
BuildPhase,
|
|
29
32
|
BuildRouteResultPayload,
|
|
30
33
|
ChangeBatch,
|
|
@@ -64,6 +67,7 @@ class IncrementalBuilder {
|
|
|
64
67
|
#watcher: HmrWatcher | null = null;
|
|
65
68
|
#generation = 0;
|
|
66
69
|
#shellKey: string | null = null;
|
|
70
|
+
#graph: { generation: number; payload: BuildGraphPayload } | null = null;
|
|
67
71
|
#discoveredThrough = 0;
|
|
68
72
|
#csrActive = IncrementalBuilder.#csrArmedByEnv();
|
|
69
73
|
#csrBundler: CsrDevBundler;
|
|
@@ -873,6 +877,22 @@ class IncrementalBuilder {
|
|
|
873
877
|
this.#logger.verbose(`announced boot state after recycle generation=${generation}`);
|
|
874
878
|
}
|
|
875
879
|
|
|
880
|
+
async handleBuildGraph(msg: BuilderGraphReq): Promise<void> {
|
|
881
|
+
const generation = this.#generation;
|
|
882
|
+
try {
|
|
883
|
+
if (this.#graph?.generation !== generation) {
|
|
884
|
+
const { workspace } = this.#app;
|
|
885
|
+
const graph = WorkspaceGraph.visibleTo(await WorkspaceGraph.build(workspace), this.#app.name);
|
|
886
|
+
this.#graph = { generation, payload: { workspaceRoot: workspace.workspaceRoot, graph } };
|
|
887
|
+
}
|
|
888
|
+
await BuilderChannel.send({ type: "build-graph-res", id: msg.id, ok: true, data: this.#graph.payload });
|
|
889
|
+
} catch (err) {
|
|
890
|
+
const error = ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot);
|
|
891
|
+
this.#logger.error(`build-graph failed: ${error}`);
|
|
892
|
+
await BuilderChannel.send({ type: "build-graph-res", id: msg.id, ok: false, error });
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
876
896
|
// On demand: dev serves CSR only via the opt-in `/__csr` and `?csr=true` routes; once built, it rebuilds every save.
|
|
877
897
|
async handleBuildCsr(msg: BuilderCsrReq): Promise<void> {
|
|
878
898
|
if (msg.shell) {
|
|
@@ -1080,13 +1100,18 @@ class IncrementalBuilder {
|
|
|
1080
1100
|
void builder.shutdown(msg.reason);
|
|
1081
1101
|
return;
|
|
1082
1102
|
}
|
|
1083
|
-
if (msg.type !== "build-route" && msg.type !== "build-csr") return;
|
|
1103
|
+
if (msg.type !== "build-route" && msg.type !== "build-csr" && msg.type !== "build-graph") return;
|
|
1084
1104
|
if (!builder || builder.shuttingDown) {
|
|
1085
1105
|
const error = builder?.shuttingDown ? recyclingError : bootingError;
|
|
1086
1106
|
BuilderChannel.emit({ type: `${msg.type}-res`, id: msg.id, ok: false, error });
|
|
1087
1107
|
return;
|
|
1088
1108
|
}
|
|
1089
|
-
const handled =
|
|
1109
|
+
const handled =
|
|
1110
|
+
msg.type === "build-route"
|
|
1111
|
+
? builder.handleBuildRoute(msg)
|
|
1112
|
+
: msg.type === "build-csr"
|
|
1113
|
+
? builder.handleBuildCsr(msg)
|
|
1114
|
+
: builder.handleBuildGraph(msg);
|
|
1090
1115
|
// Unhandled, a rejection (an ipc write that failed) would exit the builder and drop the watcher with it.
|
|
1091
1116
|
void handled.catch((err: unknown) =>
|
|
1092
1117
|
logger.error(`${msg.type} failed: ${err instanceof Error ? err.message : String(err)}`),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "3.1.2-rc.
|
|
3
|
+
"version": "3.1.2-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.1.2-rc.
|
|
44
|
+
"@akanjs/language": "3.1.2-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.1.2-rc.
|
|
53
|
+
"akanjs": "3.1.2-rc.3",
|
|
54
54
|
"chalk": "^6.0.1",
|
|
55
55
|
"commander": "^15.0.0",
|
|
56
56
|
"dayjs": "^1.11.23",
|
package/workflow/executor.ts
CHANGED
|
@@ -404,6 +404,7 @@ export const createWorkflowStepRegistry = ({
|
|
|
404
404
|
addEnumField,
|
|
405
405
|
addMutation,
|
|
406
406
|
addSlice,
|
|
407
|
+
renameKey,
|
|
407
408
|
createInteraction,
|
|
408
409
|
}: WorkflowPrimitiveOperations): WorkflowStepRegistry => {
|
|
409
410
|
const inspect = async () => undefined;
|
|
@@ -520,6 +521,16 @@ export const createWorkflowStepRegistry = ({
|
|
|
520
521
|
),
|
|
521
522
|
[workflowStepKey("add-slice", "update-signal-slice")]: inspect,
|
|
522
523
|
[workflowStepKey("add-slice", "connect-view-surfaces")]: async (_step, plan) => addSliceViewSurfaceInspection(plan),
|
|
524
|
+
[workflowStepKey("rename-key", "rename-key")]: async (_step, plan) =>
|
|
525
|
+
primitiveReportToWorkflowStepResult(
|
|
526
|
+
await renameKey({
|
|
527
|
+
app: workflowStringInput(plan.inputs.app),
|
|
528
|
+
module: workflowStringInput(plan.inputs.module),
|
|
529
|
+
slice: workflowStringInput(plan.inputs.slice),
|
|
530
|
+
query: workflowStringInput(plan.inputs.query),
|
|
531
|
+
to: workflowStringInput(plan.inputs.to),
|
|
532
|
+
}),
|
|
533
|
+
),
|
|
523
534
|
};
|
|
524
535
|
};
|
|
525
536
|
|
package/workflow/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ export * from "./artifacts";
|
|
|
2
2
|
export * from "./executor";
|
|
3
3
|
export * from "./guardCatalog";
|
|
4
4
|
export * from "./interactionScaffold";
|
|
5
|
+
export * from "./keyRename";
|
|
5
6
|
export * from "./moduleIndex";
|
|
6
7
|
export * from "./mutationScaffold";
|
|
7
8
|
export * from "./mutationSource";
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { AkanIndex, type AkanRenameEdit } from "@akanjs/language";
|
|
4
|
+
import ts from "typescript";
|
|
5
|
+
import type { Workspace } from "../commandDecorators";
|
|
6
|
+
import { resolveWorkflowSys } from "./executor";
|
|
7
|
+
import { getSysRoot, moduleSourcePaths } from "./source";
|
|
8
|
+
import type { RenameKeyInput, ScaffoldFile, WorkflowDiagnostic } from "./types";
|
|
9
|
+
|
|
10
|
+
const indexedPath = /^(apps|libs)\/(?!(?:.*\/)?(?:node_modules|dist|\.akan)\/).*\.tsx?$/;
|
|
11
|
+
const keyPattern = /^[a-z][A-Za-z0-9]*$/;
|
|
12
|
+
const slashed = (fileName: string) => fileName.replace(/\\/g, "/");
|
|
13
|
+
|
|
14
|
+
const refuse = (code: string, input: string, message: string) => ({
|
|
15
|
+
diagnostics: [{ severity: "error", code, input, message }] satisfies WorkflowDiagnostic[],
|
|
16
|
+
files: [] as ScaffoldFile[],
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export class KeyRename {
|
|
20
|
+
static async build(workspace: Workspace, input: RenameKeyInput) {
|
|
21
|
+
const sys = await resolveWorkflowSys(workspace, input.app);
|
|
22
|
+
if (!sys) return refuse("workflow-target-missing", "app", `No app or library named ${input.app ?? ""}.`);
|
|
23
|
+
if (!input.module) return refuse("workflow-input-missing", "module", "Module name is required.");
|
|
24
|
+
if (!input.slice === !input.query)
|
|
25
|
+
return refuse("rename-key-source-invalid", "slice", "Name exactly one key to rename, as slice or as query.");
|
|
26
|
+
const family = input.slice ? "slice" : "query";
|
|
27
|
+
const from = input.slice ?? input.query ?? "";
|
|
28
|
+
if (!input.to || !keyPattern.test(input.to))
|
|
29
|
+
return refuse(
|
|
30
|
+
"rename-key-name-invalid",
|
|
31
|
+
"to",
|
|
32
|
+
`New key "${input.to ?? ""}" must be camelCase (inFeed, byTitle).`,
|
|
33
|
+
);
|
|
34
|
+
if (input.to === from) return refuse("rename-key-name-same", "to", `New key is the same as ${from}.`);
|
|
35
|
+
|
|
36
|
+
const root = workspace.workspaceRoot;
|
|
37
|
+
const paths = moduleSourcePaths(input.module);
|
|
38
|
+
const sourceRelative = `${getSysRoot(sys)}/${family === "slice" ? paths.signal : paths.document}`;
|
|
39
|
+
const sourcePath = slashed(path.join(root, sourceRelative));
|
|
40
|
+
const texts = new Map<string, string>();
|
|
41
|
+
const index = new AkanIndex({ ts, root });
|
|
42
|
+
for (const relative of await workspace.getAllFiles("{apps,libs}/**/*.{ts,tsx}")) {
|
|
43
|
+
if (!indexedPath.test(slashed(relative))) continue;
|
|
44
|
+
const fileName = slashed(path.join(root, relative));
|
|
45
|
+
const text = await readFile(fileName, "utf8");
|
|
46
|
+
texts.set(fileName, text);
|
|
47
|
+
index.setFile(fileName, text);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const keys = index.sourceKeysOf(sourcePath).filter((key) => key.family === family);
|
|
51
|
+
const source = keys.find((key) => key.sourceKey === from);
|
|
52
|
+
if (!source)
|
|
53
|
+
return refuse("rename-key-source-missing", family, `${sourceRelative} declares no ${family} key ${from}.`);
|
|
54
|
+
if (keys.some((key) => key.sourceKey === input.to))
|
|
55
|
+
return refuse("rename-key-exists", "to", `${sourceRelative} already declares the ${family} key ${input.to}.`);
|
|
56
|
+
const edits = index.renameAt(sourcePath, source.span.start, input.to, (fileName) => texts.get(fileName)) ?? [];
|
|
57
|
+
|
|
58
|
+
const byFile = new Map<string, AkanRenameEdit[]>();
|
|
59
|
+
for (const edit of edits) byFile.set(edit.span.fileName, [...(byFile.get(edit.span.fileName) ?? []), edit]);
|
|
60
|
+
const files: ScaffoldFile[] = [...byFile].map(([fileName, fileEdits]) => {
|
|
61
|
+
let content = texts.get(fileName) ?? "";
|
|
62
|
+
for (const { span, newText } of fileEdits.toSorted((a, b) => b.span.start - a.span.start))
|
|
63
|
+
content = content.slice(0, span.start) + newText + content.slice(span.end);
|
|
64
|
+
const names = fileEdits.length === 1 ? "1 name changes" : `${fileEdits.length} names change`;
|
|
65
|
+
return {
|
|
66
|
+
path: slashed(path.relative(root, fileName)),
|
|
67
|
+
content,
|
|
68
|
+
action: "modify",
|
|
69
|
+
reason: `${names} with the ${family} key ${from} → ${input.to}.`,
|
|
70
|
+
};
|
|
71
|
+
});
|
|
72
|
+
return { diagnostics: [] as WorkflowDiagnostic[], files: files.toSorted((a, b) => a.path.localeCompare(b.path)) };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
static targetsOf(files: readonly { path: string }[]) {
|
|
76
|
+
return [...new Set(files.map((file) => file.path.split("/").slice(0, 2).join("/")))];
|
|
77
|
+
}
|
|
78
|
+
}
|
package/workflow/plan.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { Workspace } from "../commandDecorators";
|
|
|
3
3
|
import { workflowPlanApproval } from "./artifacts";
|
|
4
4
|
import { resolveWorkflowSys } from "./executor";
|
|
5
5
|
import { InteractionScaffold } from "./interactionScaffold";
|
|
6
|
+
import { KeyRename } from "./keyRename";
|
|
6
7
|
import { MutationScaffold } from "./mutationScaffold";
|
|
7
8
|
import { coerceFieldDefault, moduleSourcePaths } from "./source";
|
|
8
9
|
import type {
|
|
@@ -28,6 +29,8 @@ const nameInputPatterns: Record<string, RegExp> = {
|
|
|
28
29
|
scalar: identifierPattern,
|
|
29
30
|
field: identifierPattern,
|
|
30
31
|
slice: identifierPattern,
|
|
32
|
+
query: identifierPattern,
|
|
33
|
+
to: identifierPattern,
|
|
31
34
|
mutation: identifierPattern,
|
|
32
35
|
interaction: identifierPattern,
|
|
33
36
|
};
|
|
@@ -458,7 +461,26 @@ const planString = (inputs: Record<string, WorkflowInputValue>, name: string) =>
|
|
|
458
461
|
const planStrings = (inputs: Record<string, WorkflowInputValue>, name: string) =>
|
|
459
462
|
Array.isArray(inputs[name]) ? inputs[name] : null;
|
|
460
463
|
|
|
464
|
+
const inspectRenameKeyPlan = async (workspace: Workspace, plan: WorkflowPlan): Promise<WorkflowPlan> => {
|
|
465
|
+
if (plan.diagnostics.some((diagnostic) => diagnostic.severity === "error")) return plan;
|
|
466
|
+
const { diagnostics, files } = await KeyRename.build(workspace, {
|
|
467
|
+
app: planString(plan.inputs, "app"),
|
|
468
|
+
module: planString(plan.inputs, "module"),
|
|
469
|
+
slice: planString(plan.inputs, "slice"),
|
|
470
|
+
query: planString(plan.inputs, "query"),
|
|
471
|
+
to: planString(plan.inputs, "to"),
|
|
472
|
+
});
|
|
473
|
+
return {
|
|
474
|
+
...plan,
|
|
475
|
+
diagnostics: [...plan.diagnostics, ...diagnostics],
|
|
476
|
+
predictedChanges: files.length
|
|
477
|
+
? files.map((file) => ({ target: file.path, action: "modify", applyScope: "auto", reason: file.reason }))
|
|
478
|
+
: plan.predictedChanges,
|
|
479
|
+
};
|
|
480
|
+
};
|
|
481
|
+
|
|
461
482
|
export const inspectWorkflowPlan = async (workspace: Workspace, plan: WorkflowPlan): Promise<WorkflowPlan> => {
|
|
483
|
+
if (plan.workflow === "rename-key") return await inspectRenameKeyPlan(workspace, plan);
|
|
462
484
|
if (plan.workflow !== "add-mutation" && plan.workflow !== "create-interaction") return plan;
|
|
463
485
|
const app = planString(plan.inputs, "app");
|
|
464
486
|
if (!app) return plan;
|
package/workflow/types.ts
CHANGED
|
@@ -63,6 +63,12 @@ export interface AddSliceInput extends PrimitiveTargetInput {
|
|
|
63
63
|
slice: string | null;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
export interface RenameKeyInput extends PrimitiveTargetInput {
|
|
67
|
+
slice: string | null;
|
|
68
|
+
query: string | null;
|
|
69
|
+
to: string | null;
|
|
70
|
+
}
|
|
71
|
+
|
|
66
72
|
export interface WorkflowInputSpec {
|
|
67
73
|
type: WorkflowInputType;
|
|
68
74
|
required?: boolean;
|
|
@@ -498,5 +504,6 @@ export interface WorkflowPrimitiveOperations {
|
|
|
498
504
|
addEnumField: (input: AddEnumFieldInput) => Promise<PrimitiveWriteReport>;
|
|
499
505
|
addMutation: (input: AddMutationInput) => Promise<PrimitiveWriteReport>;
|
|
500
506
|
addSlice: (input: AddSliceInput) => Promise<PrimitiveWriteReport>;
|
|
507
|
+
renameKey: (input: RenameKeyInput) => Promise<PrimitiveWriteReport>;
|
|
501
508
|
createInteraction: (input: CreateInteractionInput) => Promise<PrimitiveWriteReport>;
|
|
502
509
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { type AkanGraph, type AkanGraphNode, AkanIndex } from "@akanjs/language";
|
|
4
|
+
import ts from "typescript";
|
|
5
|
+
import type { Workspace } from "./commandDecorators";
|
|
6
|
+
|
|
7
|
+
export interface GraphScope {
|
|
8
|
+
app?: string | null;
|
|
9
|
+
module?: string | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
//? `env/` holds each environment's secrets in plain TypeScript; the graph never needs to read it.
|
|
13
|
+
const indexedPath = /^(apps|libs)\/(?!(?:.*\/)?(?:node_modules|dist|\.akan|env)\/).*\.tsx?$/;
|
|
14
|
+
const unscopedNodeLimit = 600;
|
|
15
|
+
const slashed = (fileName: string) => fileName.replace(/\\/g, "/");
|
|
16
|
+
|
|
17
|
+
export class WorkspaceGraph {
|
|
18
|
+
static async build(workspace: Workspace): Promise<AkanGraph> {
|
|
19
|
+
const root = workspace.workspaceRoot;
|
|
20
|
+
const index = new AkanIndex({ ts, root });
|
|
21
|
+
const texts = new Map<string, string>();
|
|
22
|
+
const files = (await workspace.getAllFiles("{apps,libs}/**/*.{ts,tsx}"))
|
|
23
|
+
.map(slashed)
|
|
24
|
+
.filter((file) => indexedPath.test(file));
|
|
25
|
+
await Promise.all(
|
|
26
|
+
files.map(async (file) => {
|
|
27
|
+
const fileName = slashed(path.join(root, file));
|
|
28
|
+
const text = await readFile(fileName, "utf8");
|
|
29
|
+
texts.set(fileName, text);
|
|
30
|
+
index.setFile(fileName, text);
|
|
31
|
+
}),
|
|
32
|
+
);
|
|
33
|
+
return index.graph((fileName) => texts.get(fileName));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
static scope(graph: AkanGraph, { app, module }: GraphScope): AkanGraph {
|
|
37
|
+
if (!app && !module) return graph;
|
|
38
|
+
const owners = app ? new Set([`apps/${app}`, `libs/${app}`]) : null;
|
|
39
|
+
const seeds = new Set(
|
|
40
|
+
graph.nodes
|
|
41
|
+
.filter((node) => (!owners || owners.has(node.owner ?? "")) && (!module || node.module === module))
|
|
42
|
+
.map((node) => node.id),
|
|
43
|
+
);
|
|
44
|
+
const edges = graph.edges.filter((edge) => seeds.has(edge.from) || seeds.has(edge.to));
|
|
45
|
+
const ids = new Set([...seeds, ...edges.flatMap((edge) => [edge.from, edge.to])]);
|
|
46
|
+
return { version: 1, nodes: graph.nodes.filter((node) => ids.has(node.id)), edges };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
static visibleTo(graph: AkanGraph, app: string): AkanGraph {
|
|
50
|
+
const appOwner = `apps/${app}`;
|
|
51
|
+
const isVisible = ({ owner }: AkanGraphNode) => !owner || owner === appOwner || owner.startsWith("libs/");
|
|
52
|
+
const nodes = graph.nodes.filter(isVisible);
|
|
53
|
+
const ids = new Set(nodes.map((node) => node.id));
|
|
54
|
+
return { version: 1, nodes, edges: graph.edges.filter((edge) => ids.has(edge.from) && ids.has(edge.to)) };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
static answer(graph: AkanGraph, scope: GraphScope) {
|
|
58
|
+
if (scope.app || scope.module || graph.nodes.length <= unscopedNodeLimit) return WorkspaceGraph.scope(graph, scope);
|
|
59
|
+
const owners = new Map<string, number>();
|
|
60
|
+
for (const node of graph.nodes) if (node.owner) owners.set(node.owner, (owners.get(node.owner) ?? 0) + 1);
|
|
61
|
+
return {
|
|
62
|
+
diagnostics: [
|
|
63
|
+
{
|
|
64
|
+
severity: "error",
|
|
65
|
+
code: "graph-scope-required",
|
|
66
|
+
message: `The graph has ${graph.nodes.length} nodes. Pass app, module or both to scope it.`,
|
|
67
|
+
input: "app",
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
owners: Object.fromEntries([...owners].sort(([a], [b]) => (a < b ? -1 : 1))),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
}
|