@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.
@@ -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(1);
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(1);
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 (failure: string, { option = "", args = [] as string[] } = {}) => {
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 { command, ExitCodeError, markCliErrorPrinted, runCommands, runner, script } from ${JSON.stringify(path.join(import.meta.dir, "index.ts"))};`,
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, "verdict", ...args], { cwd: root, stdout: "pipe", stderr: "pipe" });
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.stdout).toContain("broke\n");
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 Error {
4
+ export class ExitCodeError extends CliError {
3
5
  override readonly name = "ExitCodeError";
4
6
 
5
- constructor(
6
- message: string,
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 instanceof ExitCodeError ? error.exitCode : null;
12
+ return CliError.exitCodeOf(error);
14
13
  }
15
14
  }
@@ -1,4 +1,5 @@
1
1
  export * from "./argMeta";
2
+ export * from "./cliError";
2
3
  export * from "./command";
3
4
  export * from "./commandBuilder";
4
5
  export * from "./dependencyBuilder";
@@ -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
- #restartAttempts = 0;
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.#restartAttempts = 0;
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.#restartTimer) return;
210
+ if (this.#manualStop || this.#restart.pending) return;
214
211
  this.#status = "restarting";
215
- const attempt = this.#restartAttempts;
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
- }, delay);
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
- if (this.#restartTimer) {
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.6",
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.6",
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.6",
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
- throw new Error((stderr || stdout).trim() || `Typecheck failed with exit code ${exitCode}`);
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 ? 1 : 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(1);
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 Error {
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}`)
@@ -95,11 +95,11 @@ export const createWorkflowApplyReport = ({
95
95
  postApplyChecks?: WorkflowApplyReport["postApplyChecks"];
96
96
  recommendations?: WorkflowApplyReport["recommendations"];
97
97
  }): WorkflowApplyReport => {
98
- const validationCommands = recommendedValidationCommands ?? commands;
99
- const nextActionsWithIntent = nextActions.map((action) => ({
100
- ...action,
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 = uniqueBy(generatedFiles, (file) => `${file.action}:${file.path}:${file.reason}`);
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: workflowStatus(diagnostics),
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
@@ -1,5 +1,6 @@
1
1
  export * from "./artifacts";
2
2
  export * from "./executor";
3
+ export * from "./fieldType";
3
4
  export * from "./guardCatalog";
4
5
  export * from "./interactionScaffold";
5
6
  export * from "./keyRename";
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 = (await moduleMissing(workspace, sys, module)) ?? (await moduleNotDatabase(sys, spec, module));
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
 
@@ -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 => {