@akanjs/devkit 2.3.9-rc.7 → 2.3.9-rc.9

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,504 @@
1
+ import { access } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import ts from "typescript";
4
+ import type { AkanModuleContext } from "../akanContext";
5
+ import type { Workspace } from "../commandDecorators";
6
+ import { moduleComponentName, moduleSourcePaths } from "./source";
7
+ import type {
8
+ AkanConstantIndex,
9
+ AkanDictionaryIndex,
10
+ AkanFieldOutline,
11
+ AkanFieldPresence,
12
+ AkanIndexedFile,
13
+ AkanIndexedFileKind,
14
+ AkanModuleContextIndex,
15
+ AkanProjectionOutline,
16
+ AkanSourceSpan,
17
+ WorkflowDiagnostic,
18
+ } from "./types";
19
+
20
+ export interface BuildAkanModuleContextIndexOptions {
21
+ field?: string;
22
+ }
23
+
24
+ const indexFileKinds = [
25
+ "abstract",
26
+ "constant",
27
+ "dictionary",
28
+ "service",
29
+ "signal",
30
+ "store",
31
+ "template",
32
+ "unit",
33
+ "util",
34
+ "view",
35
+ "zone",
36
+ ] as const;
37
+
38
+ const sourceText = async (filePath: string) => {
39
+ try {
40
+ return await Bun.file(filePath).text();
41
+ } catch {
42
+ return null;
43
+ }
44
+ };
45
+
46
+ const fileExists = async (filePath: string) => {
47
+ try {
48
+ await access(filePath);
49
+ return true;
50
+ } catch {
51
+ return false;
52
+ }
53
+ };
54
+
55
+ const sourceFileFor = (filePath: string, content: string) =>
56
+ ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
57
+
58
+ const parseDiagnosticsFor = (source: ts.SourceFile, filePath: string): WorkflowDiagnostic[] => {
59
+ const parseDiagnostics =
60
+ (source as ts.SourceFile & { parseDiagnostics?: readonly ts.DiagnosticWithLocation[] }).parseDiagnostics ?? [];
61
+ return parseDiagnostics.map((diagnostic) => ({
62
+ severity: "error",
63
+ code: "module-index-typescript-parse-error",
64
+ message: `TypeScript parse diagnostic TS${diagnostic.code} in ${filePath}: ${ts.flattenDiagnosticMessageText(
65
+ diagnostic.messageText,
66
+ "\n",
67
+ )}`,
68
+ context: { target: filePath, paths: [filePath] },
69
+ }));
70
+ };
71
+
72
+ const spanFor = (source: ts.SourceFile, file: string, node: ts.Node): AkanSourceSpan => {
73
+ const startOffset = node.getStart(source);
74
+ const endOffset = node.getEnd();
75
+ const start = source.getLineAndCharacterOfPosition(startOffset);
76
+ const end = source.getLineAndCharacterOfPosition(endOffset);
77
+ return {
78
+ file,
79
+ startLine: start.line + 1,
80
+ endLine: end.line + 1,
81
+ startOffset,
82
+ endOffset,
83
+ };
84
+ };
85
+
86
+ const nodeName = (node: ts.PropertyName | ts.BindingName | undefined) => {
87
+ if (!node) return null;
88
+ if (ts.isIdentifier(node) || ts.isStringLiteral(node) || ts.isNumericLiteral(node)) return node.text;
89
+ return null;
90
+ };
91
+
92
+ const propertyName = (node: ts.ObjectLiteralElementLike) =>
93
+ ts.isPropertyAssignment(node) || ts.isShorthandPropertyAssignment(node) || ts.isMethodDeclaration(node)
94
+ ? nodeName(node.name)
95
+ : null;
96
+
97
+ const expressionName = (expression: ts.Expression): string | null => {
98
+ if (ts.isIdentifier(expression)) return expression.text;
99
+ if (ts.isPropertyAccessExpression(expression)) return expression.name.text;
100
+ if (ts.isCallExpression(expression)) return expressionName(expression.expression);
101
+ if (ts.isAsExpression(expression)) return expressionName(expression.expression);
102
+ return null;
103
+ };
104
+
105
+ const expressionSummary = (expression: ts.Expression): string => {
106
+ if (ts.isAsExpression(expression)) return expressionSummary(expression.expression);
107
+ if (ts.isIdentifier(expression) || ts.isPropertyAccessExpression(expression))
108
+ return expressionName(expression) ?? "expression";
109
+ if (ts.isArrayLiteralExpression(expression)) return "array-literal";
110
+ if (ts.isObjectLiteralExpression(expression)) return "object-literal";
111
+ if (ts.isStringLiteralLike(expression)) return "string-literal";
112
+ if (ts.isNumericLiteral(expression)) return "numeric-literal";
113
+ if (expression.kind === ts.SyntaxKind.TrueKeyword || expression.kind === ts.SyntaxKind.FalseKeyword) {
114
+ return "boolean-literal";
115
+ }
116
+ if (ts.isCallExpression(expression)) {
117
+ const callee = expressionName(expression.expression);
118
+ return callee ? `${callee}(...)` : "call-expression";
119
+ }
120
+ return ts.SyntaxKind[expression.kind] ?? "expression";
121
+ };
122
+
123
+ const typeSummaryForInitializer = (initializer: ts.Expression | undefined) => {
124
+ if (!initializer) return undefined;
125
+ const expression = ts.isAsExpression(initializer) ? initializer.expression : initializer;
126
+ if (!ts.isCallExpression(expression)) return expressionSummary(expression);
127
+ const callee = expressionName(expression.expression);
128
+ const firstArg = expression.arguments[0];
129
+ const argSummary = firstArg ? expressionSummary(firstArg) : "";
130
+ return [callee, argSummary ? `(${argSummary})` : ""].filter(Boolean).join("");
131
+ };
132
+
133
+ const fieldsFromObject = (
134
+ source: ts.SourceFile,
135
+ file: string,
136
+ objectLiteral: ts.ObjectLiteralExpression,
137
+ kind: AkanFieldOutline["kind"],
138
+ ) =>
139
+ objectLiteral.properties
140
+ .map((property, index): AkanFieldOutline | null => {
141
+ const name = propertyName(property);
142
+ if (!name) return null;
143
+ const initializer = ts.isPropertyAssignment(property) ? property.initializer : undefined;
144
+ return {
145
+ name,
146
+ kind,
147
+ order: index,
148
+ ...(initializer ? { typeSummary: typeSummaryForInitializer(initializer) } : {}),
149
+ sourceSpan: spanFor(source, file, property),
150
+ };
151
+ })
152
+ .filter((field): field is AkanFieldOutline => field !== null);
153
+
154
+ const firstObjectReturnedByArrow = (node: ts.Node): ts.ObjectLiteralExpression | null => {
155
+ if (!ts.isArrowFunction(node) && !ts.isFunctionExpression(node)) return null;
156
+ if (ts.isObjectLiteralExpression(node.body)) return node.body;
157
+ if (ts.isParenthesizedExpression(node.body) && ts.isObjectLiteralExpression(node.body.expression)) {
158
+ return node.body.expression;
159
+ }
160
+ if (!ts.isBlock(node.body)) return null;
161
+ for (const statement of node.body.statements) {
162
+ if (ts.isReturnStatement(statement) && statement.expression && ts.isObjectLiteralExpression(statement.expression)) {
163
+ return statement.expression;
164
+ }
165
+ }
166
+ return null;
167
+ };
168
+
169
+ const isViaCall = (expression: ts.Expression) =>
170
+ ts.isCallExpression(expression) && expressionName(expression.expression) === "via";
171
+
172
+ const heritageCall = (node: ts.ClassDeclaration) => {
173
+ const heritage = node.heritageClauses?.flatMap((clause) => [...clause.types]) ?? [];
174
+ const expression = heritage.find((clause) => isViaCall(clause.expression))?.expression;
175
+ return expression && ts.isCallExpression(expression) ? expression : null;
176
+ };
177
+
178
+ interface ParsedConstantIndex {
179
+ index: AkanConstantIndex;
180
+ diagnostics: WorkflowDiagnostic[];
181
+ }
182
+
183
+ interface ParsedDictionaryIndex {
184
+ index?: AkanDictionaryIndex;
185
+ diagnostics: WorkflowDiagnostic[];
186
+ modelFound: boolean;
187
+ }
188
+
189
+ const parseConstantIndex = (filePath: string, content: string, moduleClassName: string): ParsedConstantIndex => {
190
+ const source = sourceFileFor(filePath, content);
191
+ const diagnostics = parseDiagnosticsFor(source, filePath);
192
+ const inputClassName = `${moduleClassName}Input`;
193
+ let inputClass: ts.ClassDeclaration | null = null;
194
+ let inputViaFound = false;
195
+ let inputBuilderFound = false;
196
+ let inputBuilderObjectFound = false;
197
+ let builderName: string | null = null;
198
+ let fields: AkanFieldOutline[] = [];
199
+ let lightProjection: AkanProjectionOutline | undefined;
200
+
201
+ const visit = (node: ts.Node) => {
202
+ if (!ts.isClassDeclaration(node) || !node.name) {
203
+ ts.forEachChild(node, visit);
204
+ return;
205
+ }
206
+ const className = node.name.text;
207
+ const viaCall = heritageCall(node);
208
+ if (className === inputClassName) {
209
+ inputClass = node;
210
+ if (!viaCall) return;
211
+ inputViaFound = true;
212
+ const callback = viaCall.arguments.find((arg) => ts.isArrowFunction(arg) || ts.isFunctionExpression(arg));
213
+ if (callback && (ts.isArrowFunction(callback) || ts.isFunctionExpression(callback))) {
214
+ inputBuilderFound = true;
215
+ builderName = nodeName(callback.parameters[0]?.name) ?? null;
216
+ const objectLiteral = firstObjectReturnedByArrow(callback);
217
+ if (objectLiteral) {
218
+ inputBuilderObjectFound = true;
219
+ fields = fieldsFromObject(source, filePath, objectLiteral, "constant");
220
+ }
221
+ }
222
+ }
223
+ if (!viaCall) return;
224
+ if (className === `Light${moduleClassName}`) {
225
+ const projectionArg = viaCall.arguments.find((arg) => {
226
+ const expression = ts.isAsExpression(arg) ? arg.expression : arg;
227
+ return ts.isArrayLiteralExpression(expression);
228
+ });
229
+ const arrayLiteral = projectionArg
230
+ ? ts.isAsExpression(projectionArg)
231
+ ? projectionArg.expression
232
+ : projectionArg
233
+ : null;
234
+ if (arrayLiteral && ts.isArrayLiteralExpression(arrayLiteral)) {
235
+ lightProjection = {
236
+ className,
237
+ sourceSpan: spanFor(source, filePath, arrayLiteral),
238
+ fields: arrayLiteral.elements
239
+ .map((element, order): AkanFieldOutline | null =>
240
+ ts.isStringLiteralLike(element)
241
+ ? {
242
+ name: element.text,
243
+ kind: "lightProjection",
244
+ order,
245
+ typeSummary: "string-literal",
246
+ sourceSpan: spanFor(source, filePath, element),
247
+ }
248
+ : null,
249
+ )
250
+ .filter((field): field is AkanFieldOutline => field !== null),
251
+ };
252
+ }
253
+ }
254
+ };
255
+ ts.forEachChild(source, visit);
256
+
257
+ if (!inputClass) {
258
+ diagnostics.push({
259
+ severity: "error",
260
+ code: "module-index-constant-input-missing",
261
+ message: `Constant input class ${inputClassName} was not found in ${filePath}.`,
262
+ context: { target: inputClassName, paths: [filePath] },
263
+ });
264
+ } else if (!inputViaFound) {
265
+ diagnostics.push({
266
+ severity: "error",
267
+ code: "module-index-constant-via-missing",
268
+ message: `Constant input class ${inputClassName} does not extend via(...) in ${filePath}.`,
269
+ context: { target: inputClassName, paths: [filePath] },
270
+ });
271
+ } else if (!inputBuilderFound) {
272
+ diagnostics.push({
273
+ severity: "error",
274
+ code: "module-index-constant-builder-missing",
275
+ message: `Constant input class ${inputClassName} does not provide a via(...) builder callback in ${filePath}.`,
276
+ context: { target: inputClassName, paths: [filePath] },
277
+ });
278
+ } else if (!inputBuilderObjectFound) {
279
+ diagnostics.push({
280
+ severity: "error",
281
+ code: "module-index-constant-builder-object-missing",
282
+ message: `Constant input class ${inputClassName} builder does not return an object literal in ${filePath}.`,
283
+ context: { target: inputClassName, paths: [filePath] },
284
+ });
285
+ }
286
+
287
+ return {
288
+ index: {
289
+ path: filePath,
290
+ inputClassName,
291
+ builderName,
292
+ fields,
293
+ ...(lightProjection ? { lightProjection } : {}),
294
+ ...(inputClass ? { sourceSpan: spanFor(source, filePath, inputClass) } : {}),
295
+ },
296
+ diagnostics,
297
+ };
298
+ };
299
+
300
+ const callExpressionName = (node: ts.CallExpression) =>
301
+ ts.isPropertyAccessExpression(node.expression) ? node.expression.name.text : expressionName(node.expression);
302
+
303
+ const parseDictionaryIndex = (filePath: string, content: string, moduleClassName: string): ParsedDictionaryIndex => {
304
+ const source = sourceFileFor(filePath, content);
305
+ const diagnostics = parseDiagnosticsFor(source, filePath);
306
+ let index: AkanDictionaryIndex | undefined;
307
+ let modelFound = false;
308
+
309
+ const visit = (node: ts.Node) => {
310
+ if (modelFound || !ts.isCallExpression(node)) {
311
+ ts.forEachChild(node, visit);
312
+ return;
313
+ }
314
+ if (callExpressionName(node) !== "model") {
315
+ ts.forEachChild(node, visit);
316
+ return;
317
+ }
318
+ const typeArgument = node.typeArguments?.[0];
319
+ if (!typeArgument || typeArgument.getText(source) !== moduleClassName) {
320
+ ts.forEachChild(node, visit);
321
+ return;
322
+ }
323
+ modelFound = true;
324
+ const callback = node.arguments.find((arg) => ts.isArrowFunction(arg) || ts.isFunctionExpression(arg));
325
+ if (!callback || (!ts.isArrowFunction(callback) && !ts.isFunctionExpression(callback))) {
326
+ diagnostics.push({
327
+ severity: "error",
328
+ code: "module-index-dictionary-builder-missing",
329
+ message: `Dictionary .model<${moduleClassName}> branch does not provide a builder callback in ${filePath}.`,
330
+ context: { target: moduleClassName, paths: [filePath] },
331
+ });
332
+ return;
333
+ }
334
+ const objectLiteral = firstObjectReturnedByArrow(callback);
335
+ if (!objectLiteral) {
336
+ diagnostics.push({
337
+ severity: "error",
338
+ code: "module-index-dictionary-builder-object-missing",
339
+ message: `Dictionary .model<${moduleClassName}> builder does not return an object literal in ${filePath}.`,
340
+ context: { target: moduleClassName, paths: [filePath] },
341
+ });
342
+ }
343
+ index = {
344
+ path: filePath,
345
+ modelClassName: moduleClassName,
346
+ translatorName: nodeName(callback.parameters[0]?.name),
347
+ fields: objectLiteral ? fieldsFromObject(source, filePath, objectLiteral, "dictionary") : [],
348
+ sourceSpan: spanFor(source, filePath, node),
349
+ };
350
+ };
351
+ ts.forEachChild(source, visit);
352
+ return { index, diagnostics, modelFound };
353
+ };
354
+
355
+ const expectedFilesFor = (module: AkanModuleContext): AkanIndexedFile[] => {
356
+ const paths = moduleSourcePaths(module.name);
357
+ return indexFileKinds.map((kind) => {
358
+ const expectedModulePath = paths[kind];
359
+ const expectedFilename = path.basename(expectedModulePath);
360
+ const actualFilename =
361
+ module.files.find((file) => file === expectedFilename) ??
362
+ module.files.find((file) => file.toLowerCase() === expectedFilename.toLowerCase());
363
+ const present = actualFilename !== undefined;
364
+ const casing = !present ? "missing" : actualFilename === expectedFilename ? "match" : "mismatch";
365
+ return {
366
+ kind: kind as AkanIndexedFileKind,
367
+ path: `${module.path}/${actualFilename ?? expectedFilename}`,
368
+ expectedPath: `${module.path}/${expectedFilename}`,
369
+ filename: actualFilename ?? expectedFilename,
370
+ expectedFilename,
371
+ present,
372
+ casing,
373
+ };
374
+ });
375
+ };
376
+
377
+ const diagnosticsForFiles = (files: AkanIndexedFile[]): WorkflowDiagnostic[] =>
378
+ files.flatMap((file) => {
379
+ if (file.kind !== "constant" && file.kind !== "dictionary") return [];
380
+ if (file.casing === "match") return [];
381
+ return [
382
+ {
383
+ severity: "error",
384
+ code: file.casing === "missing" ? "module-index-file-missing" : "module-index-file-casing-mismatch",
385
+ message:
386
+ file.casing === "missing"
387
+ ? `Expected ${file.kind} file is missing: ${file.expectedPath}.`
388
+ : `Expected ${file.expectedPath}, but found ${file.path}.`,
389
+ context: { target: file.expectedPath, paths: [file.path, file.expectedPath] },
390
+ },
391
+ ];
392
+ });
393
+
394
+ const fieldPresence = (
395
+ requestedField: string | undefined,
396
+ constantFields: AkanFieldOutline[],
397
+ dictionaryFields: AkanFieldOutline[],
398
+ lightFields: AkanFieldOutline[],
399
+ ) => {
400
+ const names = new Set([
401
+ ...constantFields.map((field) => field.name),
402
+ ...dictionaryFields.map((field) => field.name),
403
+ ...lightFields.map((field) => field.name),
404
+ ...(requestedField ? [requestedField] : []),
405
+ ]);
406
+ return [...names].sort().map(
407
+ (name): AkanFieldPresence => ({
408
+ name,
409
+ requested: requestedField === name,
410
+ constant: constantFields.some((field) => field.name === name),
411
+ dictionary: dictionaryFields.some((field) => field.name === name),
412
+ lightProjection: lightFields.some((field) => field.name === name),
413
+ }),
414
+ );
415
+ };
416
+
417
+ const partialPresenceDiagnostics = (presence: AkanFieldPresence[], module: AkanModuleContext): WorkflowDiagnostic[] =>
418
+ presence
419
+ .filter(
420
+ (field) =>
421
+ field.constant !== field.dictionary || (field.lightProjection && (!field.constant || !field.dictionary)),
422
+ )
423
+ .map((field) => ({
424
+ severity: "warning",
425
+ code: "module-index-field-presence-partial",
426
+ message: `${module.sysName}:${module.name}.${field.name} presence differs across constant, dictionary, and lightProjection.`,
427
+ context: {
428
+ target: `${module.sysName}:${module.name}.${field.name}`,
429
+ paths: [`${module.path}/${module.name}.constant.ts`, `${module.path}/${module.name}.dictionary.ts`],
430
+ },
431
+ }));
432
+
433
+ export const buildAkanModuleContextIndex = async (
434
+ workspace: Workspace,
435
+ module: AkanModuleContext,
436
+ options: BuildAkanModuleContextIndexOptions = {},
437
+ ): Promise<AkanModuleContextIndex> => {
438
+ const files = expectedFilesFor(module);
439
+ const diagnostics = diagnosticsForFiles(files);
440
+ const moduleClassName = moduleComponentName(module.name);
441
+ const constantFile = files.find((file) => file.kind === "constant");
442
+ const dictionaryFile = files.find((file) => file.kind === "dictionary");
443
+ const constantContent =
444
+ constantFile?.present && constantFile.casing === "match"
445
+ ? await sourceText(path.join(workspace.workspaceRoot, constantFile.path))
446
+ : null;
447
+ const dictionaryContent =
448
+ dictionaryFile?.present && dictionaryFile.casing === "match"
449
+ ? await sourceText(path.join(workspace.workspaceRoot, dictionaryFile.path))
450
+ : null;
451
+ const parsedConstant =
452
+ constantFile && constantContent
453
+ ? parseConstantIndex(constantFile.path, constantContent, moduleClassName)
454
+ : undefined;
455
+ const constant = parsedConstant?.index;
456
+ const parsedDictionary =
457
+ dictionaryFile && dictionaryContent
458
+ ? parseDictionaryIndex(dictionaryFile.path, dictionaryContent, moduleClassName)
459
+ : undefined;
460
+ const dictionary = parsedDictionary?.index;
461
+ const presence = fieldPresence(
462
+ options.field,
463
+ constant?.fields ?? [],
464
+ dictionary?.fields ?? [],
465
+ constant?.lightProjection?.fields ?? [],
466
+ );
467
+
468
+ if (
469
+ constantFile?.present &&
470
+ constantFile.casing === "match" &&
471
+ !(await fileExists(path.join(workspace.workspaceRoot, constantFile.path)))
472
+ ) {
473
+ diagnostics.push({
474
+ severity: "error",
475
+ code: "module-index-file-unreadable",
476
+ message: `Expected constant file could not be read: ${constantFile.path}.`,
477
+ context: { paths: [constantFile.path] },
478
+ });
479
+ }
480
+ if (parsedConstant) diagnostics.push(...parsedConstant.diagnostics);
481
+ if (parsedDictionary) diagnostics.push(...parsedDictionary.diagnostics);
482
+ if (dictionaryFile?.present && dictionaryFile.casing === "match" && !parsedDictionary?.modelFound) {
483
+ diagnostics.push({
484
+ severity: "error",
485
+ code: "module-index-dictionary-model-missing",
486
+ message: `Dictionary .model<${moduleClassName}> branch was not found in ${dictionaryFile.path}.`,
487
+ context: { paths: [dictionaryFile.path] },
488
+ });
489
+ }
490
+
491
+ diagnostics.push(...partialPresenceDiagnostics(presence, module));
492
+
493
+ return {
494
+ schemaVersion: 1,
495
+ app: module.sysName,
496
+ module: module.name,
497
+ moduleClassName,
498
+ files,
499
+ fieldPresence: presence,
500
+ ...(constant ? { constant } : {}),
501
+ ...(dictionary ? { dictionary } : {}),
502
+ diagnostics,
503
+ };
504
+ };
package/workflow/plan.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { capitalize } from "akanjs/common";
2
+ import { workflowPlanApproval } from "./artifacts";
2
3
  import { coerceFieldDefault, moduleSourcePaths } from "./source";
