@akanjs/devkit 2.3.9-rc.1 → 2.3.9-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.
@@ -0,0 +1,429 @@
1
+ import { capitalize } from "akanjs/common";
2
+ import { workflowPlanApproval } from "./artifacts";
3
+ import { coerceFieldDefault, moduleSourcePaths } from "./source";
4
+ import type {
5
+ WorkflowDiagnostic,
6
+ WorkflowInputSpec,
7
+ WorkflowInputValue,
8
+ WorkflowPlan,
9
+ WorkflowPlanInputs,
10
+ WorkflowRecommendation,
11
+ WorkflowSpec,
12
+ WorkflowSurfaceMode,
13
+ } from "./types";
14
+ import { addFieldUiPolicyForType } from "./uiPolicy";
15
+
16
+ const surfaceModes = new Set<WorkflowSurfaceMode>(["infer", "include", "skip"]);
17
+
18
+ const parseStringList = (value: unknown) => {
19
+ if (Array.isArray(value)) {
20
+ const values = value.filter((item): item is string => typeof item === "string" && item.trim().length > 0);
21
+ return values.length === value.length ? values : null;
22
+ }
23
+ if (typeof value !== "string") return null;
24
+ return value
25
+ .split(",")
26
+ .map((item) => item.trim())
27
+ .filter(Boolean);
28
+ };
29
+
30
+ const normalizeInputValue = (name: string, spec: WorkflowInputSpec, value: unknown): WorkflowInputValue | null => {
31
+ if (spec.type === "string") return typeof value === "string" && value.length > 0 ? value : null;
32
+ if (spec.type === "string-list") {
33
+ const values = parseStringList(value);
34
+ return values && values.length > 0 ? values : null;
35
+ }
36
+ if (spec.type === "boolean") {
37
+ if (typeof value === "boolean") return value;
38
+ if (typeof value !== "string") return null;
39
+ const lowered = value.trim().toLowerCase();
40
+ if (lowered === "true") return true;
41
+ if (lowered === "false") return false;
42
+ return null;
43
+ }
44
+ if (typeof value === "string" && surfaceModes.has(value as WorkflowSurfaceMode)) return value as WorkflowSurfaceMode;
45
+ throw new Error(`Unsupported workflow input value for ${name}`);
46
+ };
47
+
48
+ export const listWorkflowSpecs = (specs: readonly WorkflowSpec[]) =>
49
+ [...specs].sort((a, b) => a.name.localeCompare(b.name));
50
+
51
+ export const getWorkflowSpec = (specs: readonly WorkflowSpec[], name: string) =>
52
+ specs.find((spec) => spec.name === name) ?? null;
53
+
54
+ export const compactWorkflowInputs = (inputs: WorkflowPlanInputs) =>
55
+ Object.fromEntries(Object.entries(inputs).filter(([, value]) => value !== null && value !== ""));
56
+
57
+ const addFieldTargetRoot = (app: string | null) => (app ? `apps/${app}` : "*");
58
+
59
+ const addFieldPaths = (inputs: Record<string, WorkflowInputValue>) => {
60
+ const app = typeof inputs.app === "string" ? inputs.app : null;
61
+ const module = typeof inputs.module === "string" ? inputs.module : "<module>";
62
+ const paths = moduleSourcePaths(module);
63
+ const root = addFieldTargetRoot(app);
64
+ return {
65
+ constant: `${root}/${paths.constant}`,
66
+ dictionary: `${root}/${paths.dictionary}`,
67
+ template: `${root}/${paths.template}`,
68
+ unit: `${root}/${paths.unit}`,
69
+ view: `${root}/${paths.view}`,
70
+ };
71
+ };
72
+
73
+ const selectedAddFieldSurfaces = (inputs: Record<string, WorkflowInputValue>) =>
74
+ Array.isArray(inputs.surfaces) ? new Set(inputs.surfaces) : null;
75
+
76
+ const addFieldDefaultCoercion = (inputs: Record<string, WorkflowInputValue>) => {
77
+ const typeName = typeof inputs.type === "string" ? inputs.type : null;
78
+ const defaultValue = typeof inputs.default === "string" ? inputs.default : null;
79
+ if (!typeName || defaultValue === null) return null;
80
+ if (typeName.toLowerCase() === "number" || typeName.toLowerCase() === "numeric") return null;
81
+ const enumValues = Array.isArray(inputs.values) ? inputs.values : null;
82
+ return coerceFieldDefault(typeName.toLowerCase() === "enum" ? "enum" : typeName, defaultValue, { enumValues });
83
+ };
84
+
85
+ const createAddFieldDefaultRecommendations = (inputs: Record<string, WorkflowInputValue>): WorkflowRecommendation[] => {
86
+ const field = typeof inputs.field === "string" ? inputs.field : "<field>";
87
+ const coercion = addFieldDefaultCoercion(inputs);
88
+ if (!coercion?.normalized || !coercion.expression) return [];
89
+ return [
90
+ {
91
+ code: "add-field-default-normalized",
92
+ kind: "manual-action",
93
+ confidence: "high",
94
+ message: `Default for ${field} will be normalized to ${coercion.normalizedType} literal ${coercion.expression}.`,
95
+ action:
96
+ "Review the normalized default in the apply report. To leave the field without a default, omit the default input.",
97
+ },
98
+ ];
99
+ };
100
+
101
+ const moneyLikeFieldPattern = /(budget|price|amount|cost|rate|fee|salary|balance)/i;
102
+ const wholeNumberFieldPattern = /(count|quantity|total|number|index|rank|order|age)/i;
103
+
104
+ const createAddFieldInputRecommendations = (inputs: Record<string, WorkflowInputValue>): WorkflowRecommendation[] => {
105
+ const field = typeof inputs.field === "string" ? inputs.field : "<field>";
106
+ const typeName = typeof inputs.type === "string" ? inputs.type : null;
107
+ const recommendations: WorkflowRecommendation[] = [];
108
+ if (!typeName) return recommendations;
109
+ if (typeName.toLowerCase() === "number" || typeName.toLowerCase() === "numeric") {
110
+ const recommendedType = moneyLikeFieldPattern.test(field)
111
+ ? "Float"
112
+ : wholeNumberFieldPattern.test(field)
113
+ ? "Int"
114
+ : null;
115
+ recommendations.push({
116
+ code: "add-field-type-choice",
117
+ kind: "input-guidance",
118
+ confidence: recommendedType ? "high" : "medium",
119
+ message: recommendedType
120
+ ? `Use ${recommendedType} for ${field}; Float is recommended for money-like decimal values and Int for whole-number values.`
121
+ : `Choose Int for whole-number ${field} values or Float for decimal ${field} values.`,
122
+ action: recommendedType
123
+ ? `Re-run plan_workflow with type="${recommendedType}".`
124
+ : 'Re-run plan_workflow with type="Int" or type="Float".',
125
+ });
126
+ }
127
+ if (typeName.toLowerCase() === "enum" && !Array.isArray(inputs.values)) {
128
+ recommendations.push({
129
+ code: "add-field-enum-values",
130
+ kind: "input-guidance",
131
+ confidence: "high",
132
+ message: `Enum field ${field} needs values before apply can choose valid dictionary and default behavior.`,
133
+ action: 'Pass values as an array or comma-separated string, for example values=["draft","done"].',
134
+ });
135
+ }
136
+ if (inputs.default === undefined) {
137
+ recommendations.push({
138
+ code: "add-field-default-optional",
139
+ kind: "input-guidance",
140
+ confidence: "medium",
141
+ message: `No default will be written for ${field} because default is omitted.`,
142
+ action: "Keep default omitted when the field should have no default value.",
143
+ });
144
+ }
145
+ return recommendations;
146
+ };
147
+
148
+ const createAddFieldRecommendations = (inputs: Record<string, WorkflowInputValue>): WorkflowRecommendation[] => {
149
+ const app = typeof inputs.app === "string" ? inputs.app : "<app-or-lib>";
150
+ const module = typeof inputs.module === "string" ? inputs.module : "<module>";
151
+ const field = typeof inputs.field === "string" ? inputs.field : "<field>";
152
+ const typeName = typeof inputs.type === "string" ? inputs.type : null;
153
+ if (!typeName) return [];
154
+
155
+ const policy = addFieldUiPolicyForType(typeName);
156
+ const normalizedType = policy.normalizedType;
157
+ const paths = addFieldPaths(inputs);
158
+ const surfaces = selectedAddFieldSurfaces(inputs);
159
+ const templateRequested = surfaces?.has("template") ?? false;
160
+ const includeInLight = inputs.includeInLight === true;
161
+ return [
162
+ ...(normalizedType === "Int" || normalizedType === "Float"
163
+ ? [
164
+ {
165
+ code: "add-field-import",
166
+ kind: "import" as const,
167
+ target: paths.constant,
168
+ confidence: "high" as const,
169
+ message: `Import ${normalizedType} from "akanjs/base" before writing field(${normalizedType}).`,
170
+ },
171
+ ]
172
+ : []),
173
+ {
174
+ code: "add-field-placement-constant",
175
+ kind: "placement",
176
+ target: paths.constant,
177
+ confidence: "high",
178
+ message: `Insert ${field}: field(${normalizedType}) in ${capitalize(module)}Input.`,
179
+ },
180
+ {
181
+ code: "add-field-placement-dictionary",
182
+ kind: "placement",
183
+ target: paths.dictionary,
184
+ confidence: "high",
185
+ message: `Add dictionary labels for ${module}.${field}.`,
186
+ },
187
+ {
188
+ code: "add-field-component",
189
+ kind: "ui-component",
190
+ target: paths.template,
191
+ confidence: policy.confidence,
192
+ action: templateRequested
193
+ ? `apply_workflow will try to add ${policy.component} to Template when the existing Template has a safe generated field-list pattern.`
194
+ : `Recommended component is ${policy.component}. Pass surfaces=["template"] when this field should be rendered in the Template form.`,
195
+ message: `Recommended UI component for ${field} (${normalizedType}): ${policy.component}.`,
196
+ },
197
+ ...(!surfaces
198
+ ? [
199
+ {
200
+ code: "add-field-template-surface-choice",
201
+ kind: "manual-action" as const,
202
+ target: paths.template,
203
+ action: `Choose whether to expose ${field} in Template by passing surfaces=["template"].`,
204
+ confidence: "medium" as const,
205
+ message: `Template exposure for ${module}.${field} is not selected yet.`,
206
+ },
207
+ ]
208
+ : []),
209
+ ...(!includeInLight
210
+ ? [
211
+ {
212
+ code: "add-field-light-projection-choice",
213
+ kind: "manual-action" as const,
214
+ target: paths.constant,
215
+ action: `Pass includeInLight=true when users should see ${field} in list/card data. Without it, the field can exist on ${capitalize(
216
+ module,
217
+ )}Input but stay absent from Light${capitalize(module)} projections.`,
218
+ confidence: "medium" as const,
219
+ message: `${module}.${field} is not selected for list/card projection data.`,
220
+ },
221
+ ]
222
+ : []),
223
+ ...(includeInLight
224
+ ? [
225
+ {
226
+ code: "add-field-light-projection",
227
+ kind: "placement" as const,
228
+ target: paths.constant,
229
+ confidence: "high" as const,
230
+ message: `Add ${field} to Light${capitalize(module)} projection fields.`,
231
+ },
232
+ ]
233
+ : []),
234
+ ...(surfaces && !templateRequested
235
+ ? [
236
+ {
237
+ code: "add-field-ui-surface-skip",
238
+ kind: "manual-action" as const,
239
+ target: paths.template,
240
+ confidence: "medium" as const,
241
+ message: `Template form auto-edit is skipped for ${module}.${field} because surfaces does not include "template".`,
242
+ action: `Pass surfaces=["template"] when users should enter ${field} in the Template form.`,
243
+ },
244
+ ]
245
+ : []),
246
+ {
247
+ code: "add-field-ui-manual-review",
248
+ kind: "manual-action",
249
+ target: paths.template,
250
+ action: `After apply, verify what users can see: Template form auto-edit only runs when a generated ${module}Form hook and Layout.Template field list are present. Unit/View cards are not auto-edited, so ${field} may be stored on ${capitalize(
251
+ module,
252
+ )} but not shown in list/card UI until you place it in the local card layout.`,
253
+ confidence: "medium",
254
+ message: `${app}:${module}.${field} may need UI review for user-visible Template, Unit, and View behavior.`,
255
+ },
256
+ ...createAddFieldDefaultRecommendations(inputs),
257
+ ...createAddFieldInputRecommendations(inputs),
258
+ ];
259
+ };
260
+
261
+ const createWorkflowPlanPredictedChanges = (
262
+ spec: WorkflowSpec,
263
+ inputs: Record<string, WorkflowInputValue>,
264
+ ): WorkflowPlan["predictedChanges"] => {
265
+ if (spec.name !== "add-field") return spec.predictedChanges;
266
+ const paths = addFieldPaths(inputs);
267
+ const surfaces = selectedAddFieldSurfaces(inputs);
268
+ const includeInLight = inputs.includeInLight === true;
269
+ return [
270
+ {
271
+ target: paths.constant,
272
+ action: "modify",
273
+ applyScope: "auto",
274
+ reason: includeInLight ? "Field shape and Light projection are added." : "Field shape is added.",
275
+ },
276
+ {
277
+ target: paths.dictionary,
278
+ action: "modify",
279
+ applyScope: "auto",
280
+ reason: "Field label is added.",
281
+ },
282
+ ...(!surfaces || surfaces.has("template")
283
+ ? [
284
+ {
285
+ target: paths.template,
286
+ action: "modify" as const,
287
+ applyScope: surfaces?.has("template") ? ("auto" as const) : ("manual-review" as const),
288
+ reason: surfaces?.has("template")
289
+ ? "Template form is selected for safe-pattern auto insertion."
290
+ : "Form surface may include the field.",
291
+ },
292
+ ]
293
+ : []),
294
+ {
295
+ target: `${addFieldTargetRoot(typeof inputs.app === "string" ? inputs.app : null)}/lib/cnst.ts`,
296
+ action: "sync",
297
+ applyScope: "generated-sync",
298
+ reason: "Generated constants may change after sync.",
299
+ },
300
+ {
301
+ target: `${addFieldTargetRoot(typeof inputs.app === "string" ? inputs.app : null)}/lib/dict.ts`,
302
+ action: "sync",
303
+ applyScope: "generated-sync",
304
+ reason: "Generated dictionary barrel may change after sync.",
305
+ },
306
+ ];
307
+ };
308
+
309
+ const createWorkflowPlanOptionalSurfaces = (
310
+ spec: WorkflowSpec,
311
+ inputs: Record<string, WorkflowInputValue>,
312
+ ): Record<string, WorkflowSurfaceMode> => {
313
+ const optionalSurfaces = spec.optionalSurfaces ?? {};
314
+ const surfaces = selectedAddFieldSurfaces(inputs);
315
+ if (spec.name !== "add-field" || !surfaces) return optionalSurfaces;
316
+ return Object.fromEntries(
317
+ Object.entries(optionalSurfaces).map(([surface, mode]) => [surface, surfaces.has(surface) ? "include" : mode]),
318
+ );
319
+ };
320
+
321
+ const createWorkflowPlanRecommendations = (
322
+ spec: WorkflowSpec,
323
+ inputs: Record<string, WorkflowInputValue>,
324
+ ): WorkflowRecommendation[] => [
325
+ {
326
+ code: "workflow-apply-first",
327
+ kind: "auto-apply",
328
+ confidence: "high",
329
+ action: "Call apply_workflow with the MCP planPath before editing source files directly.",
330
+ message: `Apply the ${spec.name} plan through apply_workflow when MCP returns planPath or next.tool=apply_workflow.`,
331
+ },
332
+ {
333
+ code: "workflow-validate-apply-report",
334
+ kind: "validation",
335
+ confidence: "high",
336
+ action:
337
+ "After apply_workflow, call run_validation with validationTarget when present; otherwise use applyReportPath.",
338
+ message: "Validate the apply report artifact so diagnostics and recommendations follow the actual apply result.",
339
+ },
340
+ ...(spec.name === "add-field" ? createAddFieldRecommendations(inputs) : []),
341
+ ];
342
+
343
+ export const createWorkflowPlan = (spec: WorkflowSpec, rawInputs: Record<string, unknown>): WorkflowPlan => {
344
+ const inputs: Record<string, WorkflowInputValue> = {};
345
+ const diagnostics: WorkflowDiagnostic[] = [];
346
+
347
+ for (const [name, inputSpec] of Object.entries(spec.inputs)) {
348
+ const rawValue = rawInputs[name];
349
+ if (rawValue === undefined || rawValue === null || rawValue === "") {
350
+ if (inputSpec.required) {
351
+ diagnostics.push({
352
+ severity: "error",
353
+ code: "workflow-input-missing",
354
+ input: name,
355
+ message: `Workflow ${spec.name} requires input "${name}".`,
356
+ });
357
+ }
358
+ continue;
359
+ }
360
+
361
+ const value = normalizeInputValue(name, inputSpec, rawValue);
362
+ if (value === null) {
363
+ diagnostics.push({
364
+ severity: "error",
365
+ code: "workflow-input-invalid",
366
+ input: name,
367
+ message: `Workflow ${spec.name} input "${name}" must be ${inputSpec.type}.`,
368
+ });
369
+ continue;
370
+ }
371
+ if (inputSpec.allowedValues && typeof value === "string" && !inputSpec.allowedValues.includes(value)) {
372
+ diagnostics.push({
373
+ severity: "error",
374
+ code: "workflow-input-not-allowed",
375
+ input: name,
376
+ message: `Workflow ${spec.name} input "${name}" must be one of: ${inputSpec.allowedValues.join(", ")}.`,
377
+ });
378
+ continue;
379
+ }
380
+ inputs[name] = value;
381
+ }
382
+
383
+ const fieldType = inputs.type;
384
+ if (
385
+ spec.name === "add-field" &&
386
+ typeof fieldType === "string" &&
387
+ (fieldType.toLowerCase() === "number" || fieldType.toLowerCase() === "numeric")
388
+ ) {
389
+ diagnostics.push({
390
+ severity: "error",
391
+ code: "primitive-field-type-unsupported",
392
+ input: "type",
393
+ message: `Field type "${fieldType}" is ambiguous in Akan. Use Int for integer fields or Float for decimal fields.`,
394
+ });
395
+ }
396
+ if (spec.name === "add-field") {
397
+ const defaultCoercion = addFieldDefaultCoercion(inputs);
398
+ if (defaultCoercion?.diagnostic) {
399
+ diagnostics.push({
400
+ ...defaultCoercion.diagnostic,
401
+ code: "workflow-default-value-invalid",
402
+ message: `Plan input default is not valid for ${defaultCoercion.normalizedType}: ${defaultCoercion.diagnostic.message}`,
403
+ });
404
+ } else if (defaultCoercion?.normalized && defaultCoercion.expression) {
405
+ diagnostics.push({
406
+ severity: "warning",
407
+ code: "workflow-default-value-normalized",
408
+ input: "default",
409
+ failureScope: "source-change",
410
+ message: `Plan input default will be written as ${defaultCoercion.normalizedType} literal ${defaultCoercion.expression}.`,
411
+ });
412
+ }
413
+ }
414
+
415
+ return {
416
+ schemaVersion: 1,
417
+ workflow: spec.name,
418
+ mode: "plan",
419
+ inputs,
420
+ optionalSurfaces: createWorkflowPlanOptionalSurfaces(spec, inputs),
421
+ steps: spec.steps,
422
+ predictedChanges: createWorkflowPlanPredictedChanges(spec, inputs),
423
+ validation: spec.validation,
424
+ diagnostics,
425
+ recommendations: createWorkflowPlanRecommendations(spec, inputs),
426
+ requiresApproval: true,
427
+ approval: workflowPlanApproval,
428
+ };
429
+ };
@@ -0,0 +1,59 @@
1
+ import type { PrimitiveFormat, PrimitiveWriteReport } from "./types";
2
+ import { jsonText } from "./utils";
3
+
4
+ export const createPrimitiveWriteReport = ({
5
+ command,
6
+ status,
7
+ changedFiles = [],
8
+ generatedFiles = [],
9
+ validationCommands = [],
10
+ diagnostics = [],
11
+ nextActions = [],
12
+ }: Omit<PrimitiveWriteReport, "schemaVersion" | "status"> & {
13
+ status?: PrimitiveWriteReport["status"];
14
+ }): PrimitiveWriteReport => ({
15
+ schemaVersion: 1,
16
+ command,
17
+ status: status ?? (diagnostics.some((diagnostic) => diagnostic.severity === "error") ? "failed" : "passed"),
18
+ changedFiles,
19
+ generatedFiles,
20
+ validationCommands,
21
+ diagnostics,
22
+ nextActions,
23
+ });
24
+
25
+ export const renderPrimitiveWriteReport = (report: PrimitiveWriteReport) =>
26
+ [
27
+ `# Primitive Write: ${report.command}`,
28
+ "",
29
+ `- Status: ${report.status}`,
30
+ "",
31
+ "## Changed Files",
32
+ ...(report.changedFiles.length
33
+ ? report.changedFiles.map((file) => `- \`${file.action}\` ${file.path}: ${file.reason}`)
34
+ : ["- none"]),
35
+ "",
36
+ "## Generated Files",
37
+ ...(report.generatedFiles.length
38
+ ? report.generatedFiles.map((file) => `- \`${file.action}\` ${file.path}: ${file.reason}`)
39
+ : ["- none"]),
40
+ "",
41
+ "## Validation Commands",
42
+ ...(report.validationCommands.length
43
+ ? report.validationCommands.map((validation) => `- \`${validation.command}\`: ${validation.reason}`)
44
+ : ["- none"]),
45
+ "",
46
+ "## Diagnostics",
47
+ ...(report.diagnostics.length
48
+ ? report.diagnostics.map((diagnostic) => `- [${diagnostic.severity}] ${diagnostic.code}: ${diagnostic.message}`)
49
+ : ["- none"]),
50
+ "",
51
+ "## Next Actions",
52
+ ...(report.nextActions.length
53
+ ? report.nextActions.map((action) => `- \`${action.command}\`: ${action.reason}`)
54
+ : ["- none"]),
55
+ "",
56
+ ].join("\n");
57
+
58
+ export const renderPrimitiveReport = (report: PrimitiveWriteReport, format: PrimitiveFormat = "markdown") =>
59
+ format === "json" ? jsonText(report) : renderPrimitiveWriteReport(report);