@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "2.3.9-rc.7",
3
+ "version": "2.3.9-rc.9",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -32,7 +32,7 @@
32
32
  "@langchain/openai": "^1.4.6",
33
33
  "@tailwindcss/node": "^4.3.0",
34
34
  "@trapezedev/project": "^7.1.4",
35
- "akanjs": "2.3.9-rc.7",
35
+ "akanjs": "2.3.9-rc.9",
36
36
  "chalk": "^5.6.2",
37
37
  "commander": "^14.0.3",
38
38
  "daisyui": "5.5.23",
@@ -7,9 +7,12 @@ import type {
7
7
  RepairReport,
8
8
  WorkflowApplyCommand,
9
9
  WorkflowApplyReport,
10
+ WorkflowBaselineSummary,
10
11
  WorkflowDiagnostic,
11
12
  WorkflowFailureScope,
12
13
  WorkflowKnownBlocker,
14
+ WorkflowNextActionCode,
15
+ WorkflowOverallStatus,
13
16
  WorkflowPlan,
14
17
  WorkflowRunArtifact,
15
18
  WorkflowRunSource,
@@ -19,6 +22,43 @@ import type {
19
22
  } from "./types";
20
23
  import { commandStatus, jsonText, uniqueBy, workflowStatus } from "./utils";
21
24
 
25
+ const sourceChangeBlocked = (diagnostics: readonly WorkflowDiagnostic[]) =>
26
+ diagnostics.some(
27
+ (diagnostic) =>
28
+ diagnostic.severity === "error" &&
29
+ (diagnostic.failureScope === "source-change" ||
30
+ !diagnostic.failureScope ||
31
+ diagnostic.failureScope === "unknown"),
32
+ );
33
+
34
+ export const workflowPlanApproval = {
35
+ required: true,
36
+ meaning: "Review this read-only plan before apply_workflow mutates files.",
37
+ applyTool: "apply_workflow",
38
+ } as const;
39
+
40
+ const inferNextActionCode = (
41
+ action: { action?: WorkflowNextActionCode; command: string },
42
+ diagnostics: readonly WorkflowDiagnostic[],
43
+ ): WorkflowNextActionCode => {
44
+ if (action.action) return action.action;
45
+ if (sourceChangeBlocked(diagnostics) && action.command.startsWith("akan workflow explain")) return "blocked";
46
+ if (action.command.startsWith("akan workflow repair")) return "repair";
47
+ if (action.command.startsWith("akan workflow explain")) return "manual-review";
48
+ if (action.command.startsWith("akan ")) return "validate";
49
+ return "answer";
50
+ };
51
+
52
+ const nextActionPriority = (
53
+ action: { action?: WorkflowNextActionCode },
54
+ diagnostics: readonly WorkflowDiagnostic[],
55
+ ) => {
56
+ const priority = sourceChangeBlocked(diagnostics)
57
+ ? { blocked: 0, repair: 1, "manual-review": 2, validate: 3, answer: 4 }
58
+ : { "manual-review": 0, validate: 1, repair: 2, blocked: 3, answer: 4 };
59
+ return priority[action.action ?? "answer"];
60
+ };
61
+
22
62
  export const createWorkflowApplyReport = ({
23
63
  workflow,
24
64
  mode,
@@ -39,6 +79,7 @@ export const createWorkflowApplyReport = ({
39
79
  | "applyReportPath"
40
80
  | "validationTarget"
41
81
  | "status"
82
+ | "summary"
42
83
  | "appliedCommands"
43
84
  | "recommendedValidationCommands"
44
85
  | "commands"
@@ -52,18 +93,33 @@ export const createWorkflowApplyReport = ({
52
93
  recommendations?: WorkflowApplyReport["recommendations"];
53
94
  }): WorkflowApplyReport => {
54
95
  const validationCommands = recommendedValidationCommands ?? commands;
55
- const orderedNextActions = uniqueBy(nextActions, (action) => action.command).sort((left, right) => {
56
- const leftRepair = left.command.startsWith("akan workflow explain") ? 0 : 1;
57
- const rightRepair = right.command.startsWith("akan workflow explain") ? 0 : 1;
58
- return leftRepair - rightRepair;
59
- });
96
+ const nextActionsWithIntent = nextActions.map((action) => ({
97
+ ...action,
98
+ action: inferNextActionCode(action, diagnostics),
99
+ }));
100
+ if (sourceChangeBlocked(diagnostics) && !nextActionsWithIntent.some((action) => action.action === "blocked")) {
101
+ nextActionsWithIntent.unshift({
102
+ command: `akan workflow explain ${workflow}`,
103
+ reason: "Review source-change blockers before running validation.",
104
+ action: "blocked",
105
+ });
106
+ }
107
+ const orderedNextActions = uniqueBy(nextActionsWithIntent, (action) => action.command).sort(
108
+ (left, right) => nextActionPriority(left, diagnostics) - nextActionPriority(right, diagnostics),
109
+ );
110
+ const sourceFilesChanged = uniqueBy(changedFiles, (file) => `${file.action}:${file.path}:${file.reason}`);
111
+ const generatedFilesSynced = uniqueBy(generatedFiles, (file) => `${file.action}:${file.path}:${file.reason}`);
60
112
  return {
61
113
  schemaVersion: 1,
62
114
  workflow,
63
115
  mode,
64
116
  status: workflowStatus(diagnostics),
65
- changedFiles: uniqueBy(changedFiles, (file) => `${file.action}:${file.path}:${file.reason}`),
66
- generatedFiles: uniqueBy(generatedFiles, (file) => `${file.action}:${file.path}:${file.reason}`),
117
+ summary: {
118
+ sourceFilesChanged,
119
+ generatedFilesSynced,
120
+ },
121
+ changedFiles: sourceFilesChanged,
122
+ generatedFiles: generatedFilesSynced,
67
123
  appliedCommands: uniqueBy(appliedCommands, (command) => command.command),
68
124
  recommendedValidationCommands: uniqueBy(validationCommands, (command) => command.command),
69
125
  commands: uniqueBy(validationCommands, (command) => command.command),
@@ -204,6 +260,58 @@ const statusForValidationKind = (
204
260
  return matching.some((command) => command.status === "failed") ? "failed" : "passed";
205
261
  };
206
262
 
263
+ const statusForCommands = (commands: readonly WorkflowValidationCommandResult[]): WorkflowValidationStatus => {
264
+ if (commands.length === 0) return "unknown";
265
+ return commandStatus(commands) === "failed" ? "failed" : "passed";
266
+ };
267
+
268
+ const statusForDiagnostics = (diagnostics: readonly WorkflowDiagnostic[]): WorkflowValidationStatus => {
269
+ if (diagnostics.length === 0) return "unknown";
270
+ return workflowStatus(diagnostics) === "failed" ? "failed" : "passed";
271
+ };
272
+
273
+ const workflowDiagnosticContextPaths = (diagnostics: readonly WorkflowDiagnostic[]) =>
274
+ uniqueBy(
275
+ diagnostics.flatMap((diagnostic) => diagnostic.context?.paths ?? []),
276
+ (filePath) => filePath,
277
+ );
278
+
279
+ export const createWorkflowBaselineSummary = (
280
+ diagnostics: readonly WorkflowDiagnostic[],
281
+ { detailsIncluded = true, knownBlockerCount = 0 }: { detailsIncluded?: boolean; knownBlockerCount?: number } = {},
282
+ ): WorkflowBaselineSummary => {
283
+ const grouped = new Map<string, WorkflowBaselineSummary["byCode"][number]>();
284
+ let totalErrors = 0;
285
+ let totalWarnings = 0;
286
+ for (const diagnostic of diagnostics) {
287
+ if (diagnostic.severity === "error") totalErrors += 1;
288
+ else totalWarnings += 1;
289
+ const existing = grouped.get(diagnostic.code);
290
+ if (existing) {
291
+ existing.count += 1;
292
+ if (existing.severity !== diagnostic.severity) existing.severity = "mixed";
293
+ } else {
294
+ grouped.set(diagnostic.code, {
295
+ code: diagnostic.code,
296
+ severity: diagnostic.severity,
297
+ count: 1,
298
+ sampleMessage: diagnostic.message,
299
+ });
300
+ }
301
+ }
302
+ const contextPaths = workflowDiagnosticContextPaths(diagnostics);
303
+ return {
304
+ status: totalErrors > 0 ? "failed" : diagnostics.length > 0 ? "passed" : "unknown",
305
+ total: diagnostics.length,
306
+ totalErrors,
307
+ totalWarnings,
308
+ detailsIncluded,
309
+ knownBlockerCount,
310
+ byCode: [...grouped.values()],
311
+ ...(contextPaths.length ? { contextPaths } : {}),
312
+ };
313
+ };
314
+
207
315
  const createKnownBlockers = (
208
316
  commands: readonly WorkflowValidationCommandResult[],
209
317
  diagnostics: readonly WorkflowDiagnostic[],
@@ -250,10 +358,37 @@ const createKnownBlockers = (
250
358
 
251
359
  const createValidationStatuses = (
252
360
  commands: readonly WorkflowValidationCommandResult[],
253
- diagnostics: readonly WorkflowDiagnostic[],
254
- ) => {
361
+ reportDiagnostics: readonly WorkflowDiagnostic[],
362
+ baselineDiagnostics: readonly WorkflowDiagnostic[],
363
+ workflowDiagnostics: readonly WorkflowDiagnostic[],
364
+ ): {
365
+ sourceStatus: WorkflowValidationStatus;
366
+ workspaceStatus: WorkflowValidationStatus;
367
+ validationCommandsStatus: WorkflowValidationStatus;
368
+ baselineStatus: WorkflowValidationStatus;
369
+ overallStatus: WorkflowOverallStatus;
370
+ summary: {
371
+ sourceChange: WorkflowValidationStatus;
372
+ generatedSync: WorkflowValidationStatus;
373
+ validationCommands: WorkflowValidationStatus;
374
+ baseline: WorkflowValidationStatus;
375
+ workspaceConfig: WorkflowValidationStatus;
376
+ environment: WorkflowValidationStatus;
377
+ };
378
+ } => {
379
+ const diagnostics = [...reportDiagnostics, ...baselineDiagnostics, ...workflowDiagnostics];
255
380
  const scopes = [...failedCommandScopes(commands), ...errorDiagnosticScopes(diagnostics)];
256
- const sourceStatus = statusForScope(commands, diagnostics, scopes, "source-change");
381
+ const sourceDiagnostics = [...reportDiagnostics, ...workflowDiagnostics].filter(
382
+ (diagnostic) =>
383
+ diagnostic.failureScope === "source-change" ||
384
+ diagnostic.scope === "workflow" ||
385
+ (!diagnostic.failureScope && diagnostic.scope !== "baseline"),
386
+ );
387
+ const sourceScopes = [...failedCommandScopes(commands), ...errorDiagnosticScopes(sourceDiagnostics)];
388
+ const sourceStatus = statusForScope(commands, sourceDiagnostics, sourceScopes, "source-change");
389
+ const validationCommandsStatus = statusForCommands(commands);
390
+ const baselineStatus = statusForDiagnostics(baselineDiagnostics);
391
+ const nonBaselineDiagnostics = [...reportDiagnostics, ...workflowDiagnostics];
257
392
  const workspaceStatus =
258
393
  hasScopeFailure(scopes, "workspace-config", diagnostics) || hasScopeFailure(scopes, "environment", diagnostics)
259
394
  ? "failed"
@@ -262,20 +397,28 @@ const createValidationStatuses = (
262
397
  : "unknown";
263
398
  const overallStatus = hasScopeFailure(scopes, "source-change", diagnostics)
264
399
  ? "failed"
265
- : hasScopeFailure(scopes, "workspace-config", diagnostics)
266
- ? "blocked-by-workspace-config"
267
- : hasScopeFailure(scopes, "environment", diagnostics)
268
- ? "blocked-by-environment"
269
- : workflowStatus(diagnostics) === "failed" || commandStatus(commands) === "failed"
270
- ? "failed"
271
- : "passed";
400
+ : validationCommandsStatus === "passed" &&
401
+ baselineStatus === "failed" &&
402
+ workflowStatus(nonBaselineDiagnostics) !== "failed"
403
+ ? "passed-with-baseline-blockers"
404
+ : hasScopeFailure(scopes, "workspace-config", diagnostics)
405
+ ? "blocked-by-workspace-config"
406
+ : hasScopeFailure(scopes, "environment", diagnostics)
407
+ ? "blocked-by-environment"
408
+ : workflowStatus(diagnostics) === "failed" || commandStatus(commands) === "failed"
409
+ ? "failed"
410
+ : "passed";
272
411
  return {
273
412
  sourceStatus,
274
413
  workspaceStatus,
414
+ validationCommandsStatus,
415
+ baselineStatus,
275
416
  overallStatus,
276
417
  summary: {
277
418
  sourceChange: sourceStatus,
278
419
  generatedSync: statusForValidationKind(commands, "sync"),
420
+ validationCommands: validationCommandsStatus,
421
+ baseline: baselineStatus,
279
422
  workspaceConfig: statusForScope(commands, diagnostics, scopes, "workspace-config"),
280
423
  environment: statusForScope(commands, diagnostics, scopes, "environment"),
281
424
  },
@@ -325,7 +468,8 @@ export const createWorkflowValidationRunReport = async ({
325
468
  );
326
469
  const reportDiagnostics = [...diagnostics, ...commandDiagnostics];
327
470
  const scopedDiagnostics = [...reportDiagnostics, ...baselineDiagnostics, ...workflowDiagnostics];
328
- const statuses = createValidationStatuses(results, scopedDiagnostics);
471
+ const knownBlockers = createKnownBlockers(results, scopedDiagnostics);
472
+ const statuses = createValidationStatuses(results, reportDiagnostics, baselineDiagnostics, workflowDiagnostics);
329
473
  return {
330
474
  schemaVersion: 1,
331
475
  runId,
@@ -334,9 +478,12 @@ export const createWorkflowValidationRunReport = async ({
334
478
  source,
335
479
  status: statuses.overallStatus === "passed" ? "passed" : "failed",
336
480
  ...statuses,
337
- knownBlockers: createKnownBlockers(results, scopedDiagnostics),
481
+ knownBlockers,
338
482
  commands: results,
339
483
  diagnostics: reportDiagnostics,
484
+ baselineSummary: createWorkflowBaselineSummary(baselineDiagnostics, {
485
+ knownBlockerCount: knownBlockers.filter((blocker) => blocker.failureScope === "workspace-config").length,
486
+ }),
340
487
  baselineDiagnostics,
341
488
  workflowDiagnostics,
342
489
  repairActions: uniqueBy(repairActions, (action) => action.command),
@@ -1,9 +1,17 @@
1
- import { capitalize } from "akanjs/common";
2
1
  import ts from "typescript";
2
+ import type { AkanModuleContext } from "../akanContext";
3
3
  import type { Sys, Workspace } from "../commandDecorators";
4
4
  import { AppExecutor, LibExecutor } from "../executors";
5
5
  import { createWorkflowApplyReport, workflowCommandsForPlan } from "./artifacts";
6
- import { moduleSourcePaths } from "./source";
6
+ import { buildAkanModuleContextIndex } from "./moduleIndex";
7
+ import {
8
+ insertionIndexForFieldOrder,
9
+ inspectConstantStructure,
10
+ inspectDictionaryStructure,
11
+ moduleComponentName,
12
+ moduleSourcePaths,
13
+ normalizeFieldType,
14
+ } from "./source";
7
15
  import type {
8
16
  PrimitiveChangedFile,
9
17
  PrimitiveGeneratedFile,
@@ -47,6 +55,14 @@ const postApplyDiagnostic = (code: string, message: string, target: string): Wor
47
55
  context: { target },
48
56
  });
49
57
 
58
+ const postApplyWarning = (code: string, message: string, target: string): WorkflowDiagnostic => ({
59
+ severity: "warning",
60
+ code,
61
+ message,
62
+ failureScope: "source-change",
63
+ context: { target },
64
+ });
65
+
50
66
  const sourceKindForPath = (filePath: string) => {
51
67
  if (filePath.endsWith(".tsx")) return ts.ScriptKind.TSX;
52
68
  if (filePath.endsWith(".ts")) return ts.ScriptKind.TS;
@@ -81,7 +97,8 @@ const checkTypeScriptSyntax = async (workspace: Workspace, filePath: string) =>
81
97
  if (!scriptKind) return null;
82
98
  const content = await workspace.readFile(filePath);
83
99
  const source = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true, scriptKind);
84
- const diagnostic = source.parseDiagnostics[0];
100
+ const diagnostic = ((source as ts.SourceFile & { parseDiagnostics?: readonly ts.DiagnosticWithLocation[] })
101
+ .parseDiagnostics ?? [])[0];
85
102
  if (!diagnostic) return null;
86
103
  const position = diagnostic.file?.getLineAndCharacterOfPosition(diagnostic.start ?? 0);
87
104
  const location = position ? `:${position.line + 1}:${position.character + 1}` : "";
@@ -136,6 +153,188 @@ const checkRecommendationPath = async (
136
153
  };
137
154
  };
138
155
 
156
+ const sourceChangeError = (diagnostics: readonly WorkflowDiagnostic[]) =>
157
+ diagnostics.some(
158
+ (diagnostic) =>
159
+ diagnostic.severity === "error" &&
160
+ (diagnostic.failureScope === "source-change" ||
161
+ !diagnostic.failureScope ||
162
+ diagnostic.failureScope === "unknown"),
163
+ );
164
+
165
+ const fieldCount = (fields: readonly string[], fieldName: string) =>
166
+ fields.filter((field) => field === fieldName).length;
167
+
168
+ const fieldOrderValid = (fields: readonly string[], fieldName: string) => {
169
+ const actualIndex = fields.indexOf(fieldName);
170
+ if (actualIndex < 0 || fields.lastIndexOf(fieldName) !== actualIndex) return false;
171
+ const fieldsWithoutRequested = fields.filter((_, index) => index !== actualIndex);
172
+ return insertionIndexForFieldOrder(fieldsWithoutRequested, fieldName) === actualIndex;
173
+ };
174
+
175
+ const workflowModuleContext = async (workspace: Workspace, plan: WorkflowPlan): Promise<AkanModuleContext | null> => {
176
+ const app = workflowStringInput(plan.inputs.app);
177
+ const moduleName = workflowStringInput(plan.inputs.module);
178
+ if (!app || !moduleName) return null;
179
+ const [apps, libs] = await workspace.getSyss();
180
+ const sysType = apps.includes(app) ? "app" : libs.includes(app) ? "lib" : null;
181
+ if (!sysType) return null;
182
+ const modulePath = `${sysType}s/${app}/lib/${moduleName}`;
183
+ const files = await workspace.readdir(modulePath);
184
+ const abstractPath = `${modulePath}/${moduleName}.abstract.md`;
185
+ return {
186
+ kind: "domain",
187
+ name: moduleName,
188
+ folderName: moduleName,
189
+ sysName: app,
190
+ sysType,
191
+ path: modulePath,
192
+ abstract: {
193
+ path: abstractPath,
194
+ exists: files.includes(`${moduleName}.abstract.md`),
195
+ headings: [],
196
+ },
197
+ files,
198
+ };
199
+ };
200
+
201
+ const structureCheck = (
202
+ code: string,
203
+ target: string,
204
+ status: WorkflowPostApplyCheck["status"],
205
+ message: string,
206
+ ): WorkflowPostApplyCheck => ({
207
+ code,
208
+ target,
209
+ status,
210
+ message,
211
+ });
212
+
213
+ const checkAddFieldStructure = async (workspace: Workspace, plan: WorkflowPlan): Promise<WorkflowStepResult> => {
214
+ if (plan.workflow !== "add-field" && plan.workflow !== "add-enum-field") return {};
215
+ const app = workflowStringInput(plan.inputs.app);
216
+ const moduleName = workflowStringInput(plan.inputs.module);
217
+ const fieldName = workflowStringInput(plan.inputs.field);
218
+ const typeName = workflowStringInput(plan.inputs.type);
219
+ if (!app || !moduleName || !fieldName || !typeName) return {};
220
+
221
+ const moduleContext = await workflowModuleContext(workspace, plan);
222
+ if (!moduleContext) return {};
223
+
224
+ const paths = moduleSourcePaths(moduleName);
225
+ const constantPath = `${moduleContext.path}/${paths.constant.replace(`lib/${moduleName}/`, "")}`;
226
+ const dictionaryPath = `${moduleContext.path}/${paths.dictionary.replace(`lib/${moduleName}/`, "")}`;
227
+ const moduleClassName = moduleComponentName(moduleName);
228
+ const inputClassName = `${moduleClassName}Input`;
229
+ const index = await buildAkanModuleContextIndex(workspace, moduleContext, { field: fieldName });
230
+ const diagnostics: WorkflowDiagnostic[] = [];
231
+ const postApplyChecks: WorkflowPostApplyCheck[] = [];
232
+
233
+ const constantContent = await workspace.readFile(constantPath);
234
+ const constantStructure = inspectConstantStructure(constantContent, inputClassName, moduleClassName);
235
+ const constantNames = constantStructure.fields.map((field) => field.name);
236
+ const requestedConstantFields = constantStructure.fields.filter((field) => field.name === fieldName);
237
+ const normalizedType = typeName.toLowerCase() === "enum" ? typeName : normalizeFieldType(typeName);
238
+ const constantFailures = [
239
+ !constantStructure.parseValid ? "constant file does not parse" : null,
240
+ !constantStructure.inputObjectFound ? `${inputClassName} via builder object was not found` : null,
241
+ requestedConstantFields.length !== 1
242
+ ? `field "${fieldName}" appears ${requestedConstantFields.length} time(s) in ${inputClassName}`
243
+ : null,
244
+ constantStructure.builderName &&
245
+ requestedConstantFields[0]?.expressionBuilder &&
246
+ requestedConstantFields[0].expressionBuilder !== constantStructure.builderName
247
+ ? `field "${fieldName}" uses builder "${requestedConstantFields[0].expressionBuilder}" instead of "${constantStructure.builderName}"`
248
+ : null,
249
+ !constantStructure.builderName ? `${inputClassName} via builder parameter was not found` : null,
250
+ (normalizedType === "Int" || normalizedType === "Float") && !constantStructure.baseImports.includes(normalizedType)
251
+ ? `missing ${normalizedType} import from "akanjs/base"`
252
+ : null,
253
+ workflowBooleanInput(plan.inputs.includeInLight) === true &&
254
+ fieldCount(constantStructure.lightProjectionFields, fieldName) !== 1
255
+ ? `field "${fieldName}" is not present exactly once in Light${moduleClassName}`
256
+ : null,
257
+ ...index.diagnostics
258
+ .filter((diagnostic) => diagnostic.severity === "error" && diagnostic.context?.paths?.includes(constantPath))
259
+ .map((diagnostic) => diagnostic.message),
260
+ ].filter((failure): failure is string => failure !== null);
261
+ const constantValid = constantFailures.length === 0;
262
+ postApplyChecks.push(
263
+ structureCheck(
264
+ constantValid ? "workflow-post-apply-constant-shape-valid" : "workflow-post-apply-structure-invalid",
265
+ constantPath,
266
+ constantValid ? "passed" : "failed",
267
+ constantValid
268
+ ? `${inputClassName} keeps via structure, builder usage, imports, and requested field presence.`
269
+ : constantFailures.join(" "),
270
+ ),
271
+ );
272
+ if (!constantValid) {
273
+ diagnostics.push(
274
+ postApplyDiagnostic("workflow-post-apply-structure-invalid", constantFailures.join(" "), constantPath),
275
+ );
276
+ }
277
+
278
+ const dictionaryContent = await workspace.readFile(dictionaryPath);
279
+ const dictionaryStructure = inspectDictionaryStructure(dictionaryContent, moduleClassName);
280
+ const dictionaryFailures = [
281
+ !dictionaryStructure.parseValid ? "dictionary file does not parse" : null,
282
+ !dictionaryStructure.modelObjectFound ? `.model<${moduleClassName}> object was not found` : null,
283
+ dictionaryStructure.modelObjectFound && !dictionaryStructure.chainOrderValid
284
+ ? `.model(), .slice(), .enum(), .error(), and .translate() chain order is broken: ${dictionaryStructure.chainMethods.join(
285
+ " -> ",
286
+ )}`
287
+ : null,
288
+ fieldCount(dictionaryStructure.fields, fieldName) !== 1
289
+ ? `field "${fieldName}" appears ${fieldCount(dictionaryStructure.fields, fieldName)} time(s) in .model<${moduleClassName}>`
290
+ : null,
291
+ ...index.diagnostics
292
+ .filter((diagnostic) => diagnostic.severity === "error" && diagnostic.context?.paths?.includes(dictionaryPath))
293
+ .map((diagnostic) => diagnostic.message),
294
+ ].filter((failure): failure is string => failure !== null);
295
+ const dictionaryValid = dictionaryFailures.length === 0;
296
+ postApplyChecks.push(
297
+ structureCheck(
298
+ dictionaryValid ? "workflow-post-apply-dictionary-shape-valid" : "workflow-post-apply-structure-invalid",
299
+ dictionaryPath,
300
+ dictionaryValid ? "passed" : "failed",
301
+ dictionaryValid
302
+ ? `.model<${moduleClassName}> keeps the requested field inside the model object and preserves dictionary chain order.`
303
+ : dictionaryFailures.join(" "),
304
+ ),
305
+ );
306
+ if (!dictionaryValid) {
307
+ diagnostics.push(
308
+ postApplyDiagnostic("workflow-post-apply-structure-invalid", dictionaryFailures.join(" "), dictionaryPath),
309
+ );
310
+ }
311
+
312
+ const constantOrderValid = fieldOrderValid(constantNames, fieldName);
313
+ const dictionaryOrderValid = fieldOrderValid(dictionaryStructure.fields, fieldName);
314
+ const orderValid = constantOrderValid && dictionaryOrderValid;
315
+ postApplyChecks.push(
316
+ structureCheck(
317
+ "workflow-post-apply-field-order-valid",
318
+ `${constantPath}, ${dictionaryPath}`,
319
+ orderValid ? "passed" : "failed",
320
+ orderValid
321
+ ? `Field "${fieldName}" follows the shared priority ordering policy.`
322
+ : `Field "${fieldName}" is present but does not match the shared priority ordering policy.`,
323
+ ),
324
+ );
325
+ if (!orderValid) {
326
+ diagnostics.push(
327
+ postApplyWarning(
328
+ "workflow-post-apply-field-order-mismatch",
329
+ `Field "${fieldName}" is present but does not match the shared priority ordering policy.`,
330
+ `${constantPath}, ${dictionaryPath}`,
331
+ ),
332
+ );
333
+ }
334
+
335
+ return { diagnostics, postApplyChecks };
336
+ };
337
+
139
338
  const resolveWorkflowSys = async (workspace: Workspace, target: string | null): Promise<Sys | null> => {
140
339
  if (!target) return null;
141
340
  const [apps, libs] = await workspace.getSyss();
@@ -173,7 +372,7 @@ const addFieldUiSurfaceInspection = (plan: WorkflowPlan): WorkflowStepResult =>
173
372
  const policy = addFieldUiPolicyForType(typeName ?? "String");
174
373
  const surfaces = workflowStringArrayInput(plan.inputs.surfaces);
175
374
  const templateRequested = surfaces?.includes("template") ?? false;
176
- const moduleClassName = capitalize(module);
375
+ const moduleClassName = moduleComponentName(module);
177
376
  const target = `${app ? `apps/${app}` : "*"}/${moduleSourcePaths(module).template}`;
178
377
  return {
179
378
  recommendations: [
@@ -182,10 +381,10 @@ const addFieldUiSurfaceInspection = (plan: WorkflowPlan): WorkflowStepResult =>
182
381
  kind: "manual-action",
183
382
  target,
184
383
  action: templateRequested
185
- ? `Template was requested for ${field}. If no Template file changed, auto-edit was skipped because the file was missing, the generated ${module}Form/Layout.Template pattern was not found, or ${policy.component} needs option binding. Candidate position: inside Layout.Template near the existing Field components.`
186
- : `Template was not selected, so UI files are intentionally left unchanged. Candidate positions if you expose it later: Layout.Template field list for editing, Light${moduleClassName} projection for list/card data, and Unit/View card sections for display.`,
384
+ ? `Template was requested for ${field}. If no Template file changed, users will not see the field in the form yet because the file was missing, the generated ${module}Form/Layout.Template pattern was not found, or ${policy.component} needs option binding. Add it inside Layout.Template near existing Field components.`
385
+ : `Template was not selected, so users will not see ${field} in the form from this apply. If list/card display is needed, include ${field} in Light${moduleClassName} projection data and place it in the local Unit/View card layout.`,
187
386
  confidence: "medium",
188
- message: `Review UI surfaces for ${module}.${field}; recommended component is ${policy.component}, with manual reasons and candidate positions in action.`,
387
+ message: `Review user-visible UI for ${module}.${field}; recommended component is ${policy.component}.`,
189
388
  },
190
389
  ],
191
390
  nextActions: [
@@ -362,6 +561,11 @@ export class WorkflowExecutor {
362
561
  postApplyChecks.push(...(result.postApplyChecks ?? []));
363
562
  diagnostics.push(...(result.diagnostics ?? []));
364
563
  }
564
+ if (!sourceChangeError(diagnostics)) {
565
+ const result = await checkAddFieldStructure(this.workspace, plan);
566
+ postApplyChecks.push(...(result.postApplyChecks ?? []));
567
+ diagnostics.push(...(result.diagnostics ?? []));
568
+ }
365
569
  const recommendationDiagnostics = await Promise.all(
366
570
  recommendations.map((recommendation) => checkRecommendationPath(this.workspace as Workspace, recommendation)),
367
571
  );
package/workflow/index.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  export * from "./artifacts";
2
2
  export * from "./executor";
3
+ export * from "./moduleIndex";
3
4
  export * from "./plan";
4
5
  export * from "./primitive";
5
6
  export * from "./render";
7
+ export * from "./rolloutGate";
6
8
  export * from "./source";
7
9
  export * from "./types";
8
10
  export * from "./uiPolicy";