3
4
  import type {
4
5
  WorkflowDiagnostic,
@@ -91,11 +92,59 @@ const createAddFieldDefaultRecommendations = (inputs: Record<string, WorkflowInp
91
92
  kind: "manual-action",
92
93
  confidence: "high",
93
94
  message: `Default for ${field} will be normalized to ${coercion.normalizedType} literal ${coercion.expression}.`,
94
- action: "Review the normalized default in the apply report if this value should stay unset.",
95
+ action:
96
+ "Review the normalized default in the apply report. To leave the field without a default, omit the default input.",
95
97
  },
96
98
  ];
97
99
  };
98
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
+
99
148
  const createAddFieldRecommendations = (inputs: Record<string, WorkflowInputValue>): WorkflowRecommendation[] => {
100
149
  const app = typeof inputs.app === "string" ? inputs.app : "<app-or-lib>";
101
150
  const module = typeof inputs.module === "string" ? inputs.module : "<module>";
@@ -163,9 +212,11 @@ const createAddFieldRecommendations = (inputs: Record<string, WorkflowInputValue
163
212
  code: "add-field-light-projection-choice",
164
213
  kind: "manual-action" as const,
165
214
  target: paths.constant,
166
- action: `Pass includeInLight=true when ${field} should appear in Light${capitalize(module)} list/card projections.`,
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.`,
167
218
  confidence: "medium" as const,
168
- message: `Light projection exposure for ${module}.${field} is not selected yet.`,
219
+ message: `${module}.${field} is not selected for list/card projection data.`,
169
220
  },
170
221
  ]
171
222
  : []),
@@ -187,7 +238,8 @@ const createAddFieldRecommendations = (inputs: Record<string, WorkflowInputValue
187
238
  kind: "manual-action" as const,
188
239
  target: paths.template,
189
240
  confidence: "medium" as const,
190
- message: `Template is not included in surfaces, so ${module}.${field} will not be auto-rendered there.`,
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.`,
191
243
  },
192
244
  ]
