@akanjs/devkit 3.2.0-rc.6 → 3.2.0-rc.8
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 +55 -0
- package/akanApp/akanApp.host.test.ts +76 -0
- package/akanApp/akanApp.host.ts +47 -54
- package/akanApp/devHostPolicy.test.ts +11 -11
- package/akanApp/devHostPolicy.ts +2 -4
- package/akanMcpContract.ts +17 -7
- package/akanMcpToolError.ts +16 -0
- package/apiReport/apiReport.test.ts +14 -0
- package/apiReport/apiReportPolicy.ts +4 -3
- package/codeAgent/skills/akan-debug/SKILL.md +1 -1
- package/codeAgent/skills/akan-mcp/SKILL.md +2 -1
- package/codeAgent/skills/akan-scaffold/SKILL.md +1 -1
- package/codeAgent/skills/akan-validate/SKILL.md +1 -0
- package/codeAgent/tools/McpClient.ts +28 -6
- package/codeAgent/tools/McpToolPack.ts +1 -1
- package/codeAgent/tools/akanCodePlugins.test.ts +13 -1
- package/commandDecorators/cliError.ts +72 -0
- package/commandDecorators/command.ts +112 -57
- package/commandDecorators/commandDecorators.test.ts +92 -6
- package/commandDecorators/exitCodeError.ts +6 -7
- package/commandDecorators/index.ts +1 -0
- package/commandDecorators/targetMeta.ts +1 -0
- package/incrementalBuilder/incrementalBuilder.host.test.ts +25 -0
- package/incrementalBuilder/incrementalBuilder.host.ts +8 -21
- package/package.json +3 -3
- package/typecheck/projectTypecheck.ts +5 -1
- package/typecheck/typecheck.proc.ts +3 -2
- package/typecheck/typecheckFailure.ts +6 -1
- package/workflow/artifacts.ts +8 -7
- package/workflow/fieldType.ts +92 -0
- package/workflow/index.ts +1 -0
- package/workflow/plan.ts +13 -2
- package/workflow/source.ts +0 -5
|
@@ -748,7 +748,7 @@ describe("a target left out where no terminal can ask for it", () => {
|
|
|
748
748
|
|
|
749
749
|
test("exits at once with the choices and the line to rerun when there are several", async () => {
|
|
750
750
|
const refused = await run(["web", "admin"]);
|
|
751
|
-
expect(refused.code).toBe(
|
|
751
|
+
expect(refused.code).toBe(2);
|
|
752
752
|
expect(refused.stdout).toContain(
|
|
753
753
|
"Missing app or lib: stdin is not a terminal to prompt on, so rerun it as `akan create-thing photo <app-or-lib> --force`",
|
|
754
754
|
);
|
|
@@ -758,7 +758,7 @@ describe("a target left out where no terminal can ask for it", () => {
|
|
|
758
758
|
|
|
759
759
|
test("a destructive command exits with the line to rerun even when the workspace has one app", async () => {
|
|
760
760
|
const refused = await run(["web"], ["remove-thing"]);
|
|
761
|
-
expect(refused.code).toBe(
|
|
761
|
+
expect(refused.code).toBe(2);
|
|
762
762
|
expect(refused.stdout).toContain(
|
|
763
763
|
"Missing app: this command deletes files and never picks its target, so rerun it as `akan remove-thing <app>` (choices: web).",
|
|
764
764
|
);
|
|
@@ -769,7 +769,10 @@ describe("a target left out where no terminal can ask for it", () => {
|
|
|
769
769
|
|
|
770
770
|
describe("the exit code of a command that failed", () => {
|
|
771
771
|
const track = tempRoots();
|
|
772
|
-
const run = async (
|
|
772
|
+
const run = async (
|
|
773
|
+
failure: string,
|
|
774
|
+
{ option = "", args = [], argv = ["verdict", ...args] }: { option?: string; args?: string[]; argv?: string[] } = {},
|
|
775
|
+
) => {
|
|
773
776
|
const { root } = track(await makeCliTempWorkspace());
|
|
774
777
|
await writeText(path.join(root, "package.json"), '{ "name": "repo", "version": "1.0.0" }\n');
|
|
775
778
|
await writeText(path.join(root, "tsconfig.json"), "{}\n");
|
|
@@ -777,7 +780,8 @@ describe("the exit code of a command that failed", () => {
|
|
|
777
780
|
await writeText(
|
|
778
781
|
entry,
|
|
779
782
|
[
|
|
780
|
-
`import {
|
|
783
|
+
`import { AkanError } from ${JSON.stringify(Bun.resolveSync("akanjs/base", import.meta.dir))};`,
|
|
784
|
+
`import { CliError, command, ExitCodeError, markCliErrorPrinted, runCommands, runner, script } from ${JSON.stringify(path.join(import.meta.dir, "index.ts"))};`,
|
|
781
785
|
'class VerdictRunner extends runner("verdict") {}',
|
|
782
786
|
'class VerdictScript extends script("verdict", [VerdictRunner]) {}',
|
|
783
787
|
'class VerdictCommand extends command("verdict", [VerdictScript], ({ public: target }) => ({',
|
|
@@ -789,7 +793,7 @@ describe("the exit code of a command that failed", () => {
|
|
|
789
793
|
"",
|
|
790
794
|
].join("\n"),
|
|
791
795
|
);
|
|
792
|
-
const proc = Bun.spawn(["bun", entry,
|
|
796
|
+
const proc = Bun.spawn(["bun", entry, ...argv], { cwd: root, stdout: "pipe", stderr: "pipe" });
|
|
793
797
|
const [stdout, stderr, code] = await Promise.all([
|
|
794
798
|
new Response(proc.stdout).text(),
|
|
795
799
|
new Response(proc.stderr).text(),
|
|
@@ -805,6 +809,28 @@ describe("the exit code of a command that failed", () => {
|
|
|
805
809
|
expect((await run('new Error("broke")')).code).toBe(1);
|
|
806
810
|
}, 30_000);
|
|
807
811
|
|
|
812
|
+
test("is the table's for a CliError's code, and 1 for an AkanError of any other code", async () => {
|
|
813
|
+
const [checked, unknown, gone] = await Promise.all([
|
|
814
|
+
run('new CliError("checkFailed", "found problems")'),
|
|
815
|
+
run('new CliError("unknownTarget", "no app shop")'),
|
|
816
|
+
run('new AkanError("documentNotFound", "gone")'),
|
|
817
|
+
]);
|
|
818
|
+
expect([checked.code, unknown.code, gone.code]).toEqual([3, 2, 1]);
|
|
819
|
+
expect(checked.stdout).toContain("found problems");
|
|
820
|
+
}, 30_000);
|
|
821
|
+
|
|
822
|
+
test("is 2 for a flag commander refuses, a value outside the choices and an unknown command", async () => {
|
|
823
|
+
const choice = ' }).option("mode", String, { enum: ["a", "b"]';
|
|
824
|
+
const [flag, outside, unknownCommand] = await Promise.all([
|
|
825
|
+
run('new Error("broke")', { args: ["--nope"] }),
|
|
826
|
+
run('new Error("broke")', { option: choice, args: ["--mode", "c"] }),
|
|
827
|
+
run('new Error("broke")', { argv: ["verdikt"] }),
|
|
828
|
+
]);
|
|
829
|
+
expect([flag.code, outside.code, unknownCommand.code]).toEqual([2, 2, 2]);
|
|
830
|
+
expect(outside.stdout).toContain("option '--mode' argument 'c' is invalid");
|
|
831
|
+
expect(unknownCommand.stderr).toContain("unknown command 'verdikt'");
|
|
832
|
+
}, 30_000);
|
|
833
|
+
|
|
808
834
|
test("is usageExitCode for a flag commander refuses before the command runs, failureExitCode after", async () => {
|
|
809
835
|
const option = ", failureExitCode: 4, usageExitCode: 2";
|
|
810
836
|
expect((await run('new Error("broke")', { option, args: ["--nope"] })).code).toBe(2);
|
|
@@ -832,7 +858,7 @@ describe("the exit code of a command that failed", () => {
|
|
|
832
858
|
|
|
833
859
|
const thrown = await run('new Error("broke")', { option, args: ["--json"] });
|
|
834
860
|
expect(thrown.code).toBe(1);
|
|
835
|
-
expect(thrown.
|
|
861
|
+
expect(thrown.stderr).toContain("broke\n");
|
|
836
862
|
expect(lastOf(thrown.stdout)).toEqual({ message: "broke", usage: false, given: { json: true } });
|
|
837
863
|
|
|
838
864
|
const help = await run('new Error("broke")', { option, args: ["--help"] });
|
|
@@ -840,6 +866,66 @@ describe("the exit code of a command that failed", () => {
|
|
|
840
866
|
expect(help.stdout).not.toContain('"usage"');
|
|
841
867
|
}, 60_000);
|
|
842
868
|
|
|
869
|
+
test("with JSON asked for, ends stdout with ok, error and code, and prints the message to stderr", async () => {
|
|
870
|
+
const json = ' }).option("json", Boolean, { default: false';
|
|
871
|
+
const format = ' }).option("format", String, { default: "text", enum: ["text", "json"]';
|
|
872
|
+
const marked =
|
|
873
|
+
'(() => { const error = new CliError("checkFailed", "verify failed"); markCliErrorPrinted(error); return error; })()';
|
|
874
|
+
const [unknown, plain, refused, answered, text] = await Promise.all([
|
|
875
|
+
run('new CliError("unknownTarget", "no app shop")', { option: json, args: ["--json"] }),
|
|
876
|
+
run('new Error("broke")', { option: format, args: ["--format", "json"] }),
|
|
877
|
+
run('new Error("broke")', { option: json, args: ["--json", "--nope"] }),
|
|
878
|
+
run(marked, { option: format, args: ["--format", "json"] }),
|
|
879
|
+
run('new Error("broke")', { option: format }),
|
|
880
|
+
]);
|
|
881
|
+
expect(unknown.code).toBe(2);
|
|
882
|
+
expect(JSON.parse(unknown.stdout)).toEqual({ ok: false, error: "no app shop", code: "unknownTarget" });
|
|
883
|
+
expect(unknown.stderr).toContain("no app shop");
|
|
884
|
+
expect(plain.code).toBe(1);
|
|
885
|
+
expect(JSON.parse(plain.stdout)).toEqual({ ok: false, error: "broke", code: "failed" });
|
|
886
|
+
expect(refused.code).toBe(2);
|
|
887
|
+
expect(JSON.parse(refused.stdout)).toEqual({ ok: false, error: "unknown option '--nope'", code: "usage" });
|
|
888
|
+
expect(answered.code).toBe(3);
|
|
889
|
+
expect(answered.stdout.trim()).toBe("");
|
|
890
|
+
expect(text.stdout).toContain("broke\n");
|
|
891
|
+
expect(text.stdout).not.toContain('"code"');
|
|
892
|
+
}, 60_000);
|
|
893
|
+
|
|
894
|
+
test("is usageExitCode for a usage or unknown-target failure where 2 answers something else", async () => {
|
|
895
|
+
const option = ", usageExitCode: 1";
|
|
896
|
+
const [flag, unknown, verdict] = await Promise.all([
|
|
897
|
+
run('new Error("broke")', { option, args: ["--nope"] }),
|
|
898
|
+
run('new CliError("unknownTarget", "Unknown app \'shop\'")', { option }),
|
|
899
|
+
run('new ExitCodeError("needs a new store build", 2)', { option }),
|
|
900
|
+
]);
|
|
901
|
+
expect([flag.code, unknown.code, verdict.code]).toEqual([1, 1, 2]);
|
|
902
|
+
}, 30_000);
|
|
903
|
+
|
|
904
|
+
test("is 130 for a prompt closed with Ctrl+C", async () => {
|
|
905
|
+
const closed = await run(
|
|
906
|
+
'Object.assign(new Error("User force closed the prompt with SIGINT"), { name: "ExitPromptError" })',
|
|
907
|
+
);
|
|
908
|
+
expect(closed.code).toBe(130);
|
|
909
|
+
}, 30_000);
|
|
910
|
+
|
|
911
|
+
test("answers JSON for an ExitCodeError, a thrown object and an unhandled rejection", async () => {
|
|
912
|
+
const json = ' }).option("json", Boolean, { default: false';
|
|
913
|
+
const late = 'await (void Promise.reject(new CliError("refused", "late")), new Promise(() => {}))';
|
|
914
|
+
const [verdict, checked, thrown, rejected] = await Promise.all([
|
|
915
|
+
run('new ExitCodeError("needs a new store build", 2)', { option: json, args: ["--json"] }),
|
|
916
|
+
run('new ExitCodeError("found problems", 3)', { option: json, args: ["--json"] }),
|
|
917
|
+
run('{ reason: "an object" }', { option: json, args: ["--json"] }),
|
|
918
|
+
run(late, { option: json, args: ["--json"] }),
|
|
919
|
+
]);
|
|
920
|
+
expect(JSON.parse(verdict.stdout)).toEqual({ ok: false, error: "needs a new store build", code: "refused" });
|
|
921
|
+
expect(verdict.code).toBe(2);
|
|
922
|
+
expect(JSON.parse(checked.stdout)).toEqual({ ok: false, error: "found problems", code: "checkFailed" });
|
|
923
|
+
expect(checked.code).toBe(3);
|
|
924
|
+
expect(JSON.parse(thrown.stdout)).toEqual({ ok: false, error: '{"reason":"an object"}', code: "failed" });
|
|
925
|
+
expect(JSON.parse(rejected.stdout)).toEqual({ ok: false, error: "late", code: "refused" });
|
|
926
|
+
expect(rejected.code).toBe(2);
|
|
927
|
+
}, 60_000);
|
|
928
|
+
|
|
843
929
|
test("prints nothing more for an error the command marked as printed", async () => {
|
|
844
930
|
const marked = await run(
|
|
845
931
|
'(() => { const error = new Error("printed already"); markCliErrorPrinted(error); return error; })()',
|
|
@@ -1,15 +1,14 @@
|
|
|
1
|
+
import { CliError } from "./cliError";
|
|
2
|
+
|
|
1
3
|
//* A failure that is an answer, not a breakage: the CLI exits with its code, so a CI tells the two apart.
|
|
2
|
-
export class ExitCodeError extends
|
|
4
|
+
export class ExitCodeError extends CliError {
|
|
3
5
|
override readonly name = "ExitCodeError";
|
|
4
6
|
|
|
5
|
-
constructor(
|
|
6
|
-
|
|
7
|
-
readonly exitCode: number,
|
|
8
|
-
) {
|
|
9
|
-
super(message);
|
|
7
|
+
constructor(message: string, exitCode: number) {
|
|
8
|
+
super(exitCode === 2 ? "refused" : exitCode === 3 ? "checkFailed" : "failed", message, { exitCode });
|
|
10
9
|
}
|
|
11
10
|
|
|
12
11
|
static codeOf(error: unknown): number | null {
|
|
13
|
-
return error
|
|
12
|
+
return CliError.exitCodeOf(error);
|
|
14
13
|
}
|
|
15
14
|
}
|
|
@@ -44,6 +44,7 @@ export interface TargetOption {
|
|
|
44
44
|
runsOnWorkspaceRoot?: boolean;
|
|
45
45
|
stdio?: boolean;
|
|
46
46
|
failureExitCode?: number;
|
|
47
|
+
/** The exit of a `usage` or `unknownTarget` failure, for a command whose 2 already answers something else. */
|
|
47
48
|
usageExitCode?: number;
|
|
48
49
|
/** It deletes files in its app, lib, pkg or module, so one left out or unknown is refused, never picked or asked. */
|
|
49
50
|
destructive?: boolean;
|
|
@@ -68,6 +68,31 @@ describe("IncrementalBuilderHost", () => {
|
|
|
68
68
|
host.stop();
|
|
69
69
|
});
|
|
70
70
|
|
|
71
|
+
test("a stop while a restart is pending leaves the builder down once the delay passes", async () => {
|
|
72
|
+
const { spawns, host } = startRecordingHost();
|
|
73
|
+
spawns[0]?.options.onExit?.();
|
|
74
|
+
expect(host.status).toBe("restarting");
|
|
75
|
+
|
|
76
|
+
host.stop();
|
|
77
|
+
await wait(1_050);
|
|
78
|
+
expect(spawns).toHaveLength(1);
|
|
79
|
+
expect(host.status).toBe("stopped");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("a builder that crashes again after coming back ready waits the first delay again", async () => {
|
|
83
|
+
const { spawns, host } = startRecordingHost();
|
|
84
|
+
spawns[0]?.options.onExit?.();
|
|
85
|
+
await wait(1_050);
|
|
86
|
+
expect(spawns).toHaveLength(2);
|
|
87
|
+
|
|
88
|
+
spawns[1]?.options.ipc?.({ type: "builder-ready" });
|
|
89
|
+
spawns[1]?.options.onExit?.();
|
|
90
|
+
await wait(1_050);
|
|
91
|
+
expect(spawns).toHaveLength(3);
|
|
92
|
+
|
|
93
|
+
host.stop();
|
|
94
|
+
});
|
|
95
|
+
|
|
71
96
|
test("recycles a ready builder gracefully and replaces it immediately", async () => {
|
|
72
97
|
const spawns = mockSpawns();
|
|
73
98
|
const onRestartReady = mock();
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { Logger } from "akanjs/common";
|
|
3
|
+
import { Logger, RestartSchedule } from "akanjs/common";
|
|
4
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";
|
|
@@ -49,8 +49,6 @@ interface IncrementalBuilderStartOptions {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
export class IncrementalBuilderHost {
|
|
52
|
-
static readonly #restartBaseDelayMs = 1_000;
|
|
53
|
-
static readonly #restartMaxDelayMs = 30_000;
|
|
54
52
|
static readonly #recycleDrainTimeoutMs = 30_000;
|
|
55
53
|
// apps/akan's builder peaks near 950MB holding the SSR registry's patcher through a route build, and arming CSR adds
|
|
56
54
|
// a second patcher: this recycles only a builder grown well past both.
|
|
@@ -65,8 +63,7 @@ export class IncrementalBuilderHost {
|
|
|
65
63
|
readonly #onOutput: ((kind: "stdout" | "stderr", text: string) => void) | null;
|
|
66
64
|
#proc: Bun.Subprocess<"ignore", "inherit" | "pipe", "inherit" | "pipe"> | null = null;
|
|
67
65
|
#status: IncrementalBuilderStatus = "stopped";
|
|
68
|
-
#
|
|
69
|
-
#restartTimer: ReturnType<typeof setTimeout> | null = null;
|
|
66
|
+
readonly #restart = new RestartSchedule({ min: 1_000, max: 30_000 });
|
|
70
67
|
#recycleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
71
68
|
#recycleRequested: boolean = false;
|
|
72
69
|
#spawnAfterRecycle: boolean = false;
|
|
@@ -136,7 +133,7 @@ export class IncrementalBuilderHost {
|
|
|
136
133
|
if (msg.type === "builder-ready" && !this.ready) {
|
|
137
134
|
this.ready = true;
|
|
138
135
|
this.#status = "ready";
|
|
139
|
-
this.#
|
|
136
|
+
this.#restart.reset();
|
|
140
137
|
if (isRestart) this.#startOptions.onRestartReady?.();
|
|
141
138
|
else this.#startOptions.onReady?.();
|
|
142
139
|
}
|
|
@@ -210,20 +207,13 @@ export class IncrementalBuilderHost {
|
|
|
210
207
|
}
|
|
211
208
|
}
|
|
212
209
|
#scheduleRestart() {
|
|
213
|
-
if (this.#manualStop || this.#
|
|
210
|
+
if (this.#manualStop || this.#restart.pending) return;
|
|
214
211
|
this.#status = "restarting";
|
|
215
|
-
const
|
|
216
|
-
const delay = Math.min(
|
|
217
|
-
IncrementalBuilderHost.#restartBaseDelayMs * 2 ** attempt,
|
|
218
|
-
IncrementalBuilderHost.#restartMaxDelayMs,
|
|
219
|
-
);
|
|
220
|
-
this.#restartAttempts = attempt + 1;
|
|
221
|
-
this.logger.warn(`builder exited after ready; restarting in ${delay}ms (attempt ${this.#restartAttempts})`);
|
|
222
|
-
this.#restartTimer = setTimeout(() => {
|
|
223
|
-
this.#restartTimer = null;
|
|
212
|
+
const delay = this.#restart.schedule(() => {
|
|
224
213
|
if (this.#manualStop) return;
|
|
225
214
|
this.#spawn(true);
|
|
226
|
-
}
|
|
215
|
+
});
|
|
216
|
+
this.logger.warn(`builder exited after ready; restarting in ${delay}ms (attempt ${this.#restart.attempts})`);
|
|
227
217
|
}
|
|
228
218
|
/** Drain-and-exit rather than `kill()`, so no rebuild is truncated; false when the request was not sent. */
|
|
229
219
|
recycle(reason: string): boolean {
|
|
@@ -294,10 +284,7 @@ export class IncrementalBuilderHost {
|
|
|
294
284
|
this.#manualStop = true;
|
|
295
285
|
this.#clearRecycle();
|
|
296
286
|
this.#failInFlight("builder was stopped before answering");
|
|
297
|
-
|
|
298
|
-
clearTimeout(this.#restartTimer);
|
|
299
|
-
this.#restartTimer = null;
|
|
300
|
-
}
|
|
287
|
+
this.#restart.cancel();
|
|
301
288
|
if (this.#proc) this.#proc.kill();
|
|
302
289
|
this.#proc = null;
|
|
303
290
|
this.ready = false;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "3.2.0-rc.
|
|
3
|
+
"version": "3.2.0-rc.8",
|
|
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.
|
|
44
|
+
"@akanjs/language": "3.2.0-rc.8",
|
|
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.
|
|
53
|
+
"akanjs": "3.2.0-rc.8",
|
|
54
54
|
"chalk": "^6.0.1",
|
|
55
55
|
"commander": "^15.0.0",
|
|
56
56
|
"dayjs": "^1.11.23",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { mkdir, rm } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { CliError } from "../commandDecorators/cliError";
|
|
3
4
|
import type { TypecheckRecord } from "../typeChecker";
|
|
4
5
|
import { TypecheckFailure } from "./typecheckFailure";
|
|
5
6
|
|
|
@@ -69,7 +70,10 @@ export class ProjectTypecheck {
|
|
|
69
70
|
if (exitCode === 0) return;
|
|
70
71
|
const records = report === "records" ? ProjectTypecheck.#parseRecords(stdout) : null;
|
|
71
72
|
if (records?.length) throw new TypecheckFailure(records);
|
|
72
|
-
|
|
73
|
+
const output = (stderr || stdout).trim();
|
|
74
|
+
if (exitCode === TypecheckFailure.workerExitCode && report === "pretty")
|
|
75
|
+
throw new CliError("checkFailed", output || "Typecheck failed");
|
|
76
|
+
throw new Error(output || `Typecheck failed with exit code ${exitCode}`);
|
|
73
77
|
}
|
|
74
78
|
|
|
75
79
|
static #parseRecords(stdout: string): TypecheckRecord[] | null {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Logger } from "akanjs/common";
|
|
2
2
|
import { TypeChecker } from "../typeChecker";
|
|
3
|
+
import { TypecheckFailure } from "./typecheckFailure";
|
|
3
4
|
|
|
4
5
|
try {
|
|
5
6
|
const filePath = process.env.AKAN_TYPECHECK_FILE;
|
|
@@ -28,11 +29,11 @@ try {
|
|
|
28
29
|
//? Not process.cwd(): that is the realpath (/private/var on macOS) while TypeScript keeps the spelling it was given.
|
|
29
30
|
const root = process.env.AKAN_TYPECHECK_ROOT ?? process.cwd();
|
|
30
31
|
process.stdout.write(JSON.stringify(TypeChecker.toRecords(result.errors, root)));
|
|
31
|
-
process.exit(result.errors.length > 0 ?
|
|
32
|
+
process.exit(result.errors.length > 0 ? TypecheckFailure.workerExitCode : 0);
|
|
32
33
|
}
|
|
33
34
|
if (result.errors.length > 0) {
|
|
34
35
|
console.error(result.message);
|
|
35
|
-
process.exit(
|
|
36
|
+
process.exit(TypecheckFailure.workerExitCode);
|
|
36
37
|
}
|
|
37
38
|
} catch (error) {
|
|
38
39
|
console.error(error instanceof Error ? error.message : String(error));
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
+
import { CliError } from "../commandDecorators/cliError";
|
|
1
2
|
import type { TypecheckRecord } from "../typeChecker";
|
|
2
3
|
|
|
3
|
-
export class TypecheckFailure extends
|
|
4
|
+
export class TypecheckFailure extends CliError<"checkFailed"> {
|
|
5
|
+
//? The worker's exit when the project has type errors, so its crash, which exits 1, is told apart.
|
|
6
|
+
static readonly workerExitCode = 3;
|
|
7
|
+
|
|
4
8
|
constructor(readonly records: TypecheckRecord[]) {
|
|
5
9
|
super(
|
|
10
|
+
"checkFailed",
|
|
6
11
|
records
|
|
7
12
|
.slice(0, 20)
|
|
8
13
|
.map((record) => `${record.file ?? "-"}:${record.line}:${record.col} ${record.code} ${record.message}`)
|
package/workflow/artifacts.ts
CHANGED
|
@@ -95,11 +95,11 @@ export const createWorkflowApplyReport = ({
|
|
|
95
95
|
postApplyChecks?: WorkflowApplyReport["postApplyChecks"];
|
|
96
96
|
recommendations?: WorkflowApplyReport["recommendations"];
|
|
97
97
|
}): WorkflowApplyReport => {
|
|
98
|
-
const
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
action: inferNextActionCode(action, diagnostics)
|
|
102
|
-
|
|
98
|
+
const status = workflowStatus(diagnostics);
|
|
99
|
+
const validationCommands = status === "failed" ? [] : (recommendedValidationCommands ?? commands);
|
|
100
|
+
const nextActionsWithIntent = nextActions
|
|
101
|
+
.map((action) => ({ ...action, action: inferNextActionCode(action, diagnostics) }))
|
|
102
|
+
.filter((action) => status !== "failed" || action.action !== "validate");
|
|
103
103
|
if (sourceChangeBlocked(diagnostics) && !nextActionsWithIntent.some((action) => action.action === "blocked")) {
|
|
104
104
|
nextActionsWithIntent.unshift({
|
|
105
105
|
command: `akan workflow explain ${workflow}`,
|
|
@@ -111,12 +111,13 @@ export const createWorkflowApplyReport = ({
|
|
|
111
111
|
(left, right) => nextActionPriority(left, diagnostics) - nextActionPriority(right, diagnostics),
|
|
112
112
|
);
|
|
113
113
|
const sourceFilesChanged = uniqueBy(changedFiles, (file) => `${file.action}:${file.path}:${file.reason}`);
|
|
114
|
-
const generatedFilesSynced =
|
|
114
|
+
const generatedFilesSynced =
|
|
115
|
+
status === "failed" ? [] : uniqueBy(generatedFiles, (file) => `${file.action}:${file.path}:${file.reason}`);
|
|
115
116
|
return {
|
|
116
117
|
schemaVersion: 1,
|
|
117
118
|
workflow,
|
|
118
119
|
mode,
|
|
119
|
-
status
|
|
120
|
+
status,
|
|
120
121
|
summary: {
|
|
121
122
|
sourceFilesChanged,
|
|
122
123
|
generatedFilesSynced,
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import type { Sys } from "../commandDecorators";
|
|
3
|
+
import { ensureBaseImport, ensureNamedImport, getSysRoot, moduleComponentName, moduleSourcePaths } from "./source";
|
|
4
|
+
import { sourceFileFor } from "./sourceAst";
|
|
5
|
+
import type { WorkflowDiagnostic } from "./types";
|
|
6
|
+
|
|
7
|
+
const builtInFieldTypes = new Set(["String", "Boolean", "Date", "Map"]);
|
|
8
|
+
const baseFieldTypes = new Set(["Int", "Float", "ID", "Any", "Binary", "ImageResult"]);
|
|
9
|
+
const fieldTypeReference = /^\[*([A-Za-z_$][\w$]*)((?:\.[A-Za-z_$][\w$]*)*)\]*$/;
|
|
10
|
+
|
|
11
|
+
export const ensureBaseTypeImport = (content: string, typeName: string) => {
|
|
12
|
+
const [, name = "", member] = fieldTypeReference.exec(typeName) ?? [];
|
|
13
|
+
if (member || !baseFieldTypes.has(name)) return content;
|
|
14
|
+
return ensureBaseImport(content, name);
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const valueNamesOf = (content: string) => {
|
|
18
|
+
const names = new Set<string>();
|
|
19
|
+
for (const statement of sourceFileFor("source.ts", content).statements) {
|
|
20
|
+
if (ts.isImportDeclaration(statement)) {
|
|
21
|
+
const clause = statement.importClause;
|
|
22
|
+
if (!clause || clause.isTypeOnly) continue;
|
|
23
|
+
if (clause.name) names.add(clause.name.text);
|
|
24
|
+
const bindings = clause.namedBindings;
|
|
25
|
+
if (bindings && ts.isNamespaceImport(bindings)) names.add(bindings.name.text);
|
|
26
|
+
else for (const element of bindings?.elements ?? []) if (!element.isTypeOnly) names.add(element.name.text);
|
|
27
|
+
} else if (ts.isVariableStatement(statement)) {
|
|
28
|
+
for (const declaration of statement.declarationList.declarations)
|
|
29
|
+
if (ts.isIdentifier(declaration.name)) names.add(declaration.name.text);
|
|
30
|
+
} else if (
|
|
31
|
+
(ts.isClassDeclaration(statement) || ts.isEnumDeclaration(statement) || ts.isFunctionDeclaration(statement)) &&
|
|
32
|
+
statement.name
|
|
33
|
+
)
|
|
34
|
+
names.add(statement.name.text);
|
|
35
|
+
}
|
|
36
|
+
return names;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const fieldTypeSource = async (
|
|
40
|
+
sys: Sys,
|
|
41
|
+
module: string,
|
|
42
|
+
typeName: string,
|
|
43
|
+
constantContent: string,
|
|
44
|
+
): Promise<{ import?: { name: string; from: string }; diagnostic?: WorkflowDiagnostic }> => {
|
|
45
|
+
const reference = fieldTypeReference.exec(typeName);
|
|
46
|
+
if (!reference)
|
|
47
|
+
return {
|
|
48
|
+
diagnostic: {
|
|
49
|
+
severity: "error",
|
|
50
|
+
code: "primitive-field-type-unknown",
|
|
51
|
+
input: "type",
|
|
52
|
+
message: `Field type "${typeName}" is not a type name field() takes. Name one type, such as Address, or a list of one, such as [Address], then plan this again.`,
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
const [, name = "", member] = reference;
|
|
56
|
+
if (builtInFieldTypes.has(name) || baseFieldTypes.has(name) || valueNamesOf(constantContent).has(name)) return {};
|
|
57
|
+
const [scalars, models] = await Promise.all([sys.getScalarModules(), sys.getDatabaseModules()]);
|
|
58
|
+
const scalar = member ? undefined : scalars.find((scalarName) => moduleComponentName(scalarName) === name);
|
|
59
|
+
if (scalar) return { import: { name, from: `../__scalar/${scalar}/${scalar}.constant` } };
|
|
60
|
+
const model = member ? undefined : models.find((modelName) => moduleComponentName(modelName) === name);
|
|
61
|
+
if (model) return { import: { name, from: `../${model}/${model}.constant` } };
|
|
62
|
+
const root = getSysRoot(sys);
|
|
63
|
+
const constantPath = `${root}/${moduleSourcePaths(module).constant}`;
|
|
64
|
+
const named = (kind: string, names: string[]) =>
|
|
65
|
+
names.length ? `Its ${kind} are ${names.map(moduleComponentName).join(", ")}.` : `It has no ${kind}.`;
|
|
66
|
+
return {
|
|
67
|
+
diagnostic: {
|
|
68
|
+
severity: "error",
|
|
69
|
+
code: "primitive-field-type-unknown",
|
|
70
|
+
input: "type",
|
|
71
|
+
message: [
|
|
72
|
+
`Field type "${typeName}" names ${name}, which is not a built-in or akanjs/base type, not imported by ${constantPath}, and not a scalar or model in ${root}.`,
|
|
73
|
+
named("scalars", scalars),
|
|
74
|
+
named("models", models),
|
|
75
|
+
`Create the scalar with the create-scalar workflow first, or import ${name} into ${constantPath}, then plan this again.`,
|
|
76
|
+
].join(" "),
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const withFieldTypeImport = async (
|
|
82
|
+
sys: Sys,
|
|
83
|
+
module: string,
|
|
84
|
+
typeName: string,
|
|
85
|
+
content: string,
|
|
86
|
+
diagnostics: WorkflowDiagnostic[],
|
|
87
|
+
) => {
|
|
88
|
+
const withBaseImport = ensureBaseTypeImport(content, typeName);
|
|
89
|
+
const source = await fieldTypeSource(sys, module, typeName, withBaseImport);
|
|
90
|
+
if (source.diagnostic) diagnostics.push(source.diagnostic);
|
|
91
|
+
return source.import ? ensureNamedImport(withBaseImport, source.import.from, source.import.name) : withBaseImport;
|
|
92
|
+
};
|
package/workflow/index.ts
CHANGED
package/workflow/plan.ts
CHANGED
|
@@ -2,11 +2,12 @@ import { capitalize } from "akanjs/common";
|
|
|
2
2
|
import type { Sys, Workspace } from "../commandDecorators";
|
|
3
3
|
import { withoutApplyHints, workflowPlanApproval } from "./artifacts";
|
|
4
4
|
import { resolveWorkflowSys } from "./executor";
|
|
5
|
+
import { fieldTypeSource } from "./fieldType";
|
|
5
6
|
import { InteractionScaffold } from "./interactionScaffold";
|
|
6
7
|
import { KeyRename } from "./keyRename";
|
|
7
8
|
import { MutationScaffold } from "./mutationScaffold";
|
|
8
9
|
import { identifierPattern, sysNamePattern } from "./plainName";
|
|
9
|
-
import { coerceFieldDefault, getSysRoot, moduleSourcePaths } from "./source";
|
|
10
|
+
import { coerceFieldDefault, getSysRoot, moduleSourcePaths, normalizeFieldType } from "./source";
|
|
10
11
|
import type {
|
|
11
12
|
WorkflowDiagnostic,
|
|
12
13
|
WorkflowInputSpec,
|
|
@@ -541,6 +542,13 @@ const moduleNotDatabase = async (sys: Sys, spec: WorkflowSpec, module: string):
|
|
|
541
542
|
};
|
|
542
543
|
};
|
|
543
544
|
|
|
545
|
+
const fieldTypeUnknown = async (sys: Sys, spec: WorkflowSpec, module: string, type: string | null) => {
|
|
546
|
+
if (spec.name !== "add-field" || !type || ["enum", "number", "numeric", "upload"].includes(type.toLowerCase()))
|
|
547
|
+
return null;
|
|
548
|
+
const constant = await sys.readFile(moduleSourcePaths(module).constant);
|
|
549
|
+
return (await fieldTypeSource(sys, module, normalizeFieldType(type), constant)).diagnostic ?? null;
|
|
550
|
+
};
|
|
551
|
+
|
|
544
552
|
export const workflowTargetDiagnostics = async (
|
|
545
553
|
workspace: Workspace,
|
|
546
554
|
spec: WorkflowSpec,
|
|
@@ -563,7 +571,10 @@ export const workflowTargetDiagnostics = async (
|
|
|
563
571
|
const module = planString(inputs, "module");
|
|
564
572
|
const moduleSpec: WorkflowInputSpec | undefined = spec.inputs.module;
|
|
565
573
|
if (!module || !moduleSpec?.mustExist) return [];
|
|
566
|
-
const refused =
|
|
574
|
+
const refused =
|
|
575
|
+
(await moduleMissing(workspace, sys, module)) ??
|
|
576
|
+
(await moduleNotDatabase(sys, spec, module)) ??
|
|
577
|
+
(await fieldTypeUnknown(sys, spec, module, planString(inputs, "type")));
|
|
567
578
|
return refused ? [refused] : [];
|
|
568
579
|
};
|
|
569
580
|
|
package/workflow/source.ts
CHANGED
|
@@ -238,11 +238,6 @@ export const ensureImportDeclaration = (content: string, moduleSpecifier: string
|
|
|
238
238
|
|
|
239
239
|
export const ensureBaseImport = (content: string, name: string) => ensureNamedImport(content, "akanjs/base", name);
|
|
240
240
|
|
|
241
|
-
export const ensureBaseTypeImport = (content: string, typeName: string) => {
|
|
242
|
-
if (typeName !== "Int" && typeName !== "Float") return content;
|
|
243
|
-
return ensureBaseImport(content, typeName);
|
|
244
|
-
};
|
|
245
|
-
|
|
246
241
|
export type FieldDefaultValue = string | number | boolean | null;
|
|
247
242
|
|
|
248
243
|
const numericDefault = (typeName: "Int" | "Float", rawDefault: FieldDefaultValue): string | null => {
|