@akanjs/devkit 3.1.1-rc.15 → 3.1.1-rc.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/agentAssets.test.ts +65 -0
  2. package/agentAssets.ts +166 -0
  3. package/agentsIndex.ts +4 -4
  4. package/akanContext.ts +44 -2
  5. package/applicationBuildRunner.test.ts +17 -0
  6. package/applicationBuildRunner.ts +3 -1
  7. package/biome.base.json +4 -1
  8. package/codeAgent/agent/akanSystemPrompt.ts +1 -1
  9. package/codeAgent/feedback/VerifyFeedback.ts +3 -4
  10. package/codeAgent/skills/akan-agent/SKILL.md +36 -0
  11. package/codeAgent/skills/akan-debug/SKILL.md +3 -3
  12. package/codeAgent/skills/akan-deps/SKILL.md +1 -2
  13. package/codeAgent/skills/akan-mcp/SKILL.md +40 -0
  14. package/codeAgent/skills/akan-page/SKILL.md +44 -0
  15. package/codeAgent/skills/akan-query/SKILL.md +38 -0
  16. package/codeAgent/skills/akan-runtime/SKILL.md +40 -0
  17. package/codeAgent/skills/akan-scaffold/SKILL.md +16 -6
  18. package/codeAgent/skills/akan-transport/SKILL.md +40 -0
  19. package/codeAgent/skills/akan-validate/SKILL.md +13 -11
  20. package/codeAgent/tools/AkanCli.ts +2 -1
  21. package/codeAgent/tools/AkanEditScope.ts +8 -3
  22. package/codeAgent/tools/AkanToolPack.ts +4 -10
  23. package/codeAgent/tools/AkanVerifier.ts +13 -31
  24. package/codeAgent/tools/akanCodePlugins.test.ts +12 -30
  25. package/commentDensityScanner.test.ts +121 -0
  26. package/commentDensityScanner.ts +101 -0
  27. package/componentSizeScanner.test.ts +36 -0
  28. package/componentSizeScanner.ts +52 -0
  29. package/componentWriteChainScanner.test.ts +137 -0
  30. package/componentWriteChainScanner.ts +104 -0
  31. package/conventionSet.test.ts +73 -0
  32. package/conventionSet.ts +67 -0
  33. package/deprecatedCommentScanner.test.ts +54 -0
  34. package/deprecatedCommentScanner.ts +30 -0
  35. package/endpointReach.test.ts +58 -0
  36. package/endpointReach.ts +80 -0
  37. package/executors.test.ts +0 -4
  38. package/index.ts +1 -0
  39. package/kindBranchScanner.test.ts +89 -0
  40. package/kindBranchScanner.ts +158 -0
  41. package/lint/__fixtures__/no-oversized-list-limit/bad.tsx +18 -0
  42. package/lint/__fixtures__/no-oversized-list-limit/good.tsx +23 -0
  43. package/lint/no-oversized-list-limit.grit +73 -0
  44. package/maintainabilityBaseline.test.ts +280 -0
  45. package/maintainabilityBaseline.ts +220 -0
  46. package/maintainabilityContract.test.ts +846 -0
  47. package/maintainabilityContract.ts +404 -0
  48. package/maintainabilityExceptionScanner.test.ts +90 -0
  49. package/maintainabilityExceptionScanner.ts +85 -0
  50. package/maintainabilityLimits.ts +60 -0
  51. package/maintainabilityReport.test.ts +130 -0
  52. package/maintainabilityReport.ts +153 -0
  53. package/package.json +2 -2
  54. package/prompter.ts +7 -0
  55. package/qualityScanner.test.ts +75 -0
  56. package/qualityScanner.ts +17 -1
  57. package/serviceLoopCallScanner.test.ts +56 -0
  58. package/serviceLoopCallScanner.ts +122 -0
  59. package/sourceComments.test.ts +45 -0
  60. package/sourceComments.ts +70 -0
  61. package/storeAccessScanner.test.ts +84 -0
  62. package/storeAccessScanner.ts +71 -0
  63. package/storeSource.ts +159 -0
  64. package/storeWriteScanner.test.ts +292 -0
  65. package/storeWriteScanner.ts +50 -0
  66. package/testOnlyExportScanner.test.ts +83 -0
  67. package/testOnlyExportScanner.ts +124 -0
  68. package/typeChecker.ts +32 -0
  69. package/typecheck/projectTypecheck.test.ts +18 -0
  70. package/typecheck/projectTypecheck.ts +29 -5
  71. package/typecheck/typecheck.proc.ts +6 -0
  72. package/typecheck/typecheckFailure.ts +13 -0
  73. package/workflow/artifacts.ts +16 -2
  74. package/workflow/executor.ts +22 -7
  75. package/workflow/guardCatalog.test.ts +59 -0
  76. package/workflow/guardCatalog.ts +108 -0
  77. package/workflow/index.ts +4 -0
  78. package/workflow/interactionScaffold.ts +122 -0
  79. package/workflow/mutationScaffold.ts +519 -0
  80. package/workflow/mutationSource.test.ts +108 -0
  81. package/workflow/mutationSource.ts +219 -0
  82. package/workflow/plan.ts +54 -0
  83. package/workflow/render.ts +19 -4
  84. package/workflow/source.test.ts +185 -0
  85. package/workflow/source.ts +151 -24
  86. package/workflow/types.ts +18 -1
  87. package/workspaceLayout.ts +2 -0
  88. package/writePath.test.ts +109 -0
  89. package/writePath.ts +219 -0
  90. package/writePlacementScanner.test.ts +77 -0
  91. package/writePlacementScanner.ts +50 -0
  92. package/writingActionIndex.test.ts +99 -0
  93. package/writingActionIndex.ts +63 -0