193
245
  : []),
@@ -195,13 +247,14 @@ const createAddFieldRecommendations = (inputs: Record<string, WorkflowInputValue
195
247
  code: "add-field-ui-manual-review",
196
248
  kind: "manual-action",
197
249
  target: paths.template,
198
- action: `Review ${app}:${module} UI after apply. Template auto-edit requires a generated ${module}Form hook and Layout.Template field list; Unit/View are not auto-edited because list/card placement depends on local layout. Candidate positions: Layout.Template before the closing tag, Light${capitalize(
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(
199
251
  module,
200
- )} projection array for list data, and Unit/View card sections for display.`,
252
+ )} but not shown in list/card UI until you place it in the local card layout.`,
201
253
  confidence: "medium",
202
- message: "UI manual review includes the reason auto-edit may be skipped and candidate insertion positions.",
254
+ message: `${app}:${module}.${field} may need UI review for user-visible Template, Unit, and View behavior.`,
203
255
  },
204
256
  ...createAddFieldDefaultRecommendations(inputs),
257
+ ...createAddFieldInputRecommendations(inputs),
205
258
  ];
206
259
  };
207
260
 
@@ -371,5 +424,6 @@ export const createWorkflowPlan = (spec: WorkflowSpec, rawInputs: Record<string,
371
424
  diagnostics,
372
425
  recommendations: createWorkflowPlanRecommendations(spec, inputs),
373
426
  requiresApproval: true,
427
+ approval: workflowPlanApproval,
374
428
  };
375
429
  };