@akanjs/devkit 3.2.0-rc.1 → 3.2.0-rc.10

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 (108) hide show
  1. package/CHANGELOG.md +233 -11
  2. package/agentAssets.ts +1 -1
  3. package/akanApp/akanApp.host.test.ts +127 -0
  4. package/akanApp/akanApp.host.ts +50 -57
  5. package/akanApp/devHostPolicy.test.ts +11 -11
  6. package/akanApp/devHostPolicy.ts +2 -4
  7. package/akanContext.ts +2 -5
  8. package/akanMcpContract.ts +21 -10
  9. package/akanMcpToolError.ts +16 -0
  10. package/apiReport/apiDeclarationPrinter.ts +120 -0
  11. package/apiReport/apiReport.test.ts +497 -0
  12. package/apiReport/apiReport.ts +232 -0
  13. package/apiReport/apiReportDiff.ts +126 -0
  14. package/apiReport/apiReportPolicy.ts +65 -0
  15. package/apiReport/apiTargets.ts +311 -0
  16. package/apiReport/changesetBump.ts +19 -0
  17. package/applicationBuildReporter.test.ts +21 -0
  18. package/applicationBuildReporter.ts +8 -0
  19. package/applicationBuildRunner.test.ts +67 -0
  20. package/applicationBuildRunner.ts +6 -1
  21. package/artifact/implicitRootLayout.test.ts +4 -0
  22. package/artifact/implicitRootLayout.ts +13 -1
  23. package/artifact/storeBootstrap.ts +3 -2
  24. package/bareErrorScanner.ts +126 -0
  25. package/biome.base.json +2 -1
  26. package/codeAgent/skills/akan-debug/SKILL.md +7 -3
  27. package/codeAgent/skills/akan-err/SKILL.md +7 -0
  28. package/codeAgent/skills/akan-mcp/SKILL.md +7 -1
  29. package/codeAgent/skills/akan-scaffold/SKILL.md +3 -2
  30. package/codeAgent/skills/akan-transport/SKILL.md +2 -0
  31. package/codeAgent/skills/akan-validate/SKILL.md +1 -0
  32. package/codeAgent/tools/McpClient.ts +28 -6
  33. package/codeAgent/tools/McpToolPack.ts +1 -1
  34. package/codeAgent/tools/akanCodePlugins.test.ts +13 -1
  35. package/commandDecorators/argMeta.ts +4 -0
  36. package/commandDecorators/cliError.ts +72 -0
  37. package/commandDecorators/command.ts +186 -96
  38. package/commandDecorators/commandDecorators.test.ts +240 -17
  39. package/commandDecorators/exitCodeError.ts +6 -7
  40. package/commandDecorators/index.ts +1 -0
  41. package/commandDecorators/targetMeta.ts +3 -0
  42. package/conventionSet.test.ts +5 -2
  43. package/csrE2e/csrE2eHarness.fixture.ts +1 -1
  44. package/errorCodeReference.test.ts +282 -0
  45. package/errorCodeReference.ts +71 -0
  46. package/errorCodeScan.ts +551 -0
  47. package/frontendBuild/allRoutesBuilder.test.ts +16 -4
  48. package/frontendBuild/allRoutesBuilder.ts +24 -6
  49. package/frontendBuild/buildRouteClient.test.ts +43 -0
  50. package/frontendBuild/bundleDefine.ts +2 -0
  51. package/frontendBuild/clientBuildTypes.ts +5 -2
  52. package/frontendBuild/clientEntryDiscovery.test.ts +80 -0
  53. package/frontendBuild/clientEntryDiscovery.ts +53 -9
  54. package/frontendBuild/csrArtifactBuilder.test.ts +48 -0
  55. package/frontendBuild/csrArtifactBuilder.ts +9 -3
  56. package/frontendBuild/csrDevBundler.ts +12 -2
  57. package/frontendBuild/csrDevModuleCompiler.test.ts +97 -0
  58. package/frontendBuild/csrDevModuleCompiler.ts +37 -8
  59. package/frontendBuild/csrDevPatcher.test.ts +89 -2
  60. package/frontendBuild/csrDevPatcher.ts +47 -7
  61. package/frontendBuild/csrDevTypes.ts +16 -0
  62. package/frontendBuild/cssCompiler.test.ts +63 -1
  63. package/frontendBuild/devBarrelImports.ts +132 -0
  64. package/frontendBuild/devRegistryBundler.ts +19 -4
  65. package/frontendBuild/mcpAppViewBuilder.test.ts +67 -0
  66. package/frontendBuild/nativeCore.test.ts +148 -0
  67. package/frontendBuild/precompressArtifacts.ts +1 -1
  68. package/frontendBuild/ssrBaseArtifactBuilder.test.ts +26 -0
  69. package/frontendBuild/ssrBaseArtifactBuilder.ts +23 -19
  70. package/frontendBuild/ssrDevBundler.ts +13 -2
  71. package/frontendBuild/vendorImportScanner.test.ts +24 -3
  72. package/frontendBuild/vendorImportScanner.ts +25 -10
  73. package/incrementalBuilder/incrementalBuilder.host.test.ts +30 -1
  74. package/incrementalBuilder/incrementalBuilder.host.ts +10 -22
  75. package/lint/__fixtures__/no-import-common-err/bad.tsx +13 -0
  76. package/lint/__fixtures__/no-import-common-err/good.tsx +12 -0
  77. package/lint/no-import-common-err.grit +31 -0
  78. package/lint/no-init-fetch-in-client.grit +1 -1
  79. package/lint/no-web-only-api-outside-webkit.grit +3 -3
  80. package/maintainabilityBaseline.test.ts +49 -0
  81. package/maintainabilityBaseline.ts +73 -8
  82. package/maintainabilityContract.test.ts +302 -1
  83. package/maintainabilityContract.ts +87 -22
  84. package/maintainabilityExceptionScanner.test.ts +14 -0
  85. package/maintainabilityExceptionScanner.ts +5 -1
  86. package/maintainabilityLimits.ts +12 -0
  87. package/maintainabilityReport.ts +20 -3
  88. package/moduleFieldNames.test.ts +56 -0
  89. package/moduleFieldNames.ts +25 -0
  90. package/package.json +3 -3
  91. package/preview/PreviewView.ts +32 -11
  92. package/preview/previewView.test.ts +76 -9
  93. package/routeJsBudget.test.ts +47 -11
  94. package/routeJsBudget.ts +88 -25
  95. package/transforms/barrelAnalyzer.ts +46 -1
  96. package/transforms/barrelImportsPlugin.ts +61 -45
  97. package/transforms/transforms.test.ts +67 -1
  98. package/typecheck/projectTypecheck.ts +5 -1
  99. package/typecheck/typecheck.proc.ts +3 -2
  100. package/typecheck/typecheckFailure.ts +6 -1
  101. package/workflow/artifacts.ts +25 -7
  102. package/workflow/fieldType.ts +92 -0
  103. package/workflow/index.ts +2 -0
  104. package/workflow/plainName.ts +28 -0
  105. package/workflow/plan.ts +67 -22
  106. package/workflow/source.ts +0 -5
  107. package/workspaceGraph.test.ts +200 -3
  108. package/workspaceGraph.ts +46 -8