@@ -1,11 +1,15 @@
1
1
  import { mkdir, rm } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import type { TypecheckRecord } from "../typeChecker";
4
+ import { TypecheckFailure } from "./typecheckFailure";
3
5
 
4
6
  export interface ProjectTypecheckOptions {
5
7
  clean?: boolean;
6
8
  incremental?: boolean;
7
9
  /** The project's own `*.test.ts` / `*.spec.ts` as well. */
8
10
  tests?: boolean;
11
+ /** `records` fails with a `TypecheckFailure` carrying one record per diagnostic instead of the printed report. */
12
+ report?: "pretty" | "records";
9
13
  }
10
14
 
11
15
  //* tsc checks what the entries import, and nothing imports a test, so a test is checked only when it is included by
@@ -25,7 +29,10 @@ export class ProjectTypecheck {
25
29
  }
26
30
 
27
31
  /** `entries`: relative to the typecheck folder (`../../server.ts`). */
28
- async run(entries: string[], { clean = false, incremental = true, tests = false }: ProjectTypecheckOptions = {}) {
32
+ async run(
33
+ entries: string[],
34
+ { clean = false, incremental = true, tests = false, report = "pretty" }: ProjectTypecheckOptions = {},
35
+ ) {
29
36
  const name = tests ? "tsconfig.tests" : "tsconfig";
30
37
  await mkdir(this.typecheckDir, { recursive: true });
31
38
  const tsconfig = {
@@ -38,14 +45,19 @@ export class ProjectTypecheck {
38
45
  const tsconfigPath = path.join(this.typecheckDir, `${name}.json`);
39
46
  await Bun.write(tsconfigPath, `${JSON.stringify(tsconfig, null, 2)}\n`);
40
47
  if (clean) await rm(path.join(this.typecheckDir, `${name}.tsbuildinfo`), { force: true });
41
- await this.#check(tsconfigPath);
48
+ await this.check(tsconfigPath, report);
42
49
  return tsconfigPath;
43
50
  }
44
51
 
45
- async #check(tsconfigPath: string) {
52
+ async check(tsconfigPath: string, report: "pretty" | "records" = "pretty") {
46
53
  const proc = Bun.spawn([process.execPath, await ProjectTypecheck.workerEntry(this.workspaceRoot)], {
47
54
  cwd: this.workspaceRoot,
48
- env: { ...this.env, AKAN_COMMAND_TYPE: "typecheck", AKAN_TYPECHECK_TSCONFIG: tsconfigPath },
55
+ env: {
56
+ ...this.env,
57
+ AKAN_COMMAND_TYPE: "typecheck",
58
+ AKAN_TYPECHECK_TSCONFIG: tsconfigPath,
59
+ ...(report === "records" ? { AKAN_TYPECHECK_FORMAT: "json", AKAN_TYPECHECK_ROOT: this.workspaceRoot } : {}),
60
+ },
49
61
  stdout: "pipe",
50
62
  stderr: "pipe",
51
63
  });
@@ -54,7 +66,19 @@ export class ProjectTypecheck {
54
66
  new Response(proc.stderr).text(),
55
67
  proc.exited,
56
68
  ]);
57
- if (exitCode !== 0) throw new Error((stderr || stdout).trim() || `Typecheck failed with exit code ${exitCode}`);
69
+ if (exitCode === 0) return;
70
+ const records = report === "records" ? ProjectTypecheck.#parseRecords(stdout) : null;
71
+ if (records?.length) throw new TypecheckFailure(records);
72
+ throw new Error((stderr || stdout).trim() || `Typecheck failed with exit code ${exitCode}`);
73
+ }
74
+
75
+ static #parseRecords(stdout: string): TypecheckRecord[] | null {
76
+ try {
77
+ const parsed: unknown = JSON.parse(stdout);
78
+ return Array.isArray(parsed) ? (parsed as TypecheckRecord[]) : null;
79
+ } catch {
80
+ return null;
81
+ }
58
82
  }
59
83
 
60
84
  //? The workspace's own devkit first, then an installed one, then the entry a bundle puts beside its chunks.
@@ -24,6 +24,12 @@ try {
24
24
  const configPath = process.env.AKAN_TYPECHECK_TSCONFIG;
25
25
  if (!configPath) throw new Error("AKAN_TYPECHECK_TSCONFIG is required");
26
26
  const result = TypeChecker.checkProject(configPath);
27
+ if (process.env.AKAN_TYPECHECK_FORMAT === "json") {
28
+ //? Not process.cwd(): that is the realpath (/private/var on macOS) while TypeScript keeps the spelling it was given.
29
+ const root = process.env.AKAN_TYPECHECK_ROOT ?? process.cwd();
30
+ process.stdout.write(JSON.stringify(TypeChecker.toRecords(result.errors, root)));
31
+ process.exit(result.errors.length > 0 ? 1 : 0);
32
+ }
27
33
  if (result.errors.length > 0) {
28
34
  console.error(result.message);
29
35
  process.exit(1);
@@ -0,0 +1,13 @@
1
+ import type { TypecheckRecord } from "../typeChecker";
2
+
3
+ export class TypecheckFailure extends Error {
4
+ constructor(readonly records: TypecheckRecord[]) {
5
+ super(
6
+ records
7
+ .slice(0, 20)
8
+ .map((record) => `${record.file ?? "-"}:${record.line}:${record.col} ${record.code} ${record.message}`)
9
+ .join("\n") || "Typecheck failed",
10
+ );
11
+ this.name = "TypecheckFailure";
12
+ }
13
+ }
@@ -1,5 +1,6 @@
1
1
  import { capitalize } from "akanjs/common";
2
2
  import type { Workspace } from "../commandDecorators";
3
+ import { InteractionScaffold } from "./interactionScaffold";
3
4
  import type {
4
5
  GeneratedSyncState,
5
6
  PrimitiveChangedFile,
@@ -156,11 +157,15 @@ export const workflowPathsForPlan = (plan: WorkflowPlan) => {
156
157
  const app = planInputString(plan, "app");
157
158
  const module = planInputString(plan, "module");
158
159
  const moduleClass = module ? capitalize(module) : "<Module>";
160
+ const rawInteraction = planInputString(plan, "interaction");
161
+ const interaction = rawInteraction ? (InteractionScaffold.nameOf(rawInteraction) ?? rawInteraction) : null;
159
162
  return plan.predictedChanges.map((change) =>
160
163
  change.target
161
164
  .replace(/^\*\//, app ? `apps/${app}/` : "")
162
165
  .replaceAll("<module>", module || "<module>")
163
- .replaceAll("<Module>", moduleClass),
166
+ .replaceAll("<Module>", moduleClass)
167
+ .replaceAll("<interaction>", interaction || "<interaction>")
168
+ .replaceAll("<Interaction>", interaction ? capitalize(interaction) : "<Interaction>"),
164
169
  );
165
170
  };
166
171
 
@@ -277,6 +282,15 @@ const statusForValidationKind = (
277
282
  return matching.some((command) => command.status === "failed") ? "failed" : "passed";
278
283
  };
279
284
 
285
+ //? `akan verify` syncs first and names a failed sync in its header line (`sync 1 error`), so one run answers both.
286
+ const generatedSyncStatus = (commands: readonly WorkflowValidationCommandResult[]): WorkflowValidationStatus => {
287
+ const sync = statusForValidationKind(commands, "sync");
288
+ const verify = commands.filter((command) => command.kind === "verify");
289
+ if (sync !== "unknown" || verify.length === 0) return sync;
290
+ const syncFailed = verify.some((command) => /^verify fail · .*\bsync \d+ errors?\b/m.test(command.stdout ?? ""));
291
+ return syncFailed ? "failed" : "passed";
292
+ };
293
+
280
294
  const statusForCommands = (commands: readonly WorkflowValidationCommandResult[]): WorkflowValidationStatus => {
281
295
  if (commands.length === 0) return "unknown";
282
296
  return commandStatus(commands) === "failed" ? "failed" : "passed";
@@ -433,7 +447,7 @@ const createValidationStatuses = (
433
447
  overallStatus,
434
448
  summary: {
435
449
  sourceChange: sourceStatus,
436
- generatedSync: statusForValidationKind(commands, "sync"),
450
+ generatedSync: generatedSyncStatus(commands),
437
451
  validationCommands: validationCommandsStatus,
438
452
  baseline: baselineStatus,
439
453
  workspaceConfig: statusForScope(commands, diagnostics, scopes, "workspace-config"),
@@ -312,7 +312,7 @@ const checkAddFieldStructure = async (workspace: Workspace, plan: WorkflowPlan):
312
312
  return { diagnostics, postApplyChecks };
313
313
  };
314
314
 
315
- const resolveWorkflowSys = async (workspace: Workspace, target: string | null): Promise<Sys | null> => {
315
+ export const resolveWorkflowSys = async (workspace: Workspace, target: string | null): Promise<Sys | null> => {
316
316
  if (!target) return null;
317
317
  const [apps, libs] = await workspace.getSyss();
318
318
  if (apps.includes(target)) return AppExecutor.from(workspace, target);
@@ -375,10 +375,10 @@ const addMutationActionSurfaceInspection = (plan: WorkflowPlan): WorkflowStepRes
375
375
  const moduleClassName = moduleComponentName(module);
376
376
  return surfaceReview(plan, {
377
377
  code: "add-mutation-action-surface-review",
378
- target: `*/${moduleSourcePaths(module).store}`,
379
- action: `Name the endpoint's guards and add its dictionary .endpoint() entry with a .desc(). After sync, fetch.${mutation} exists; write a store action only for a toast, an optimistic update, or a multi-field write, and put the control that calls it in ${moduleClassName}.Util.tsx.`,
380
- message: `Workflow apply does not write store or UI code for ${module}.${mutation}; review whether a screen should call it.`,
381
- reason: "Review action surface guidance before manually editing store or UI files.",
378
+ target: `*/${moduleSourcePaths(module).util}`,
379
+ action: `Fill the ${moduleClassName} chain method (validate, mutate, return this) and replace its transition comment. After sync, call st.do.${mutation}(id) from the control in ${moduleClassName}.Util.tsx; a second write that must land with it is another with/after entry, never a second fetch in the store action.`,
380
+ message: `Workflow apply does not write UI code for ${module}.${mutation}; review which screen calls it.`,
381
+ reason: "Review action surface guidance before manually editing UI files.",
382
382
  });
383
383
  };
384
384
 
@@ -404,6 +404,7 @@ export const createWorkflowStepRegistry = ({
404
404
  addEnumField,
405
405
  addMutation,
406
406
  addSlice,
407
+ createInteraction,
407
408
  }: WorkflowPrimitiveOperations): WorkflowStepRegistry => {
408
409
  const inspect = async () => undefined;
409
410
  const commandOnly = async () => undefined;
@@ -484,17 +485,31 @@ export const createWorkflowStepRegistry = ({
484
485
  ),
485
486
  [workflowStepKey("add-enum-field", "update-dictionary")]: inspect,
486
487
  [workflowStepKey("add-enum-field", "update-option")]: inspect,
487
- [workflowStepKey("add-mutation", "update-service")]: async (_step, plan) =>
488
+ [workflowStepKey("add-mutation", "update-document")]: async (_step, plan) =>
488
489
  primitiveReportToWorkflowStepResult(
489
490
  await addMutation({
490
491
  app: workflowStringInput(plan.inputs.app),
491
492
  module: workflowStringInput(plan.inputs.module),
492
493
  mutation: workflowStringInput(plan.inputs.mutation),
494
+ guards: workflowStringArrayInput(plan.inputs.guard),
495
+ withCalls: workflowStringArrayInput(plan.inputs.with),
496
+ afterCalls: workflowStringArrayInput(plan.inputs.after),
493
497
  }),
494
498
  ),
499
+ [workflowStepKey("add-mutation", "update-service")]: inspect,
495
500
  [workflowStepKey("add-mutation", "update-signal")]: inspect,
496
- [workflowStepKey("add-mutation", "update-action-surfaces")]: async (_step, plan) =>
501
+ [workflowStepKey("add-mutation", "update-store")]: inspect,
502
+ [workflowStepKey("add-mutation", "update-dictionary")]: inspect,
503
+ [workflowStepKey("add-mutation", "add-signal-test")]: inspect,
504
+ [workflowStepKey("add-mutation", "connect-action-ui")]: async (_step, plan) =>
497
505
  addMutationActionSurfaceInspection(plan),
506
+ [workflowStepKey("create-interaction", "create-interaction")]: async (_step, plan) =>
507
+ primitiveReportToWorkflowStepResult(
508
+ await createInteraction({
509
+ app: workflowStringInput(plan.inputs.app),
510
+ interaction: workflowStringInput(plan.inputs.interaction),
511
+ }),
512
+ ),
498
513
  [workflowStepKey("add-slice", "update-service-query")]: async (_step, plan) =>
499
514
  primitiveReportToWorkflowStepResult(
500
515
  await addSlice({
@@ -0,0 +1,59 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import path from "node:path";
3
+ import { createTempApp, tempRoots, writeJson, writeText } from "../testHelpers";
4
+ import { GuardCatalog } from "./guardCatalog";
5
+
6
+ const track = tempRoots();
7
+
8
+ const guardFile = (names: string[]) =>
9
+ names.map((name) => `export class ${name} implements Guard {\n canPass() {\n return true;\n }\n}\n`).join("\n");
10
+
11
+ const createAppWithLibs = async () => {
12
+ const temp = track(await createTempApp("demo"));
13
+ const lib = (name: string, files: Record<string, string>) =>
14
+ Promise.all([
15
+ writeText(path.join(temp.root, "libs", name, "akan.config.ts"), "export default {};\n"),
16
+ ...Object.entries(files).map(([file, content]) => writeText(path.join(temp.root, "libs", name, file), content)),
17
+ ]);
18
+ await lib("shared", {
19
+ "srvkit/guards.ts": guardFile(["Admin", "User"]),
20
+ "srvkit/internalArgs.ts": "export class Self implements InternalArg {}\n",
21
+ "srvkit/guards.signal.test.ts": guardFile(["TestOnly"]),
22
+ "akan.lib.json": JSON.stringify({ libDeps: ["util"] }),
23
+ });
24
+ await lib("util", { "srvkit/guards.ts": guardFile(["Machine"]) });
25
+ await lib("other", { "srvkit/guards.ts": guardFile(["Elsewhere"]) });
26
+ await writeText(path.join(temp.root, "apps/demo/srvkit/guards.ts"), guardFile(["LocalOnly"]));
27
+ return temp;
28
+ };
29
+
30
+ describe("GuardCatalog", () => {
31
+ test("reads the target's srvkit, the libs it reaches, and the framework's Public", async () => {
32
+ const { root, app } = await createAppWithLibs();
33
+ await writeJson(path.join(root, "apps/demo/akan.app.json"), { libDeps: ["shared"] });
34
+
35
+ const catalog = await GuardCatalog.load(app);
36
+
37
+ expect(catalog.reachedLibs).toEqual(["shared", "util"]);
38
+ expect(catalog.names.sort()).toEqual(["Admin", "LocalOnly", "Machine", "Public", "User"]);
39
+ expect(catalog.resolve("LocalOnly").specifier).toBe("@apps/demo/srvkit");
40
+ expect(catalog.resolve("Admin").specifier).toBe("@libs/shared/srvkit");
41
+ expect(catalog.resolve("Public").specifier).toBe("akanjs/signal");
42
+ expect(catalog.resolve("Elsewhere").specifier).toBeNull();
43
+ });
44
+
45
+ test("falls back to every workspace lib before a sync has recorded the target's deps", async () => {
46
+ const { app } = await createAppWithLibs();
47
+
48
+ expect((await GuardCatalog.load(app)).resolve("Elsewhere").specifier).toBe("@libs/other/srvkit");
49
+ });
50
+
51
+ test("names the acting user only when the guard guarantees one and its internal arg exists", async () => {
52
+ const { app } = await createAppWithLibs();
53
+ const catalog = await GuardCatalog.load(app);
54
+
55
+ expect(catalog.actingUserOf(["User"])).toMatchObject({ argName: "Self", specifier: "@libs/shared/srvkit" });
56
+ expect(catalog.actingUserOf(["Admin"])).toBeNull();
57
+ expect(catalog.actingUserOf(["SignedIn"])).toBeNull();
58
+ });
59
+ });
@@ -0,0 +1,108 @@
1
+ import type { Sys } from "../commandDecorators";
2
+
3
+ interface CatalogEntry {
4
+ name: string;
5
+ specifier: string;
6
+ }
7
+
8
+ interface ActingUser {
9
+ argName: string;
10
+ specifier: string;
11
+ param: string;
12
+ value: string;
13
+ serviceParam: string;
14
+ }
15
+
16
+ export class GuardCatalog {
17
+ static readonly frameworkGuards = ["Public"] as const;
18
+ static readonly #actingArgOf = new Map<string, Omit<ActingUser, "specifier">>([
19
+ ["User", { argName: "Self", param: "self", value: "self.id", serviceParam: "selfId" }],
20
+ [
21
+ "SignedIn",
22
+ { argName: "CurrentUserId", param: "currentUserId", value: "currentUserId", serviceParam: "currentUserId" },
23
+ ],
24
+ ]);
25
+ readonly #guards: CatalogEntry[];
26
+ readonly #internalArgs: CatalogEntry[];
27
+ readonly reachedLibs: string[];
28
+
29
+ constructor(guards: CatalogEntry[], internalArgs: CatalogEntry[], reachedLibs: string[]) {
30
+ this.#guards = guards;
31
+ this.#internalArgs = internalArgs;
32
+ this.reachedLibs = reachedLibs;
33
+ }
34
+
35
+ static async load(sys: Sys) {
36
+ const reachedLibs = await GuardCatalog.#reachedLibs(sys);
37
+ const scopes = [{ type: sys.type, name: sys.name }, ...reachedLibs.map((name) => ({ type: "lib" as const, name }))];
38
+ const exports = (await Promise.all(scopes.map((scope) => GuardCatalog.#srvkitExports(sys, scope)))).flat();
39
+ const frameworkGuards = GuardCatalog.frameworkGuards.map((name) => ({ name, specifier: "akanjs/signal" }));
40
+ return new GuardCatalog(
41
+ [...exports.filter((entry) => entry.kind === "Guard"), ...frameworkGuards],
42
+ exports.filter((entry) => entry.kind === "InternalArg"),
43
+ reachedLibs,
44
+ );
45
+ }
46
+
47
+ get names() {
48
+ return [...new Set(this.#guards.map((guard) => guard.name))];
49
+ }
50
+
51
+ resolve(name: string) {
52
+ const matches = this.#guards.filter((guard) => guard.name === name);
53
+ const specifiers = [...new Set(matches.map((guard) => guard.specifier))];
54
+ return { specifier: specifiers[0] ?? null, ambiguous: specifiers.length > 1 ? specifiers : null };
55
+ }
56
+
57
+ actingUserOf(guards: readonly string[]): ActingUser | null {
58
+ for (const guard of guards) {
59
+ const acting = GuardCatalog.#actingArgOf.get(guard);
60
+ const specifier = acting
61
+ ? this.#internalArgs.find((entry) => entry.name === acting.argName)?.specifier
62
+ : undefined;
63
+ if (acting && specifier) return { ...acting, specifier };
64
+ }
65
+ return null;
66
+ }
67
+
68
+ static async #reachedLibs(sys: Sys) {
69
+ const workspaceLibs = await sys.workspace.getLibs();
70
+ const depsOf = async (type: "app" | "lib", name: string) => {
71
+ const jsonPath = `${type}s/${name}/akan.${type}.json`;
72
+ if (!(await sys.workspace.exists(jsonPath))) return null;
73
+ const { libDeps } = (await sys.workspace.readJson(jsonPath)) as { libDeps?: unknown };
74
+ return Array.isArray(libDeps) ? libDeps.filter((lib): lib is string => workspaceLibs.includes(lib)) : [];
75
+ };
76
+ const direct = await depsOf(sys.type, sys.name);
77
+ if (!direct) return workspaceLibs.filter((lib) => sys.type !== "lib" || lib !== sys.name);
78
+ const reached = new Set<string>();
79
+ const queue = [...direct];
80
+ while (queue.length) {
81
+ const lib = queue.shift();
82
+ if (!lib || reached.has(lib) || (sys.type === "lib" && lib === sys.name)) continue;
83
+ reached.add(lib);
84
+ queue.push(...((await depsOf("lib", lib)) ?? []));
85
+ }
86
+ return [...reached];
87
+ }
88
+
89
+ static async #srvkitExports(sys: Sys, scope: { type: "app" | "lib"; name: string }) {
90
+ const dir = `${scope.type}s/${scope.name}/srvkit`;
91
+ const files = (await sys.workspace.readdir(dir)).filter(
92
+ (file) => /\.tsx?$/.test(file) && !/\.(test|spec)\.tsx?$/.test(file) && file !== "index.ts",
93
+ );
94
+ const specifier = `@${scope.type}s/${scope.name}/srvkit`;
95
+ const contents = await Promise.all(files.map((file) => sys.workspace.readFile(`${dir}/${file}`)));
96
+ return contents.flatMap((content) =>
97
+ [...content.matchAll(/export class ([A-Z][A-Za-z0-9_]*)\s+implements\s+([^{]+)\{/g)].flatMap((match) => {
98
+ const implemented = (match[2] ?? "").split(",").map((name) => name.replace(/<.*$/s, "").trim());
99
+ const kind = implemented.includes("Guard")
100
+ ? "Guard"
101
+ : implemented.includes("InternalArg")
102
+ ? "InternalArg"
103
+ : null;
104
+ return kind && match[1] ? [{ kind, name: match[1], specifier }] : [];
105
+ }),
106
+ );
107
+ }
108
+ }
package/workflow/index.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  export * from "./artifacts";
2
2
  export * from "./executor";
3
+ export * from "./guardCatalog";
4
+ export * from "./interactionScaffold";
3
5
  export * from "./moduleIndex";
6
+ export * from "./mutationScaffold";
7
+ export * from "./mutationSource";
4
8
  export * from "./plan";
5
9
  export * from "./render";
6
10
  export * from "./rolloutGate";
@@ -0,0 +1,122 @@
1
+ import { capitalize, lowerlize } from "akanjs/common";
2
+ import type { Sys } from "../commandDecorators";
3
+ import type { ScaffoldFile, WorkflowDiagnostic } from "./types";
4
+
5
+ export class InteractionScaffold {
6
+ readonly name: string;
7
+ readonly className: string;
8
+ readonly hookName: string;
9
+ readonly #commonSpecifier: string;
10
+
11
+ constructor(sys: Sys, name: string) {
12
+ this.name = name;
13
+ this.className = capitalize(name);
14
+ this.hookName = `use${this.className}`;
15
+ this.#commonSpecifier = `@${sys.type}s/${sys.name}/common`;
16
+ }
17
+
18
+ static nameOf(raw: string) {
19
+ const name = lowerlize(raw.replace(/^use(?=[A-Z])/, ""));
20
+ return /^[a-z][A-Za-z0-9]*$/.test(name) ? name : null;
21
+ }
22
+
23
+ static async build(sys: Sys, raw: string | null) {
24
+ const diagnostics: WorkflowDiagnostic[] = [];
25
+ const name = raw ? InteractionScaffold.nameOf(raw) : null;
26
+ if (!name) {
27
+ diagnostics.push({
28
+ severity: "error",
29
+ code: "create-interaction-name-invalid",
30
+ input: "interaction",
31
+ message: `Interaction name "${raw ?? ""}" must be camelCase (boardKeys), optionally prefixed with use.`,
32
+ });
33
+ return { diagnostics, files: [] as ScaffoldFile[] };
34
+ }
35
+ const scaffold = new InteractionScaffold(sys, name);
36
+ const files = scaffold.files;
37
+ const taken = (
38
+ await Promise.all(
39
+ scaffold.claimedPaths.map(async (path) =>
40
+ (await sys.exists(path)) ? `${sys.type}s/${sys.name}/${path}` : null,
41
+ ),
42
+ )
43
+ ).filter((path): path is string => !!path);
44
+ if (taken.length)
45
+ diagnostics.push({
46
+ severity: "error",
47
+ code: "create-interaction-exists",
48
+ input: "interaction",
49
+ message: `${taken.join(", ")} already exists; pick another interaction name or extend the existing files.`,
50
+ });
51
+ return { diagnostics, files: taken.length ? [] : files };
52
+ }
53
+
54
+ get claimedPaths() {
55
+ const { name, hookName } = this;
56
+ return [
57
+ `common/${name}.ts`,
58
+ `common/${name}.tsx`,
59
+ `common/${name}.test.ts`,
60
+ `webkit/${hookName}.tsx`,
61
+ `webkit/${hookName}.ts`,
62
+ ];
63
+ }
64
+
65
+ get files(): ScaffoldFile[] {
66
+ return [
67
+ {
68
+ path: `common/${this.name}.ts`,
69
+ content: this.logicSource,
70
+ action: "create",
71
+ reason: "Pure interaction logic.",
72
+ },
73
+ {
74
+ path: `common/${this.name}.test.ts`,
75
+ content: this.testSource,
76
+ action: "create",
77
+ reason: "Test of the pure interaction logic.",
78
+ },
79
+ { path: `webkit/${this.hookName}.tsx`, content: this.hookSource, action: "create", reason: "Client hook." },
80
+ ];
81
+ }
82
+
83
+ get logicSource() {
84
+ return [
85
+ `export class ${this.className} {`,
86
+ " static next<State extends object>(state: State, input: Partial<State>) {",
87
+ " return { ...state, ...input };",
88
+ " }",
89
+ "}",
90
+ "",
91
+ ].join("\n");
92
+ }
93
+
94
+ get testSource() {
95
+ return [
96
+ 'import { describe, expect, it } from "bun:test";',
97
+ `import { ${this.className} } from "./${this.name}";`,
98
+ "",
99
+ `describe(${JSON.stringify(this.className)}, () => {`,
100
+ ' it("applies an input over the state it is handed", () => {',
101
+ ` expect(${this.className}.next({ idx: 0, isOpen: false }, { idx: 1 })).toEqual({ idx: 1, isOpen: false });`,
102
+ " });",
103
+ "});",
104
+ "",
105
+ ].join("\n");
106
+ }
107
+
108
+ get hookSource() {
109
+ return [
110
+ '"use client";',
111
+ `import { ${this.className} } from "${this.#commonSpecifier}";`,
112
+ "",
113
+ `export const ${this.hookName} = <State extends object>(state: State, commit: (next: State) => void) => {`,
114
+ " const change = async (input: Partial<State>) => {",
115
+ ` commit(${this.className}.next(state, input));`,
116
+ " };",
117
+ " return { change };",
118
+ "};",
119
+ "",
120
+ ].join("\n");
121
+ }
122
+ }