@@ -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}`)
@@ -16,6 +16,7 @@ import type {
16
16
  WorkflowNextActionCode,
17
17
  WorkflowOverallStatus,
18
18
  WorkflowPlan,
19
+ WorkflowRecommendation,
19
20
  WorkflowRunArtifact,
20
21
  WorkflowRunSource,
21
22
  WorkflowValidationCommandResult,
@@ -94,11 +95,11 @@ export const createWorkflowApplyReport = ({
94
95
  postApplyChecks?: WorkflowApplyReport["postApplyChecks"];
95
96
  recommendations?: WorkflowApplyReport["recommendations"];
96
97
  }): WorkflowApplyReport => {
97
- const validationCommands = recommendedValidationCommands ?? commands;
98
- const nextActionsWithIntent = nextActions.map((action) => ({
99
- ...action,
100
- action: inferNextActionCode(action, diagnostics),
101
- }));
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");
102
103
  if (sourceChangeBlocked(diagnostics) && !nextActionsWithIntent.some((action) => action.action === "blocked")) {
103
104
  nextActionsWithIntent.unshift({
104
105
  command: `akan workflow explain ${workflow}`,
@@ -110,12 +111,13 @@ export const createWorkflowApplyReport = ({
110
111
  (left, right) => nextActionPriority(left, diagnostics) - nextActionPriority(right, diagnostics),
111
112
  );
112
113
  const sourceFilesChanged = uniqueBy(changedFiles, (file) => `${file.action}:${file.path}:${file.reason}`);
113
- 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}`);
114
116
  return {
115
117
  schemaVersion: 1,
116
118
  workflow,
117
119
  mode,
118
- status: workflowStatus(diagnostics),
120
+ status,
119
121
  summary: {
120
122
  sourceFilesChanged,
121
123
  generatedFilesSynced,
@@ -133,6 +135,11 @@ export const createWorkflowApplyReport = ({
133
135
  };
134
136
  };
135
137
 
138
+ export const withoutApplyHints = (recommendations: readonly WorkflowRecommendation[]) =>
139
+ recommendations.filter(
140
+ (recommendation) => recommendation.kind !== "auto-apply" && recommendation.kind !== "validation",
141
+ );
142
+
136
143
  export const resolveWorkflowCommand = (command: string, plan: WorkflowPlan) => {
137
144
  const target = typeof plan.inputs.app === "string" ? plan.inputs.app : "<app-or-lib>";
138
145
  return command
@@ -526,6 +533,17 @@ export const createWorkflowValidationRunReport = async ({
526
533
  };
527
534
 
528
535
  export const createDryRunWorkflowApplyReport = (plan: WorkflowPlan) => {
536
+ if (workflowStatus(plan.diagnostics) === "failed")
537
+ return createWorkflowApplyReport({
538
+ workflow: plan.workflow,
539
+ mode: "dry-run",
540
+ changedFiles: [],
541
+ generatedFiles: [],
542
+ diagnostics: [...plan.diagnostics],
543
+ recommendations: withoutApplyHints(plan.recommendations),
544
+ nextActions: [],
545
+ plan,
546
+ });
529
547
  const changedFiles: PrimitiveChangedFile[] = plan.predictedChanges.flatMap((change) => {
530
548
  if (change.action !== "create" && change.action !== "modify") return [];
531
549
  return [
@@ -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,11 +1,13 @@
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";
6
7
  export * from "./moduleIndex";
7
8
  export * from "./mutationScaffold";
8
9
  export * from "./mutationSource";
10
+ export * from "./plainName";
9
11
  export * from "./plan";
10
12
  export * from "./render";
11
13
  export * from "./rolloutGate";
@@ -0,0 +1,28 @@
1
+ export const sysNamePattern = /^[a-z0-9][a-zA-Z0-9_-]*$/;
2
+ export const identifierPattern = /^_{0,2}[a-zA-Z][a-zA-Z0-9_-]*$/;
3
+ const packageNamePattern = /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
4
+
5
+ export type PlainNameKind = "app" | "lib" | "module" | "service" | "scalar";
6
+
7
+ export const isWorkflowSysName = (value: unknown): value is string =>
8
+ typeof value === "string" && sysNamePattern.test(value);
9
+ export const isWorkflowModuleName = (value: unknown): value is string =>
10
+ typeof value === "string" && identifierPattern.test(value);
11
+
12
+ export const plainNameProblem = (kind: PlainNameKind, name: string) => {
13
+ const isSys = kind === "app" || kind === "lib";
14
+ if (isSys ? isWorkflowSysName(name) : isWorkflowModuleName(name)) return null;
15
+ return `${JSON.stringify(name)} is no ${kind} name; use letters, digits, - and _, starting with ${isSys ? "a lowercase letter or digit" : "a letter"}`;
16
+ };
17
+
18
+ export const assertPlainName = (kind: PlainNameKind, name: string) => {
19
+ const problem = plainNameProblem(kind, name);
20
+ if (problem) throw new Error(problem);
21
+ };
22
+
23
+ export const assertPackageName = (name: string) => {
24
+ if (packageNamePattern.test(name)) return;
25
+ throw new Error(
26
+ `${JSON.stringify(name)} is no package name; use name or @scope/name, each of lowercase letters, digits, -, . and _, starting with a letter or digit`,
27
+ );
28
+ };
package/workflow/plan.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  import { capitalize } from "akanjs/common";
2
2
  import type { Sys, Workspace } from "../commandDecorators";
3
- import { workflowPlanApproval } from "./artifacts";
3
+ import { withoutApplyHints, workflowPlanApproval } from "./artifacts";
4
4
  import { resolveWorkflowSys } from "./executor";
5
+ import { fieldTypeSource } from "./fieldType";
5
6
  import { InteractionScaffold } from "./interactionScaffold";
6
7
  import { KeyRename } from "./keyRename";
7
8
  import { MutationScaffold } from "./mutationScaffold";
8
- import { coerceFieldDefault, getSysRoot, moduleSourcePaths } from "./source";
9
+ import { identifierPattern, sysNamePattern } from "./plainName";
10
+ import { coerceFieldDefault, getSysRoot, moduleSourcePaths, normalizeFieldType } from "./source";
9
11
  import type {
10
12
  WorkflowDiagnostic,
11
13
  WorkflowInputSpec,
@@ -17,11 +19,10 @@ import type {
17
19
  WorkflowSurfaceMode,
18
20
  } from "./types";
19
21
  import { addFieldUiPolicyForType } from "./uiPolicy";
22
+ import { workflowStatus } from "./utils";
20
23
 
21
24
  const surfaceModes = new Set<WorkflowSurfaceMode>(["infer", "include", "skip"]);
22
25
 
23
- const sysNamePattern = /^[a-z0-9][a-zA-Z0-9_-]*$/;
24
- const identifierPattern = /^_{0,2}[a-zA-Z][a-zA-Z0-9_-]*$/;
25
26
  //? These inputs become path segments, identifiers and command arguments, so none may carry a separator or a dot.
26
27
  const nameInputPatterns: Record<string, RegExp> = {
27
28
  app: sysNamePattern,
@@ -35,11 +36,6 @@ const nameInputPatterns: Record<string, RegExp> = {
35
36
  interaction: identifierPattern,
36
37
  };
37
38
 
38
- export const isWorkflowSysName = (value: unknown): value is string =>
39
- typeof value === "string" && sysNamePattern.test(value);
40
- export const isWorkflowModuleName = (value: unknown): value is string =>
41
- typeof value === "string" && identifierPattern.test(value);
42
-
43
39
  const parseStringList = (value: unknown) => {
44
40
  if (Array.isArray(value)) {
45
41
  const values = value.filter((item): item is string => typeof item === "string" && item.trim().length > 0);
@@ -487,7 +483,7 @@ const syssHolding = async (workspace: Workspace, module: string, except: Sys) =>
487
483
  .map(async (name) => await resolveWorkflowSys(workspace, name)),
488
484
  );
489
485
  const holding = await Promise.all(
490
- others.map(async (other) => (other && (await other.readdir("lib")).includes(module) ? other : null)),
486
+ others.map(async (other) => (other && (await other.getDatabaseModules()).includes(module) ? other : null)),
491
487
  );
492
488
  return holding.filter((other): other is Sys => other !== null);
493
489
  };
@@ -495,28 +491,64 @@ const syssHolding = async (workspace: Workspace, module: string, except: Sys) =>
495
491
  const moduleMissing = async (workspace: Workspace, sys: Sys, module: string): Promise<WorkflowDiagnostic | null> => {
496
492
  if ((await sys.readdir("lib")).includes(module)) return null;
497
493
  const root = getSysRoot(sys);
498
- const modules = [...(await sys.getDatabaseModules()), ...(await sys.getServiceModules())];
499
- const sameName = modules.find((name) => name.replace(/^_/, "").toLowerCase() === module.toLowerCase());
494
+ const modules = await sys.getDatabaseModules();
495
+ const sameName = modules.find((name) => name.toLowerCase() === module.toLowerCase());
496
+ const service = (await sys.getServiceModules()).find(
497
+ (name) => name.replace(/^_/, "").toLowerCase() === module.replace(/^_/, "").toLowerCase(),
498
+ );
500
499
  const isScalar = (await sys.getScalarModules()).includes(module);
501
- const [holder] = sameName || isScalar ? [] : await syssHolding(workspace, module, sys);
500
+ const [holder] = sameName || service || isScalar ? [] : await syssHolding(workspace, module, sys);
502
501
  return {
503
502
  severity: "error",
504
503
  code: "workflow-module-missing",
505
504
  input: "module",
506
505
  message: [
507
506
  `No module named ${module} in ${root}: ${root}/lib/${module} does not exist.`,
508
- modules.length ? `Its modules are ${modules.join(", ")}.` : `${root} has no modules yet.`,
507
+ modules.length ? `Its database modules are ${modules.join(", ")}.` : `${root} has no database modules yet.`,
509
508
  sameName
510
509
  ? `Did you mean ${sameName}?`
511
- : isScalar
512
- ? `${root}/lib/__scalar/${module} is a scalar, and this workflow writes only into a module under ${root}/lib.`
513
- : holder
514
- ? `${module} is in ${getSysRoot(holder)}, so plan this with --app ${holder.name}.`
515
- : `If ${module} is a new model, create it with the create-module workflow first, then plan this again.`,
510
+ : service
511
+ ? `${root}/lib/${service} is a service module, which stores no model, and this workflow writes only into a database module.`
512
+ : isScalar
513
+ ? `${root}/lib/__scalar/${module} is a scalar, and this workflow writes only into a module under ${root}/lib.`
514
+ : holder
515
+ ? `${module} is in ${getSysRoot(holder)}, so plan this with --app ${holder.name}.`
516
+ : `If ${module} is a new model, create it with the create-module workflow first, then plan this again.`,
517
+ ].join(" "),
518
+ };
519
+ };
520
+
521
+ const moduleNotDatabase = async (sys: Sys, spec: WorkflowSpec, module: string): Promise<WorkflowDiagnostic | null> => {
522
+ const databaseModules = await sys.getDatabaseModules();
523
+ if (databaseModules.includes(module)) return null;
524
+ const root = getSysRoot(sys);
525
+ const isServiceModule = module.startsWith("_") && !module.startsWith("__");
526
+ return {
527
+ severity: "error",
528
+ code: "workflow-module-not-database",
529
+ input: "module",
530
+ message: [
531
+ isServiceModule
532
+ ? `${module} in ${root} is a service module, which stores no model.`
533
+ : `${root}/lib/${module} is not a database module: it has no ${module}.constant.ts.`,
534
+ `${spec.name} needs a database module, one with lib/<model>/<model>.constant.ts.`,
535
+ databaseModules.length
536
+ ? `Its database modules are ${databaseModules.join(", ")}.`
537
+ : `${root} has no database modules yet.`,
538
+ spec.inputs.field
539
+ ? "Add the field to one of them, or create its model with the create-module workflow first; a value several models embed is a scalar, made with create-scalar and added to a model as a field of that type."
540
+ : "Plan this on one of them, or create the model with the create-module workflow first.",
516
541
  ].join(" "),
517
542
  };
518
543
  };
519
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
+
520
552
  export const workflowTargetDiagnostics = async (
521
553
  workspace: Workspace,
522
554
  spec: WorkflowSpec,
@@ -539,11 +571,14 @@ export const workflowTargetDiagnostics = async (
539
571
  const module = planString(inputs, "module");
540
572
  const moduleSpec: WorkflowInputSpec | undefined = spec.inputs.module;
541
573
  if (!module || !moduleSpec?.mustExist) return [];
542
- const missing = await moduleMissing(workspace, sys, module);
543
- return missing ? [missing] : [];
574
+ const refused =
575
+ (await moduleMissing(workspace, sys, module)) ??
576
+ (await moduleNotDatabase(sys, spec, module)) ??
577
+ (await fieldTypeUnknown(sys, spec, module, planString(inputs, "type")));
578
+ return refused ? [refused] : [];
544
579
  };
545
580
 
546
- export const inspectWorkflowPlan = async (
581
+ const inspectPlanTarget = async (
547
582
  workspace: Workspace,
548
583
  spec: WorkflowSpec,
549
584
  plan: WorkflowPlan,
@@ -586,3 +621,13 @@ export const inspectWorkflowPlan = async (
586
621
  recommendations: [...plan.recommendations, ...inspected.recommendations],
587
622
  };
588
623
  };
624
+
625
+ export const inspectWorkflowPlan = async (
626
+ workspace: Workspace,
627
+ spec: WorkflowSpec,
628
+ plan: WorkflowPlan,
629
+ ): Promise<WorkflowPlan> => {
630
+ const inspected = await inspectPlanTarget(workspace, spec, plan);
631
+ if (workflowStatus(inspected.diagnostics) === "passed") return inspected;
632
+ return { ...inspected, recommendations: withoutApplyHints(inspected.recommendations) };
633
+ };
@@ -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 => {
@@ -1,7 +1,17 @@
1
- import { describe, expect, it } from "bun:test";
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
2
5
  import type { AkanChangeMap, AkanGraphEdge, AkanGraphNode } from "@akanjs/language";
6
+ import { AkanGraphDiff } from "@akanjs/language";
7
+ import type { Workspace } from "./commandDecorators";
3
8
  import { WorkspaceGraph } from "./workspaceGraph";
4
9
 
10
+ const roots: string[] = [];
11
+ afterEach(async () => {
12
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
13
+ });
14
+
5
15
  const node = (id: string, owner: string | null, change?: AkanGraphNode["change"]): AkanGraphNode => ({
6
16
  id,
7
17
  kind: id.split(":")[0] as AkanGraphNode["kind"],
@@ -13,10 +23,15 @@ const node = (id: string, owner: string | null, change?: AkanGraphNode["change"]
13
23
  at: null,
14
24
  ...(change ? { change } : {}),
15
25
  });
16
- const edge = (from: string, to: string, change?: AkanGraphEdge["change"]): AkanGraphEdge => ({
26
+ const edge = (
27
+ from: string,
28
+ to: string,
29
+ change?: AkanGraphEdge["change"],
30
+ kind: AkanGraphEdge["kind"] = "calls",
31
+ ): AkanGraphEdge => ({
17
32
  from,
18
33
  to,
19
- kind: "calls",
34
+ kind,
20
35
  label: null,
21
36
  count: 1,
22
37
  at: null,
@@ -102,6 +117,60 @@ describe("WorkspaceGraph.answer", () => {
102
117
  });
103
118
  });
104
119
 
120
+ describe("WorkspaceGraph.answer past the node limit", () => {
121
+ const changeMapOf = (nodes: AkanGraphNode[], edges: AkanGraphEdge[]): AkanChangeMap => ({
122
+ version: 1,
123
+ nodes,
124
+ edges,
125
+ since: "0123456789ab",
126
+ summary: { added: 0, removed: 0, changed: nodes.filter((item) => item.change).length },
127
+ unmapped: [],
128
+ });
129
+
130
+ it("drops the layout that only wraps a changed route, and keeps the route", () => {
131
+ const fillers = Array.from({ length: 599 }, (_, idx) => node(`method:apps/demo:memo.m${idx}`, "apps/demo"));
132
+ const answer = WorkspaceGraph.answer(
133
+ {
134
+ version: 1,
135
+ nodes: [
136
+ node("layout:apps/demo:/", "apps/demo"),
137
+ node("route:apps/demo:/memo", "apps/demo", "changed"),
138
+ ...fillers,
139
+ ],
140
+ edges: [edge("layout:apps/demo:/", "route:apps/demo:/memo", undefined, "wraps")],
141
+ },
142
+ {},
143
+ );
144
+ if ("diagnostics" in answer) throw new Error("expected an answer");
145
+ expect(answer.nodes.filter((item) => item.kind !== "method").map(({ id }) => id)).toEqual([
146
+ "route:apps/demo:/memo",
147
+ ]);
148
+ expect(answer.edges).toEqual([]);
149
+ });
150
+
151
+ it("answers a Change Map whole once its unchanged pages are set aside", () => {
152
+ const routes = Array.from({ length: 10 }, (_, idx) => node(`route:apps/demo:/r${idx}`, "apps/demo"));
153
+ const changed = Array.from({ length: 595 }, (_, idx) =>
154
+ node(`method:apps/demo:memo.m${idx}`, "apps/demo", "changed"),
155
+ );
156
+ const answer = WorkspaceGraph.answerChanges(changeMapOf([...routes, ...changed], []), {});
157
+ if ("diagnostics" in answer) throw new Error("expected an answer");
158
+ expect(answer.nodes).toHaveLength(595);
159
+ expect(answer.summary).toEqual({ added: 0, removed: 0, changed: 595 });
160
+ });
161
+
162
+ it("counts the changed pages it keeps when it names the owners to scope to", () => {
163
+ const routes = Array.from({ length: 650 }, (_, idx) => node(`route:apps/demo:/r${idx}`, "apps/demo", "changed"));
164
+ const answer = WorkspaceGraph.answerChanges(
165
+ changeMapOf([...routes, node("method:libs/shared:file.touch", "libs/shared", "changed")], []),
166
+ {},
167
+ );
168
+ if (!("owners" in answer)) throw new Error("expected a scope request");
169
+ expect(answer.diagnostics.map(({ message }) => message).join("\n")).toContain("651 nodes");
170
+ expect(answer.owners).toEqual({ "apps/demo": 650, "libs/shared": 1 });
171
+ });
172
+ });
173
+
105
174
  describe("WorkspaceGraph.scope", () => {
106
175
  it("keeps a lib's guard without pulling in every app endpoint that uses it", () => {
107
176
  const guard = "guard:libs/shared:Admin";
@@ -118,4 +187,132 @@ describe("WorkspaceGraph.scope", () => {
118
187
  expect(scoped.nodes.map(({ id }) => id)).toEqual([guard, own]);
119
188
  expect(scoped.edges).toEqual([edge(own, guard)]);
120
189
  });
190
+
191
+ it("follows a role the scope's method calls to the implementation its app binds", () => {
192
+ const role = "adaptor::LlmAdaptorRole";
193
+ const binds = (to: string, label: string): AkanGraphEdge => ({ ...edge(role, to), kind: "binds", label });
194
+ const graph = {
195
+ version: 1 as const,
196
+ nodes: [
197
+ { ...node("method:apps/sceny:plan.draft", "apps/sceny"), module: "plan" },
198
+ { ...node("method:apps/angelo:chat.reply", "apps/angelo"), module: "chat" },
199
+ { ...node("method:libs/genai:prompt.run", "libs/genai"), module: "prompt" },
200
+ node(role, null),
201
+ node("adaptor::AnthropicLlm", null),
202
+ node("adaptor:apps/angelo:ChatCompletionsLlm", "apps/angelo"),
203
+ node("adaptor:libs/genai:GenaiLlm", "libs/genai"),
204
+ ],
205
+ edges: [
206
+ edge("method:apps/sceny:plan.draft", role),
207
+ edge("method:apps/angelo:chat.reply", role),
208
+ edge("method:libs/genai:prompt.run", role),
209
+ binds("adaptor::AnthropicLlm", "apps/sceny"),
210
+ binds("adaptor:apps/angelo:ChatCompletionsLlm", "apps/angelo"),
211
+ binds("adaptor:libs/genai:GenaiLlm", "libs/genai"),
212
+ ],
213
+ };
214
+ const bound = (scope: { app?: string; module?: string }) =>
215
+ WorkspaceGraph.scope(graph, scope)
216
+ .edges.filter(({ kind }) => kind === "binds")
217
+ .map(({ to, label }) => `${label} ${to}`);
218
+ expect(bound({ app: "sceny" })).toEqual(["apps/sceny adaptor::AnthropicLlm"]);
219
+ expect(WorkspaceGraph.scope(graph, { app: "sceny" }).nodes.map(({ id }) => id)).toContain("adaptor::AnthropicLlm");
220
+ expect(bound({ app: "angelo" })).toEqual(["apps/angelo adaptor:apps/angelo:ChatCompletionsLlm"]);
221
+ expect(bound({ app: "genai" })).toEqual([
222
+ "apps/sceny adaptor::AnthropicLlm",
223
+ "apps/angelo adaptor:apps/angelo:ChatCompletionsLlm",
224
+ "libs/genai adaptor:libs/genai:GenaiLlm",
225
+ ]);
226
+ expect(bound({ module: "plan" })).toHaveLength(3);
227
+ expect(
228
+ WorkspaceGraph.visibleTo(graph, "sceny")
229
+ .edges.filter(({ kind }) => kind === "binds")
230
+ .map(({ label }) => label),
231
+ ).toEqual(["apps/sceny"]);
232
+ });
233
+
234
+ it("scopes a Change Map to the binding the app runs, whether or not that binding changed", () => {
235
+ const role = "adaptor::LlmAdaptorRole";
236
+ const versioned = (id: string, owner: string | null, digest: string, module: string | null = null) => ({
237
+ ...node(id, owner),
238
+ module,
239
+ digest,
240
+ at: { file: `${owner ?? "akanjs"}/x.ts`, line: 1, column: 1 },
241
+ });
242
+ const binds = (to: string, label: string): AkanGraphEdge => ({ ...edge(role, to), kind: "binds", label });
243
+ const graphOf = (ownBinding: boolean, method: string, lib: string) => ({
244
+ version: 1 as const,
245
+ nodes: [
246
+ versioned("method:apps/sceny:plan.draft", "apps/sceny", method, "plan"),
247
+ node(role, null),
248
+ node("adaptor::AnthropicLlm", null),
249
+ versioned("adaptor:libs/genai:GenaiLlm", "libs/genai", lib),
250
+ ],
251
+ edges: [
252
+ edge("method:apps/sceny:plan.draft", role),
253
+ ...(ownBinding ? [binds("adaptor::AnthropicLlm", "apps/sceny")] : []),
254
+ binds("adaptor:libs/genai:GenaiLlm", "libs/genai"),
255
+ ],
256
+ });
257
+ const scopedBinds = (base: ReturnType<typeof graphOf>, head: ReturnType<typeof graphOf>) => {
258
+ const changes = { ...AkanGraphDiff.between(base, head), since: "0123456789ab", unmapped: [] };
259
+ const answer = WorkspaceGraph.answerChanges(changes, { app: "sceny" });
260
+ if (!("edges" in answer)) throw new Error("expected an answer");
261
+ return answer.edges
262
+ .filter(({ kind }) => kind === "binds")
263
+ .map(({ label, to, change }) => `${change ?? "unchanged"} ${label} ${to}`);
264
+ };
265
+ expect(scopedBinds(graphOf(true, "1", "1"), graphOf(true, "2", "2"))).toEqual([
266
+ "unchanged apps/sceny adaptor::AnthropicLlm",
267
+ ]);
268
+ expect(scopedBinds(graphOf(true, "1", "1"), graphOf(false, "1", "2"))).toEqual([
269
+ "unchanged libs/genai adaptor:libs/genai:GenaiLlm",
270
+ "removed apps/sceny adaptor::AnthropicLlm",
271
+ ]);
272
+ });
273
+ });
274
+
275
+ describe("WorkspaceGraph.changesAt", () => {
276
+ it("maps an edit to a factory in one lib onto the guards it makes in another, and lists no file as unmapped", async () => {
277
+ const root = await mkdtemp(path.join(tmpdir(), "akan-graph-"));
278
+ roots.push(root);
279
+ const git = (...args: string[]) => {
280
+ const result = Bun.spawnSync(
281
+ ["git", "-c", "user.name=akan", "-c", "user.email=akan@example.com", "-c", "commit.gpgsign=false", ...args],
282
+ { cwd: root, env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" } },
283
+ );
284
+ expect(result.exitCode).toBe(0);
285
+ return result.stdout.toString().trim();
286
+ };
287
+ const write = async (file: string, text: string) => {
288
+ await mkdir(path.dirname(path.join(root, file)), { recursive: true });
289
+ await writeFile(path.join(root, file), text);
290
+ };
291
+ const factory = (body: string) => `import type { Guard } from "akanjs/signal";
292
+ export const resourceGuard = (field: string) =>
293
+ class implements Guard {
294
+ canPass() {
295
+ return ${body};
296
+ }
297
+ };
298
+ `;
299
+ git("init", "-q", "-b", "main");
300
+ await write("libs/util/srvkit/resource.ts", factory("!!field"));
301
+ await write(
302
+ "apps/demo/srvkit/guards.ts",
303
+ `import { resourceGuard } from "@libs/util/srvkit";\nexport const Member = resourceGuard("member");\n`,
304
+ );
305
+ git("add", "-A");
306
+ git("commit", "-q", "--no-verify", "-m", "base");
307
+ await write("libs/util/srvkit/resource.ts", factory("field.length > 0"));
308
+ const changes = await WorkspaceGraph.changesAt(
309
+ { workspaceRoot: root } as unknown as Workspace,
310
+ git("rev-parse", "HEAD"),
311
+ );
312
+ expect(changes.nodes.filter((node) => node.change).map(({ id, change }) => `${change} ${id}`)).toEqual([
313
+ "changed guard:apps/demo:Member",
314
+ ]);
315
+ expect(changes.unmapped).toEqual([]);
316
+ expect(changes.nodes.some((node) => "sources" in node)).toBe(false);
317
+ });
121
318
  });